Performance & Accessibility

Performance budgets

The site is a lead-generation marketing site, so every wasted kilobyte and every extra round-trip costs a prospect — performance is treated as a hard contract with fixed ceilings, not a "nice to have" we tune later.

Two things make up this page: the budgets (non-negotiable ceilings every page must respect — weight, requests, formats, script count) and the Phase C performance wins (the concrete launch-time work that brought a heavy lead-magnet island and a multi-megabyte background image back under those ceilings and added an edge cache in front of the Worker). Accessibility lives on /docs/accessibility; the image and font pipelines are detailed on /docs/images-fonts.


The hard budgets

These are the ceilings from website/CLAUDE.md ("Performance budgets") and _design-best-practices/global-rules.md ("Page Speed Budgets"). They are the same numbers stated in two places, and they are limits, not targets:

BudgetLimit
First-load page weight< 2 MB
Hero alone< 500 KB
HTTP requests on first load≤ 80
Raster image formatAVIF / WebP only (PNG only when lossless is genuinely required; SVG for logos/icons)
Animated GIFsNone (convert to looping <video> or animated WebP)
Web fontsWOFF2 only, ≤ 6 files
Third-party scripts≤ 5, all injected via GTM
jQueryNot allowed

The design-best-practices file adds the field evidence behind each ceiling: audited sites with 150–238 requests "universally show poor performance," 7–30+ trackers "measurably degrade LCP, TBT, INP," and hero videos in the 1.6–8 MB range "were the single largest perf failure observed." The numbers aren't arbitrary — they're where real sites fall over.

GTM is the only injection point for third-party scripts

GA4 and the HubSpot tracking pixel load inside GTM, never as a direct <script> in any .astro file. The GTM container ID (GTM-5NDTZBHF) is hardcoded in Layout.astro (both the script and noscript snippets) — deliberately not an env var, because a constant that's wrong is easier to spot than an env-driven value that silently fails to load analytics. The ≤ 5 third-party-script budget is enforced by routing everything through this single container.

Fonts: the 6-file cap, by construction

The font budget isn't a count we hope to stay under — the typeface strategy makes 6 the natural number. There are three families (Archivo / Figtree / Aleo), each shipped as one variable WOFF2 per style (roman + italic), and the actual weight/width cut is applied per element with font-variation-settings rather than separate weight files. Three families × two styles = exactly six files in public/fonts/:

archivo-vf.woff2          archivo-italic-vf.woff2
figtree-vf.woff2          figtree-italic-vf.woff2
Aleo-VF.woff2             aleo-italic-vf.woff2

All are declared with @font-face + font-display: swap in src/styles/global.css. Self-hosted only — no Google Fonts CDN, ever. See /docs/typography and /docs/images-fonts for the full type system.

Images

Sanity-sourced images use a raw <img> with a hand-rolled srcset built from urlFor(image).width(W).format('webp').url() — Astro's <Image> is reserved for local public/ assets because it reprocesses remote URLs and clobbers Sanity's transform pipeline. The format('webp') (or AVIF) call is what keeps raster images inside the format budget.

urlFor() throws on asset-less images

urlFor() (src/lib/image.ts) throws when an image has no resolvable asset, and an unhandled throw in a section's frontmatter blanks the whole page — Astro discards the SSR stream after the doctype, and the Cloudflare dev adapter swallows the trace, so it fails silently. A Sanity image field is not safe to truthy-check: clearing the asset but leaving a sub-field (e.g. alt) leaves a ghost {_type:'image', alt:'…'} that is truthy but asset-less. Always guard on ?.asset (if (!img?.asset) return ''), never on the object.


Phase C — the launch-time performance wins

Phase C of the _PRODUCTION LAUNCH ROADMAP.md was the perf pass: a set of high-leverage fixes that took the site from over-budget to comfortably under it. All of the items below are landed and verified in the live files.

Cut the audit-island weight

The free-website-audit lead magnet (/docs/audit-overview) validates submitted emails against disposable-email-domains, a 2.4 MB JSON list. Statically importing it inlined the whole list into the client:load React island and shipped it to every visitor on first paint — a ~2 MB tax on the highest-intent page.

It now loads as a separate async chunk. src/components/audit/disposable.ts imports the list dynamically and memoizes the promise, and preloadDisposableList() warms it when the email gate appears (a useEffect keyed on teaser in WebsiteAudit.tsx) so the list is usually resident before the visitor finishes typing:

let loadPromise: Promise<Set<string>> | null = null;

function loadDomains(): Promise<Set<string>> {
  loadPromise ??= import('disposable-email-domains').then(
    (m) => new Set<string>((m.default as string[]).map((d) => d.toLowerCase())),
  );
  return loadPromise;
}

The check fails open — a chunk-load failure never blocks a real lead.

Never import the disposable list statically

A static import 'disposable-email-domains' anywhere in the island re-inlines the 2.4 MB list into the first-paint bundle and silently blows the < 2 MB budget on the audit page. Keep it a dynamic import() inside disposable.ts.

Replace the heavy background image

The closing conversion band (CtaSection, on most pages) previously sat on a 3.0 MB mesh-gradient-bg.png with bg-fixed. Both were problems: the PNG alone blew past the hero/weight budgets, and bg-fixed (background-attachment: fixed) is broken on iOS and forces repaints.

The live CtaSection.astro uses an AVIF background and a compositor-only parallax transform instead of bg-fixed:

class="cta-parallax pointer-events-none absolute inset-x-0 bg-[url(/img/mesh-gradient-bg.avif)] bg-cover bg-center"

The parallax is driven by a transform (compositor-only, GPU-cheap) rather than background-attachment: fixed, so it scrolls smoothly and doesn't repaint. See /docs/components for the CTA section details.

Edge cache the Worker (src/middleware.ts)

Every uncached view otherwise costs a Worker invocation plus two serialized Sanity round-trips — the route query, then the globalStyles color-token fetch in Layout.astro. The edgeCache middleware puts the Cloudflare Cache API in front of that for anonymous, published-content responses, with a 5-minute TTL:

const EDGE_TTL_SECONDS = 300;

Behavior, straight from the live middleware:

  • Draft preview bypasses entirely. The cache only serves anonymous published responses; if (isDraftMode(context.cookies)) return next() short-circuits before any cache read, so editors always see live drafts. Published edits reach anonymous visitors within EDGE_TTL_SECONDS.

  • Dev, non-GET, and /api/* are skipped (import.meta.env.DEV, request.method !== 'GET', pathname.startsWith('/api/')).

  • Tracking params are stripped from the cache key so campaign and organic traffic share one cache entry — GTM reads utm_* / gclid / fbclid / msclkid / hsa_ client-side, so they never change the rendered HTML:

    const TRACKING_PARAMS = /^(utm_|gclid$|fbclid$|msclkid$|hsa_)/;
    
  • Set-Cookie and non-200 responses are never cached (if (res.status !== 200 || res.headers.has('Set-Cookie')) return res) — a 404/503 or any recovery response must stay live.

  • The cache write is fire-and-forget via cfContext.waitUntil and .catch(() => undefined) — a cache-write failure must never take down the response.

Return cache hits as a mutable Response copy or every hit 503s

Cache API responses carry immutable headers, and Astro's middleware finalizer mutates response headers. A bare return hit therefore throws on every cache hit. The fix is to copy it: if (hit) return new Response(hit.body, hit). Don't "simplify" this back to returning the raw hit.

locals.runtime.ctx is a throwing getter — use locals.cfContext

@astrojs/cloudflare ≥ v13 exposes the ExecutionContext as locals.cfContext. The old locals.runtime.ctx is a getter that throws, and optional chaining can't guard a throwing getter — reading it 503'd every uncached page in production. Read cfContext?.waitUntil only.

The Cache API is active on the workers.dev host

Despite older docs (and the tunables note) saying the Cache API is inert on *.workers.dev, it was verified active in June 2026 — cache hits were observed in production tails. The middleware comment is the current source of truth here.

The cache also pairs with a per-isolate memo on the globalStyles fetch itself (TTL_MS, default 60_000 ms, in src/sanity/queries/globalStyles.ts) — that memo only applies to the published client; draft clients always bypass it. See /docs/design-tokens for how globalStyles drives the theme.

Because the site is fully SSR, prefetching the next page warms it (browser/edge cache) before the click lands. One line in astro.config.mjs:

prefetch: {prefetchAll: true, defaultStrategy: 'viewport'},

'viewport' prefetches every link as it scrolls into view (fastest next-click, more Worker invocations); switch to 'hover' to trade next-click speed for fewer invocations.

Prune and preload fonts

The dead LeagueSpartan-VF.woff2 (declared but never referenced — 8 files against the 6-cap) was deleted, bringing the count back to the 6 listed above. The three roman variable fonts used above the fold are preloaded in Layout.astro — Archivo (display/hero/wordmark), Figtree (body), and Aleo (the --font-mono .eyebrow labels, which otherwise FOUT above the fold):

<link rel="preload" href="/fonts/archivo-vf.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/figtree-vf.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/Aleo-VF.woff2" as="font" type="font/woff2" crossorigin />

The italics aren't above the fold, so they load on demand (no preload).

Gate AnimDebug out of production

AnimDebug.astro is the scroll-reveal trigger-line overlay — editor tooling, never for visitors. It's gated in Layout.astro so it only renders in dev or draft mode:

{(import.meta.env.DEV || draftMode) && <AnimDebug />}

Production visitors never get the debug button or its script. See /docs/scroll-reveals for the reveal system it instruments.

Hero fetchpriority="high"

The eager hero-explorer background image carries loading="eager" and fetchpriority="high" so the LCP image is requested ahead of lower-priority resources:

<img
  class="hero-explorer-bg-img absolute inset-0 h-full w-full object-cover"
  src={defaultImageUrl}
  srcset={defaultImageSrcSet}
  sizes="50vw"
  width="1280"
  height="960"
  loading="eager"
  fetchpriority="high"
  decoding="async"
/>

How the two-build split affects performance

The public site builds as static prerendered HTML (BUILD_TARGET=static), so its content pages have no Worker and no middleware at runtime — they're static assets served from the edge, immune to a Sanity outage. The edge cache and the globalStyles memo above matter most for the staging SSR build (and any future SSR public deploy). See /docs/two-build-split and /docs/deployment for the full split.


Where this lives

ConcernFile
Hard budgets (weight, requests, formats, scripts)website/CLAUDE.md → "Performance budgets (hard limits)"
Same budgets + field evidencewebsite/_design-best-practices/global-rules.md → "Page Speed Budgets"
Phase C task list_PRODUCTION LAUNCH ROADMAP.md → "Phase C — Performance"
Edge cache + tracking-param strippingwebsite/src/middleware.ts (EDGE_TTL_SECONDS, TRACKING_PARAMS, edgeCache)
globalStyles per-isolate memowebsite/src/sanity/queries/globalStyles.ts (TTL_MS)
Prefetch configwebsite/astro.config.mjs (prefetch)
Font preload + GTM containerwebsite/src/layouts/Layout.astro
Font declarations (@font-face)website/src/styles/global.css
Disposable-email lazy chunkwebsite/src/components/audit/disposable.ts
AVIF CTA background + parallaxwebsite/src/components/sections/CtaSection.astro
Hero fetchprioritywebsite/src/components/sections/HeroExplorerSection.astro
Tunable knobs (TTL, prefetch, preload)website/tunables.md → "Edge caching", "Link prefetch strategy"
Previous
Launch & operations