Core Web Vitals

A JavaScript Budget for a Marketing Site, Enforced in CI

How much JavaScript a marketing site should ship, how to set a budget per route, and how to fail the build when a dependency or a tag pushes it over.

6 min read
Core Web VitalsJavaScriptWorkflow
1,058 words6 min read

JavaScript is the expensive byte

A kilobyte of image costs bandwidth. A kilobyte of JavaScript costs bandwidth, then parsing, then compilation, then execution, on the main thread, on a phone whose CPU is a fraction of a laptop's. It is the resource type most strongly correlated with poor Interaction to Next Paint, and it is the one that grows without anyone deciding it should: a date library here, an animation package there, a tag manager with twenty tags.

A JavaScript budget is a number per route that the site must stay under, checked on every build. When a change exceeds it, the build fails and someone decides, with the number in front of them, whether the feature is worth the weight.

How much is reasonable

For a marketing or business site (not an application), on the initial load of a route, compressed over the wire:

| Route type | First-party JS | Third-party JS | Total | |---|---|---|---| | Home, landing pages | ≤ 80 KB | ≤ 60 KB | ≤ 140 KB | | Content pages, blog | ≤ 60 KB | ≤ 40 KB | ≤ 100 KB | | Interactive (booking form, configurator) | ≤ 150 KB | ≤ 60 KB | ≤ 210 KB |

These are budgets we have hit on real Next.js sites with a framework runtime included, and they leave headroom under the point where INP starts to suffer on mid-range phones. A fully server-rendered marketing page with a couple of small islands lands well under; a page that pulls in a UI kit and a chart library does not, and that is the point of writing the number down.

Set the budget slightly below your current good state, so growth is what triggers it, not the status quo.

Measuring what you ship

Bundle analysis shows where first-party bytes come from. In Next.js, @next/bundle-analyzer produces a treemap per route. Look for: a dependency imported in full when one function is used; the same library twice at different versions; a large component in the shared chunk that only one page needs; polyfills for browsers you do not support.

Route-level totals from the build output. next build prints First Load JS per route. That column is the number to budget.

Third-party bytes are not in the build; they arrive at runtime from the tag manager and the widgets. Measure them in a Lighthouse run (the "Reduce the impact of third-party code" audit lists each origin with its size and main-thread time) or in a WebPageTest waterfall.

Enforcing first-party size: size-limit

size-limit checks bundle sizes against a config and fails when over. For a Next.js app, point it at the built chunks for each route's entry, or use its @size-limit/file preset against the .next/static/chunks a route loads. A simpler and often sufficient version: assert on the build output's First Load JS with a small script that parses next build's route table, or reads .next/build-manifest.json and sums the gzipped sizes of each page's files:

JavaScript
// scripts/check-js-budget.mjs
import { readFileSync, statSync } from 'node:fs'
import { gzipSync } from 'node:zlib'

const BUDGET_KB = { '/': 80, '/blog/[slug]': 60, '/contact': 100 }
const manifest = JSON.parse(readFileSync('.next/build-manifest.json', 'utf8'))
let failed = false

for (const [route, files] of Object.entries(manifest.pages)) {
  const kb = files
    .filter((f) => f.endsWith('.js'))
    .reduce((sum, f) => sum + gzipSync(readFileSync(`.next/${f}`)).length, 0) / 1024
  const budget = BUDGET_KB[route]
  const flag = budget && kb > budget ? 'OVER' : 'ok  '
  if (flag === 'OVER') failed = true
  console.log(`${flag} ${route.padEnd(40)} ${kb.toFixed(1)} KB${budget ? ` / ${budget}` : ''}`)
}
process.exit(failed ? 1 : 0)

Run it after next build in CI. The App Router's manifest layout differs slightly across versions; the shape of the check is what matters: sum the gzipped JS a route loads, compare to a table, fail on over.

Enforcing total size and third parties: Lighthouse CI

Lighthouse CI runs against a deployed preview and applies a budget file covering resource sizes by type, including script and third-party, plus timing metrics. It catches what the build cannot see: the tag that marketing added through the tag manager last Tuesday.

JSON
[{
  "path": "/*",
  "resourceSizes": [
    { "resourceType": "script", "budget": 140 },
    { "resourceType": "third-party", "budget": 60 }
  ],
  "timings": [
    { "metric": "total-blocking-time", "budget": 150 }
  ]
}]

Assertions on resource-summary:script:size and the third-party audit fail the run when exceeded. Run it on preview deploys so the failure is on the pull request, not on production.

The tag manager problem

Most third-party JavaScript on a marketing site arrives via a tag manager that marketing controls and the build never sees. Three controls:

  1. The Lighthouse CI budget above, run on a schedule against production as well as on previews, so a new tag trips it within a day.
  2. A tag review rule: new tags are added on a staging container first, measured, and the delta recorded. "Heat-mapping tool: +190 KB, +280 ms TBT" is a decision, not a default.
  3. Consent Mode and triggers so heavy tags fire on interaction or after a delay, keeping them out of the initial budget.

When the budget is exceeded

  • Make it cheaper. Import the one function, not the library. Load the widget on interaction. Move the chart to a route that needs it. Replace the animation library with CSS.
  • Trade. Remove the second analytics tool to afford the booking widget.
  • Raise the budget, deliberately. With a comment in the config saying why, by whom, and what was measured. A budget raised with a reason is a decision; a budget silently raised is no budget.
  • Do not ship it.

Keeping it visible

Print the per-route totals in the CI log on every build, even when passing, so the trend is visible in the history. Put the current numbers in the monthly maintenance report next to the field INP. When the two move together, the budget has earned its place.

Where this sits

A JavaScript budget with a build-time check and a Lighthouse CI budget on previews is part of the CI setup for every Next.js site we deliver, and setting one is a recommendation in nearly every Core Web Vitals audit where INP is failing, because INP fixed once and never guarded is INP that will fail again by spring.

All writingHire me for this