How to Track JavaScript Bundle Size on Every Pull Request
This guide is the visibility layer of JavaScript Performance Budgets, part of JavaScript Bundle Optimization & Code Splitting. Budgets stop large regressions; size tracking stops the small ones from accumulating unnoticed. A 6KB increase does not fail a budget, but twenty of them in a quarter add 120KB to the landing route. If every pull request shows its delta — "+6.2KB to the landing route, from react-hook-form" — reviewers can ask whether the cost is justified while the change is still easy to rework.
The mechanics are straightforward: build the base branch and the pull request, compute per-route sizes for both, and post the difference as a comment. Doing it well means comparing like with like (same build settings, same route definitions), attributing changes to modules so the comment explains itself, and storing results so trends are visible across months.
Rapid Diagnosis
- Can a reviewer see size impact today? If they need to check out the branch and run an analyser, nobody will.
- Are comparisons against the merge base? Comparing against the latest
mainmixes in other merged changes. - Are deltas attributed? "+18KB" without saying which module or route invites shrugging.
- Is history kept? Without stored results, you cannot answer "when did the landing route grow by 80KB?".
Root Cause Analysis
1. Invisible cost in review. Code review focuses on correctness and readability; bundle cost is not visible in a diff of source files.
2. Noisy comparisons. Building base and head with different dependency installs, environment variables or caches produces spurious differences that teach people to ignore the numbers.
3. Unexplained deltas. A total delta without module attribution forces the author to investigate, and most will not.
4. No long-term record. Each PR's numbers vanish after merge, so slow trends are invisible until field data shows the consequence.
Step-by-Step Resolution
1. Produce a stable size report from the build
Emit a JSON report per route with raw and brotli sizes, generated identically for any commit.
// scripts/size-report.mjs → dist/size-report.json
import { readFileSync, writeFileSync } from 'node:fs';
import { brotliCompressSync } from 'node:zlib';
import routes from '../dist/route-files.json' with { type: 'json' };
const report = {};
for (const [route, files] of Object.entries(routes)) {
let raw = 0, br = 0;
for (const f of files) { const b = readFileSync(f); raw += b.length; br += brotliCompressSync(b).length; }
report[route] = { raw, br };
}
writeFileSync('dist/size-report.json', JSON.stringify(report, null, 2));
// trade-off: brotli at default quality in Node is slower than gzip; on very
// large builds compute it only for initial routes, not every lazy chunk.
Expected outcome: a small JSON artefact that fully describes the build's route sizes.
2. Compare against the merge base in CI
Build the merge base in the same job (or fetch its stored report) and compute deltas.
# .github/workflows/size-diff.yml (excerpt)
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: git checkout $(git merge-base origin/main HEAD) && npm ci && npm run build:report && mv dist/size-report.json /tmp/base.json
- run: git checkout ${{ github.sha }} && npm ci && npm run build:report
- run: node scripts/size-diff.mjs /tmp/base.json dist/size-report.json > size-comment.md
# trade-off: building twice doubles CI time. Store each main-branch report as an
# artefact on merge and download it here instead once the pipeline is stable.
Expected outcome: deltas that reflect only this pull request's changes.
3. Post a readable comment with attribution
Format the diff as a short table: route, base, head, delta, and the top modules that changed (from the visualiser's raw data or source-map-explorer output). Update the same comment on each push rather than adding new ones.
**Bundle size** (raw / brotli, vs merge base)
| Route | Base | Head | Δ |
| --- | --- | --- | --- |
| landing | 298.1 / 92.4 KB | 304.3 / 94.1 KB | **+6.2 / +1.7 KB** |
| checkout | 131.0 / 41.2 KB | 131.0 / 41.2 KB | 0 |
Top change: `react-hook-form` +5.9 KB (landing)
Expected outcome: reviewers see the cost and its cause at a glance.
4. Store every main-branch report
Append each merge's report to a time series (a JSON file in a storage bucket, a database, or a service such as RelativeCI or bundlewatch). Plot initial-route sizes weekly.
Expected outcome: you can answer "when did this route grow, and which PR did it?" in minutes.
Verification
Open a test pull request that adds a known dependency to the landing route and check that the comment reports a delta matching the dependency's size and names it. Open another that changes only lazy-route code and confirm the landing delta is zero. Review the stored history after a month: the series should be smooth, with steps that correspond to identifiable pull requests.
Worked Example: Making Growth a Review Topic
A product team introduced per-PR size comments without changing any budgets. In the first month, reviewers questioned four changes: a carousel library added for one marketing page (moved behind a dynamic import, saving 41KB on all other pages), a duplicated copy of lodash introduced by a dependency upgrade (deduplicated, 24KB), an icon component importing a full icon set (switched to per-icon imports, 18KB), and an analytics SDK added to the entry (deferred to idle, removed 31KB from initial load). None of these would have failed the existing budgets. Visibility alone recovered more than 100KB from the landing route within weeks.
Common Mistakes
- Reporting only totals across all chunks. Lazy route growth then masks initial-route growth and vice versa.
- Comparing against
mainhead instead of the merge base. Other merged PRs appear as this PR's changes. - Rounding to whole kilobytes. Small deltas disappear, which is precisely what this process exists to show.
- Posting a new comment on every push. The PR fills with stale comments; update one in place.
FAQ
Are hosted services worth it compared with a script?
Services like RelativeCI, bundlewatch or Codecov's bundle analysis provide history, attribution and comments out of the box, which is worth it for teams without time to maintain scripts. A script is enough for a single app and gives full control over route definitions; the important part is that comments appear on every PR.
Should size comments block merges?
No — that is the budget's job. Comments are informational; they make costs visible and start conversations. Combining both works well: a budget for hard limits, a comment for everything below them.
How do I attribute changes to modules cheaply?
Generate the visualiser's raw data or a source-map-explorer JSON for both builds and diff module sizes per chunk. Show only the top three or four changes; a full module diff is too noisy for a comment and belongs in a linked artefact.
Should lazy route changes appear in the comment?
Yes, but collapsed below initial routes. Lazy chunks matter for navigation speed, and a large jump in one still deserves a look; listing them after the initial routes keeps the critical-path numbers prominent without hiding the rest.
How do I handle pull requests that legitimately add weight?
Let them through with context. The comment's job is to make the cost visible, not to prevent it; a reviewer who sees "+24KB for the new checkout validation" can confirm it is needed on that route and lazy elsewhere. Record significant intentional increases in a short changelog so later investigations of growth find the reasoning immediately.
Related
- Enforcing bundle budgets with size-limit — the hard limits behind the comments.
- Comparing two builds with Statoscope — deeper build-to-build comparison for webpack.
- Analyzing Vite bundles with rollup-plugin-visualizer — module-level data for attribution.