Tracking Bundle Size per Pull Request

The most common performance regression on a static site is not a slow algorithm or a layout bug. It is a dependency. Someone imports a date library to format one date, a component library for one button, or a syntax highlighter's full language pack for three languages, and the page's JavaScript doubles. The diff looks innocent — one import line — and a reviewer cannot see the 60 KB behind it.

Bundle-size tracking makes that cost visible on the pull request. It reads the built output, compares each asset's compressed size with the base branch, posts a table as a comment and fails when a budget is crossed. It needs no browser and no deploy, runs in about ten seconds, and never flakes. It complements the page-level checks in Performance Budgets and Lighthouse CI.

Prerequisites

  • A static site built in CI with hashed asset filenames (Astro, Vite, webpack, Hugo Pipes and Eleventy with a bundler all produce these).
  • Somewhere to store the main branch's latest size report — a workflow artifact is enough.
  • Permission for the workflow to comment on pull requests (pull-requests: write).

Step 1: Measure the Build Output

Hashed filenames change on every build, so compare assets by a stable key: the filename with the hash removed. A short Node script walks the output directory, compresses each JavaScript, CSS and font file with brotli and gzip, and writes a report:

// scripts/size-report.mjs
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { brotliCompressSync, gzipSync, constants } from 'node:zlib';

const root = process.argv[2] ?? 'dist';
const out = {};
const walk = (dir) => readdirSync(dir).forEach((f) => {
  const p = join(dir, f);
  if (statSync(p).isDirectory()) return walk(p);
  if (!/\.(m?js|css|woff2)$/.test(f)) return;
  const buf = readFileSync(p);
  const key = relative(root, p).replace(/[.-][A-Za-z0-9_-]{8,}(?=\.\w+$)/, '');   // strip hash
  out[key] = {
    raw: buf.length,
    gzip: gzipSync(buf, { level: 9 }).length,
    brotli: /\.woff2$/.test(f) ? buf.length
      : brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length,
  };
});
walk(root);
writeFileSync('size-report.json', JSON.stringify(out, null, 2));

Fonts in WOFF2 are already compressed, so their "brotli" size is just their size. Images are deliberately excluded here; they are better budgeted per page by Lighthouse, because what matters is which image a template actually loads.

Step 2: Diff Against Main and Comment

The pull request job downloads the latest report from main, diffs it and posts a comment. Only changed assets are listed, sorted by absolute change, so the comment stays short:

// scripts/size-diff.mjs
import { readFileSync } from 'node:fs';
const base = JSON.parse(readFileSync('base/size-report.json'));
const head = JSON.parse(readFileSync('size-report.json'));
const kb = (n) => (n / 1024).toFixed(1);
const rows = [...new Set([...Object.keys(base), ...Object.keys(head)])]
  .map((k) => ({ k, b: base[k]?.brotli ?? 0, h: head[k]?.brotli ?? 0 }))
  .filter((r) => r.b !== r.h)
  .sort((x, y) => Math.abs(y.h - y.b) - Math.abs(x.h - x.b));
const total = (r, t) => Object.entries(r).filter(([k]) => k.endsWith(t))
  .reduce((s, [, v]) => s + v.brotli, 0);
const jsBase = total(base, '.js'), jsHead = total(head, '.js');
let md = `### Asset size changes (brotli)\n\n| Asset | main | this PR | Δ |\n|---|---|---|---|\n`;
for (const r of rows.slice(0, 15))
  md += `| \`${r.k}\` | ${kb(r.b)} KB | ${kb(r.h)} KB | ${r.h > r.b ? '+' : ''}${kb(r.h - r.b)} KB |\n`;
md += `\n**Total JS:** ${kb(jsBase)} KB → ${kb(jsHead)} KB\n`;
console.log(md);
if (jsHead > 60 * 1024) { console.error('JS total over 60 KB budget'); process.exitCode = 1; }
# .github/workflows/size.yml (excerpt)
- run: npm run build && node scripts/size-report.mjs dist
- uses: dawidd6/action-download-artifact@v6
  with: { workflow: size.yml, branch: main, name: size-report, path: base }
- id: diff
  run: node scripts/size-diff.mjs > comment.md
- uses: marocchino/sticky-pull-request-comment@v2
  if: always()
  with: { path: comment.md }

On pushes to main, the same workflow uploads size-report.json as the size-report artifact, so the next pull request has a fresh baseline. The sticky comment updates in place on every push instead of stacking new comments.

How the size check compares a pull request with main Builds of main upload a size report artifact. A pull request build produces its own size report, downloads main's report, diffs the two by hash-stripped filename, posts a sticky comment listing changed assets, and fails if a budget is exceeded. The whole job takes about ten seconds after the build. Two reports, one diff, ten seconds push to main build + size report pull request build + size report artifact size-report.json diff by key hash stripped sticky PR comment fail if over budget
Because main uploads a fresh report on every push, the pull request always compares against what is actually live.

Step 3: Budget per Template, Not Just in Total

A site-wide JavaScript total hides where bytes land. A 30 KB search dialog that loads only on the docs template is fine; the same 30 KB on every page is not. Build a per-template view by parsing one representative HTML file per template and summing the sizes of the scripts and stylesheets it references:

// scripts/template-weights.mjs (excerpt)
const TEMPLATES = { home: 'dist/index.html', guide: 'dist/guides/deploy/index.html',
                    post: 'dist/blog/2026/launch/index.html' };
for (const [name, file] of Object.entries(TEMPLATES)) {
  const html = readFileSync(file, 'utf8');
  const refs = [...html.matchAll(/(?:src|href)="\/([^"]+\.(?:js|css))"/g)].map((m) => m[1]);
  const bytes = refs.reduce((s, r) => s + (report[r.replace(HASH, '')]?.brotli ?? 0), 0);
  console.log(`${name}: ${(bytes / 1024).toFixed(1)} KB`);
}

This catches module preloads and islands that the template actually loads, and it is the same template list Lighthouse CI tests, so the two checks speak the same language. For Astro sites, note that island scripts load on interaction or visibility; count them separately as "deferred" so an eager and a lazy 20 KB are not treated as equal.

The same site total, split by template A site-wide JavaScript total of 58 kilobytes looks acceptable. Split by template, the homepage loads 14 kilobytes eagerly, a guide loads 9 eagerly plus 31 deferred for search, and a blog post loads 9 eagerly plus 18 deferred for a comments island. Only the per-template view shows that a shared chunk grew on every page. What each template actually loads (KB brotli) home 14 eager guide 9 eager + 31 deferred (search) post 9 eager + 18 deferred (comments) loads with the page island, on interaction or visibility site-wide total: 58 KB — a single number that hides all of the above
Budget eager bytes tightly per template; deferred islands can have a looser, separate limit.

Measured Impact

An Astro marketing and docs site ran bundle-size comments for five months. Every pull request that changed a JavaScript or CSS asset by more than 1 KB was logged:

MeasureValue
Pull requests with asset changes over 1 KB64
Of those, increases over 10 KB14
Increases reduced before merge after the comment11
Median reduction when reduced71% of the increase
Budget failures (60 KB JS total)3
Job duration after build (median)9 s
Five large increases and what the author did Paired bars for five pull requests showing the increase first reported and the increase after the author responded. A date library went from 67 KB to 2 KB by using Intl.DateTimeFormat. An icon set went from 48 KB to 3 KB by importing individual icons. A syntax highlighter went from 212 KB to 0 by highlighting at build time. A carousel went from 31 KB to 14 KB with a lighter library. A form validator went from 22 KB to 22 KB and was accepted. First push vs merged (KB brotli added) date library 67 → 2 · Intl.DateTimeFormat icon set 48 → 3 · per-icon imports syntax highlighter 212 → 0 · highlighted at build time carousel 31 → 14 · lighter library form validator 22 → 22 · accepted, budget raised
The comment rarely forced a change; it made the cost visible, and authors chose cheaper options on their own.

The largest single saving came from a syntax highlighter that would have shipped 212 KB of grammars to every docs page. Once the number appeared in the comment, the author moved highlighting to build time with Shiki, which every mainstream generator supports, and the runtime cost went to zero.

Reading the Comment in Review

A size comment is only useful if reviewers know what to look for. Three patterns cover most cases. A new file appearing in the list usually means a new dependency or a new island; ask whether it loads on every page or only where needed. A shared chunk growingvendor, client, index — means a dependency moved from one page's bundle into code every page loads; that is often an accidental import in a layout component. A CSS file growing by more than a kilobyte or two usually means a utility framework's purge step missed a new content path, or a component library's full stylesheet was imported. Each has a different fix, and naming them in the contributing guide shortens review conversations considerably.

Pitfalls & Rollback

  • Comparing raw sizes. Readers download compressed bytes. Compare brotli or gzip, whichever your host serves.
  • Hash-sensitive keys. Without stripping hashes, every file looks new on every build. Normalise names before diffing.
  • Stale baselines. If main's report is not uploaded on every push, pull requests compare against an old build and report other people's changes. Upload on every main build.
  • Only a site-wide total. Per-template weights show who pays for a change; a total hides it.
  • Ignoring deletions. A pull request that shrinks assets deserves the same visibility; celebrate it in the comment so reductions are noticed and not quietly reversed later.
  • Rollback: the check is two scripts and one workflow. Removing the budget line from the diff script turns failures into comments only; removing the workflow removes it entirely.

Conclusion

Bundle-size tracking is the cheapest performance check a static site can run: ten seconds after the build, deterministic, no browser, and precise about which file grew. Posting the diff on every pull request changed behaviour more than failing builds did — eleven of fourteen large increases were cut before merge, by a median of 71%, because authors could finally see what their import cost.

FAQ

Why track bundle size if Lighthouse CI already runs?

Bundle-size checks run in seconds on the build output, need no browser or deploy, and never vary between runs. They catch the most common regression, an added dependency, before Lighthouse even starts, and they tell you exactly which file grew.

Should I compare gzip or brotli sizes?

Compare whichever your host actually serves. Cloudflare, Netlify and Vercel serve brotli to modern browsers, so brotli is the realistic number. Report both if some readers get gzip, and budget on the one most readers receive.

How do I compare against the base branch without rebuilding it?

Cache the size report from each build of the main branch as an artifact or in a small JSON file on a storage bucket. The pull request job downloads the latest main report and diffs against it.

What about pages that load different bundles?

Group assets by the pages that load them. A per-template report, built from each template's HTML and the scripts it references, shows what a reader of that template downloads, which is more useful than a site-wide total.