JavaScript Bundle Size Limits
JavaScript is the most expensive byte a browser can download: every kilobyte is decompressed, parsed, compiled, and executed on the main thread before the page becomes interactive. A bundle that ships 320 KB of gzipped script to a mid-range Android phone on 4G costs roughly 1.5 seconds of CPU time the user never sees coming. This is the asset-weight layer of the Defining Web Performance Budgets reference: it converts vague "keep the bundle small" intentions into hard, byte-denominated ceilings that the build pipeline enforces on every pull request rather than soft optimization targets that drift upward over quarters.
The work splits into three coupled decisions — what to measure (transfer size after compression, not raw bytes), how to partition the ceiling (initial entry chunk versus vendor versus lazy route chunks), and where to fail the build (a required CI status check). Get the compression basis wrong and every threshold is off by a factor of three; conflate initial and lazy chunks and you either block legitimate features or let a 400 KB route slip through. This page is the authoritative spec for all three.
Architecture Overview
A JavaScript budget is not a single number; it is an allocation across chunk roles. The diagram below shows a typical route's payload partitioned into an initial entry chunk (render-blocking, the tightest ceiling), a shared vendor chunk (cached across routes), and lazy chunks loaded on demand — each with its own limit measured after brotli compression.
Prerequisites & Environment
Bundle budgeting needs a deterministic production build and a tool that measures compressed transfer size, not the raw bytes a bundler prints to stdout.
- A production build with content hashing —
[name].[contenthash].jsfilenames so chunk globs are stable across builds and CI can match them. size-limit≥ 11 with@size-limit/preset-app(orbundlesize≥ 0.18) — measures brotli/gzip transfer size per glob and exits non-zero on breach. Pin the exact minor version.- A bundle analyzer —
webpack-bundle-analyzerorrollup-plugin-visualizerto attribute bytes to dependencies when a budget breaks. - Node.js ≥ 18 — matching your CI runner so local and CI measurements agree.
Decide your compression basis once and apply it everywhere: budgets should be denominated in brotli transfer bytes if your CDN serves brotli (it almost certainly does), because that is what users actually download. A 150 KB gzipped chunk is roughly 128 KB brotli — mixing the two understates the budget by ~15%.
Configuration Reference
The annotated size-limit block below is the authoritative spec. Each entry maps a chunk role to a glob and a ceiling; brotli: true sets the measurement basis, and running: false skips the slower time-to-run estimation when you only need byte gating.
{
"size-limit": [
{
"name": "initial entry",
"path": "dist/assets/index-*.js",
"limit": "150 KB",
"brotli": true,
"running": false
},
{
"name": "shared vendor",
"path": "dist/assets/vendor-*.js",
"limit": "80 KB",
"brotli": true,
"running": false
},
{
"name": "lazy route chunks",
"path": "dist/assets/route-*.js",
"limit": "50 KB",
"brotli": true,
"running": false
}
]
}
The initial entry ceiling is the one that gates render-blocking work and should be the tightest; the vendor chunk is amortized because it is cached across routes, so it earns a slightly larger allowance; each lazy route chunk is bounded individually so one heavy route cannot inflate the rest. For the per-chunk math behind the lazy limit, see Budgeting for Dynamic Import Code Splitting, and for distinct ceilings per entrypoint see Enforcing Per-Route JavaScript Budgets.
Step-by-Step Implementation
-
Install and add a script. Add
size-limitand the app preset, then apackage.jsonscript.npm install --save-dev size-limit @size-limit/preset-app npm pkg set scripts.check:size="size-limit"Expected output: the install completes and
npm run check:sizeis now defined. -
Run against a production build to establish where each chunk sits today.
npm run build && npm run check:sizeExpected output: a table of each named limit with measured size and a green check or red cross, e.g.
initial entry 142 KB (limit 150 KB) ✓. -
Tighten each limit to ~10% above the current measurement, commit
package.json, and let the ratchet hold the line — any change that pushes a chunk over its limit now fails locally and in CI.
Threshold Calibration
Do not copy the reference limits untouched. Derive each ceiling from a device-and-network budget: pick the slowest device class you support, decide how much of its main-thread time you will spend on script, and back out the byte ceiling. The matrix below shows representative starting points calibrated for the P75 user on each profile; reconcile them against your field data using Core Web Vitals Budget Allocation.
| Device class (P75) | Connection profile | Initial entry (brotli) | Vendor (brotli) | Per lazy chunk | Total route ceiling |
|---|---|---|---|---|---|
| Desktop | Cable / Fiber | 200 KB | 120 KB | 80 KB | 400 KB |
| High-end mobile | 4G / LTE | 150 KB | 90 KB | 60 KB | 300 KB |
| Mid-range mobile | Fast 3G | 110 KB | 70 KB | 45 KB | 220 KB |
Set a new limit to warn while you confirm it holds across two weeks of builds, then promote it to a hard error so the gate earns trust before it starts blocking merges. When a third-party tag inflates the total, subtract its allowance up front using Third-Party Script Constraints rather than quietly widening the script budget.
CI Enforcement Snippet
This GitHub Actions job builds, measures, and surfaces a required status check that branch protection can gate on. The andresz1/size-limit-action posts the byte delta as a PR comment so reviewers see the cost of a change inline.
name: JS Bundle Budget
on:
pull_request:
branches: [main]
jobs:
bundle-size:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Enforce bundle limits
uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build
script: npm run check:size
Require the bundle-size check in branch protection so a breach is unmergeable, and fan the build across viewports and routes with GitHub Actions Performance Matrices. If you already run Lighthouse CI, you can gate the same byte ceiling from lighthouserc.json with the resource-summary audit instead of a second tool:
{
"ci": {
"assert": {
"assertions": {
"resource-summary:script:size": ["error", { "maxNumericValue": 300000 }]
}
}
}
}
Troubleshooting & Edge Cases
- Glob matches the wrong chunk after a rename → pin filenames with
[name].[contenthash].jsand use role-prefixed names (index-,vendor-,route-) so the size-limit path stays stable. - Budget passes locally but fails in CI → you measured gzip locally and brotli in CI (or vice versa); set
brotli: trueexplicitly in every entry so the basis is identical. - Vendor chunk balloons after a dependency bump → run the analyzer (
npx vite-bundle-visualizerorwebpack-bundle-analyzer) to find the new bytes, then deduplicate or lazy-load the offender. - One heavy route hides under a global cap → split the global ceiling into per-route limits; see Enforcing Per-Route JavaScript Budgets.
- Tree-shaking not reducing size → confirm
"sideEffects": falsein the dependency'spackage.jsonand that you import named exports, not the whole module. - Source maps counted in the budget → exclude
*.mapfrom the glob; only the.jstransfer bytes are downloaded by users.
Frequently Asked Questions
Should budgets be measured gzipped or brotli?
Measure whatever your CDN actually serves to users — for nearly all modern CDNs that is brotli, which is roughly 15% smaller than gzip. Set brotli: true in every size-limit entry so local and CI measurements use the same basis, otherwise budgets pass locally and fail in CI for no real reason.
Why separate the initial entry chunk from lazy chunks?
The initial entry chunk is render-blocking and runs before the page is interactive, so it gets the tightest ceiling. Lazy chunks load on demand after navigation, so they can each carry a separate, smaller budget without inflating the critical path. Capping each role independently stops one heavy route from consuming the whole allowance. See Budgeting for Dynamic Import Code Splitting.
How do I pick the first numbers if I have no field data?
Build the site, measure where each chunk sits today, and set the limit ~10% above the current value as a ratchet. That freezes the status quo and blocks regressions immediately; tighten toward the device-class targets in the calibration table over the following sprints as you optimize.