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.
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.
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:
| Measure | Value |
|---|---|
| Pull requests with asset changes over 1 KB | 64 |
| Of those, increases over 10 KB | 14 |
| Increases reduced before merge after the comment | 11 |
| Median reduction when reduced | 71% of the increase |
| Budget failures (60 KB JS total) | 3 |
| Job duration after build (median) | 9 s |
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 growing — vendor, 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.
Related
- Parent: Performance Budgets and Lighthouse CI — where bundle size fits among the checks.
- How to Reduce Bundle Size in Eleventy Builds — what to do when the number is too high.
- Writing a Performance Budget That Fails Builds — choosing the 60 KB line.
- Replacing React Islands with Web Components — a larger structural reduction.
- Caching node_modules in GitHub Actions for Faster SSG Builds — keeping the build that feeds this check fast.