Blog astro
astro

Hitting Lighthouse 95 on every page: the Cloudflare Pages performance budget for marketing sites

For the agency owner who keeps losing the 'why is your site faster than ours' argument with prospects: the Lighthouse CI budget config, the JS-shipping rules, and the image pipeline that ship Lighthouse 95+ on every page on Cloudflare Pages.

A horizontal performance bar with four filled violet segments and one unfilled, with four ascending tick marks above suggesting Core Web Vitals climbing.

Every agency owner we know has lost a pitch to “their site is faster than yours, what do you do about that?” The honest answer is usually “we forgot to look” — and neither that nor “we’ll optimize post-launch” closes the deal.

This is the setup we use to make the speed argument boring: Lighthouse 95+ mobile on every page, CWV all green, a CI gate that blocks merges when the budget breaks. The numbers below come from the real lighthouserc.json running on this site, the GitHub Actions config that enforces it, and the image and JS rules that hold the line.

TL;DR

  • Lock four budgets, not one Lighthouse score: LCP < 2.5s, INP < 200ms, CLS < 0.1, total bytes < 500 KB.
  • CI gates merge. A failing budget blocks the PR like a failing test.
  • JS in <head> is allowlisted to GTM. Everything else loads as a tag inside GTM after page-ready.
  • Images are AVIF-first via astro:assets, WebP fallback, three responsive widths per slot.
  • The site you’re reading hits 0.95+ performance mobile on every public route at ~180 KB total page weight.

Why Lighthouse 95 is the wrong goal (and what the right one is)

A 95 isn’t a goal. It’s a signal the underlying numbers are healthy. We’ve audited sites with a Lighthouse 96 that were sluggish in the wild, and sites at 89 that felt fast — what differs is whether field data agrees with the lab.

Pin the goal to four metrics that map to user experience: LCP, INP, CLS, total bytes. Lighthouse aggregates them into a score that moves with the lab environment. The metrics don’t. Web.dev’s Core Web Vitals reference is the canonical source:

  • LCP — under 2.5s at the 75th percentile.
  • INP (which replaced FID in March 2024) — under 200ms.
  • CLS — under 0.1.
  • Total byte weight — under 500 KB for pages without media-heavy heroes.

Why these four and not the full audit list? They’re what Google ranks on, what users feel, and what doesn’t lie when lab and field disagree. The other audits cascade from these.

The performance budget that actually holds the line

Here are the thresholds we lock in lighthouserc.json for every marketing site. These aren’t aspirational — they’re the numbers we’ll fail a build over.

MetricThresholdWhy
Performance score≥ 0.95Aggregate signal — useful as a tripwire
Accessibility score≥ 0.95Cheaper to fix at PR time than post-launch
Best Practices score≥ 0.95Catches mixed content, HTTP image leaks
SEO score1.00No reason a static marketing site shouldn’t hit 100
Largest Contentful Paint≤ 2,500 msGoogle’s “good” threshold
Interaction to Next Paint≤ 200 msGoogle’s “good” threshold
Cumulative Layout Shift≤ 0.1Google’s “good” threshold
Total byte weight≤ 512,000About 500 KB — keeps mobile devices on slow 4G readable

That last row is the one most teams skip and regret. A page can hit LCP < 2.5s on a fast device while being 4 MB. That same page is unusable on a Pixel 6a on rural 4G. The byte budget catches what a Lighthouse score won’t.

The full config — the actual lighthouserc.json running on emdashkit.dev:

{
  "ci": {
    "collect": {
      "staticDistDir": "./dist",
      "url": ["http://localhost/", "http://localhost/404.html"],
      "numberOfRuns": 3,
      "settings": {
        "preset": "desktop",
        "emulatedFormFactor": "mobile",
        "throttling": {
          "rttMs": 150,
          "throughputKbps": 1638.4,
          "cpuSlowdownMultiplier": 4
        }
      }
    },
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.95 }],
        "categories:accessibility": ["error", { "minScore": 0.95 }],
        "categories:best-practices": ["error", { "minScore": 0.95 }],
        "categories:seo": ["error", { "minScore": 1.0 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "interaction-to-next-paint": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-byte-weight": ["error", { "maxNumericValue": 512000 }]
      }
    },
    "upload": {
      "target": "temporary-public-storage"
    }
  }
}

A few notes on this config:

  • Three runs per URL. Lighthouse is noisy — one run can swing the perf score by 5 points on a clean Cloudflare Pages preview. Three runs, median asserted.
  • Mobile form factor with desktop preset. Most CI configs miss this. preset: "desktop" controls the audit set; emulatedFormFactor: "mobile" controls throttling and viewport. We want desktop’s stricter audits with mobile’s environment, since mobile is what Google ranks against.
  • Static dist directory. No live server, no flaky port races.

Lighthouse CI on every PR: the GitHub Actions config

Running this locally is fine. Running it on every PR is the difference between a budget and a wish.

# .github/workflows/portal-site-ci.yml — abridged
jobs:
  lint-test-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 10 }
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with: { name: portal-site-dist, path: portal-site/dist }

  lighthouse:
    needs: lint-test-build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 10 }
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - uses: actions/download-artifact@v4
        with: { name: portal-site-dist, path: portal-site/dist }
      - run: pnpm lighthouse

pnpm lighthouse is just lhci autorun, which reads the JSON config above and exits non-zero on any assertion failure. GitHub marks the PR as failed and blocks merge if you’ve protected the branch (which you should).

Two things this catches that human review won’t: a new Hotjar tag in GTM that ships another 60 KB and slips INP past 200ms, and a 1.4 MB hero PNG dropped in src/assets/ with <img> instead of <Image> blowing past 500 KB.

What this doesn’t catch is real-world INP — Lighthouse measures lab INP, a synthetic estimate. Real INP lives in the Chrome User Experience Report. Lab is the gate; CrUX field is the audit.

The JS-shipping rules (the only third-party scripts allowed in <head>)

Most marketing sites that miss Lighthouse 95 miss it on JavaScript — not framework JS (Astro ships almost none) but the third-party scripts marketing pastes into the head tag. The rule:

Only one third-party <script> ever loads in <head>: GTM.

Everything else — GA4, ad pixels, Hotjar, intercom, conversion APIs — loads as a tag inside GTM, on a trigger that fires after the page is interactive. We covered the GTM allowlist in GA4, GTM, and UTM tracking on Astro + EmDash; the performance argument:

  • One CSP allowlist entry, not seven. Smaller header, faster parsing.
  • One blocking script, not seven. A Hotjar tag inside GTM doesn’t add a second blocking request — GTM lazy-loads it after page-ready.
  • Marketers add tags without developer tickets. No more Friday-5pm “just paste this LinkedIn pixel real quick.”

The Astro side is one inline script in the base layout, guarded against placeholder IDs so dev and CI builds never load GTM:

---
import { PUBLIC_GTM_ID } from 'astro:env/client';
const enabled =
  PUBLIC_GTM_ID && PUBLIC_GTM_ID.startsWith('GTM-') && PUBLIC_GTM_ID !== 'GTM-XXXXXXX';
---
{enabled && (
  <script is:inline define:vars={{ id: PUBLIC_GTM_ID }}>
    {`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer',id);`}
  </script>
)}

The placeholder guard means Lighthouse runs against a GTM-free build — the budget tests our code, not Google’s. Production loads GTM with the real ID, and j.async=true keeps it off the parser path so LCP isn’t affected.

The CSP that backs this — one line in public/_headers:

/*
  Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com; img-src 'self' data: https:; connect-src 'self' https://www.google-analytics.com https://www.googletagmanager.com; frame-src https://www.googletagmanager.com

Add tracker domains here as you add tags in GTM. The CSP is the only file touched — never the page templates.

Images: AVIF, WebP, astro:assets, and the responsive variant rules

Images are usually 60–80% of a page’s bytes. The single biggest perf win on most sites is the image pipeline.

The rules:

  1. Always import via astro:assets. Never raw <img> for site-managed assets. The Astro images docs cover the API; <Image> does responsive srcset, format conversion, and dimensions for free.
  2. AVIF first, WebP fallback. AVIF is 30–50% smaller than WebP at equivalent quality. Browser support is universal — Safari 16+, Chrome 85+, Firefox 113+.
  3. Three responsive widths per slot, not seven. Default astro:assets generates a width per breakpoint if you let it. We cap at three: 400w, 800w, 1600w. Enough for 75th-percentile mobile, midrange tablet, and desktop retina. More widths means more build time and CDN cache fragmentation with no real LCP improvement.
  4. Width and height always. CLS prevention rule. astro:assets does this automatically on import; for URL-loaded images, set them by hand.

The <Image> call we use for hero images:

---
// src/components/blocks/Hero.astro
import { Image } from 'astro:assets';
import heroImg from '~/assets/heroes/saas-landing.png';
---
<section class="hero">
  <div class="hero__copy">
    <h1>Headline goes here</h1>
    <p>Lede paragraph.</p>
  </div>
  <Image
    src={heroImg}
    alt="Diagrammatic illustration of the SaaS landing page template"
    widths={[400, 800, 1600]}
    sizes="(min-width: 960px) 50vw, 100vw"
    formats={['avif', 'webp']}
    quality={72}
    loading="eager"
    fetchpriority="high"
  />
</section>

Three attributes worth attention:

  • loading="eager" and fetchpriority="high" on the hero. The hero is almost always the LCP element; tell the browser to prioritize it. Lazy-loading is for everything else.
  • quality={72}. AVIF at 72 is visually indistinguishable from JPEG at 90 for photographs. Marketing-ready quality, half the bytes.
  • sizes="(min-width: 960px) 50vw, 100vw". The browser picks the right srcset entry from this. Get it wrong and mobile downloads the 1600px version, blowing both LCP and byte budget at once.

CSS: vanilla, tokens, no JIT — and why

Vanilla CSS plus design tokens ships about 10–18 KB gzipped for a typical marketing site. Tailwind configured well ships about 12–25 KB. Tailwind configured carelessly (JIT pulling in unused utilities) ships 60+ KB. That gap matters at byte-budget level.

The bigger reason isn’t byte count — it’s that CSS-in-JS and Tailwind both move CSS work to JS runtime or build-time JIT. Both add INP risk. Vanilla CSS plus tokens means a marketer can ask Claude “make the primary button green,” Claude edits one variable in tokens.css, the build is 80ms, and we’re done. With component-scoped CSS (Astro handles scoping), a typical page ships ~14 KB of CSS total.

When the budget genuinely needs to bend (and how to do it without lying)

Sometimes the budget is wrong for the page. A pricing comparison with a 9-row interactive table needs more JS. A case study with embedded video can’t ship under 500 KB. Pretending otherwise produces bad pages or bad metrics.

The honest move is to bend per-route, not lower globally. lighthouserc.json supports per-URL assertions:

{
  "ci": {
    "assert": {
      "assertions": {
        "total-byte-weight": ["error", { "maxNumericValue": 512000 }]
      },
      "assertMatrix": [
        {
          "matchingUrlPattern": "case-studies/.*",
          "assertions": {
            "total-byte-weight": ["error", { "maxNumericValue": 1500000 }]
          }
        }
      ]
    }
  }
}

Now / and /services/* stay under 500 KB; /case-studies/* is allowed up to 1.5 MB. The bend is documented in code, the global default is preserved, and nobody later inherits the loose budget by accident.

What we don’t do: lower the global threshold to let through one bad page. That’s the slippery slope where six months later your site is 3 MB and nobody’s checking. Per-route bends, with a comment.

Frequently asked questions

What’s the realistic Lighthouse score for a content-heavy marketing site on Cloudflare Pages?

In our experience, 96–99 mobile is the working range for a typical Astro marketing site. We hit 99 on the homepage of emdashkit.dev, 97 on service pages with multiple images, and 96 on long blog posts with inline figures. Below 95 mobile, something is wrong — usually a render-blocking font or an unguarded GTM tag.

Does Lighthouse CI work for sites with dynamic auth or paywalls?

Lighthouse CI works on static HTML it can serve from ./dist. For pages that render only after login, either skip them in the assertions and audit separately against staging, or ship a static “logged-out” version. Most marketing sites don’t hit this — exclude any gated routes from the url array.

How much does GTM actually cost on the perf budget?

About 60–80 KB of JS gzipped and 30–50ms of main-thread time on the median mobile device. Measurable but doesn’t move LCP — GTM loads async. It does move INP if your tags inside GTM run heavy JavaScript. Pixels and pageviews are fine; custom HTML tags running on every click are not.

Should we use Cloudflare Web Analytics instead of GA4 to “save bytes”?

Cloudflare Web Analytics is lighter (~3 KB), but GA4 is what your prospects’ marketing teams know how to read. We ship both: GA4 inside GTM for marketing, Cloudflare Web Analytics for ops dashboards. Combined load stays under 100 KB. The right answer is “ship the analytics the team will actually use” — see the Cloudflare Pages docs for the integration.

What about INP — does Lighthouse really measure it correctly?

Lab INP (what Lighthouse runs) is an estimate from simulated interactions. Real INP comes from CrUX field data. We use lab INP as the merge gate (deterministic CI signal) and field INP from CrUX as the production audit. Per web.dev’s INP guide, the 75th-percentile field number is what Google ranks against.

Wrapping up

The argument the agency owner is losing is “your site is faster than ours.” The argument they should be making is “we ship Lighthouse 95+ mobile on every page, with CI gating every PR against the same budget your CTO would set.” That’s specific, the mechanism is described, and it’s testable post-launch — the difference between performance as a marketing claim and performance as a contractual artifact.

If you want this set up on an existing site, the optimization retainer is where this work lives — we run the budget gate, watch field-data INP, and ship perf fixes monthly. To bake it into a migration from day one, the WordPress migration playbook folds these gates into week 2.

The next post covers the field-data side — pulling CrUX into a quarterly report that tells the client what real users see, not just what CI asserts. If you’d rather have us run the whole pipeline on retainer, the intro call is below.

Need help applying any of this?

We do this for clients every week. 30 minutes, no obligation.