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 brotli-compressed script to a mid-range Android phone (P75 device, Fast 3G, roughly a 4x CPU slowdown versus a reference laptop) costs on the order of 1.5 seconds of main-thread time the user never sees coming — time during which taps queue, animations stutter, and the largest contentful paint slips. 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 unnoticed. This page is the authoritative spec for all three, with the exact tooling config, the device-calibrated numbers behind each ceiling, and the CI wiring that makes a breach unmergeable.

Why JavaScript Bytes Cost More Than Other Bytes

A byte of JavaScript is not equivalent to a byte of image or a byte of CSS. An image byte is decoded off the main thread and painted; a script byte has to be decompressed, then parsed into an abstract syntax tree, then compiled to bytecode, then executed — and every stage except decompression happens on the main thread, blocking user input the whole time. That is why the same transfer weight has a wildly different user-visible cost depending on whether it arrives as pictures or as script.

The compounding factor is that compression hides the true parse cost. A 128 KB brotli chunk decompresses to roughly 480 KB of raw JavaScript source, and it is that raw figure the parser and compiler grind through. On a P75 mid-range mobile device on Fast 3G, budget planners should assume parse-plus-compile runs at roughly 1 MB of raw JS per second of main-thread time, so a single 480 KB chunk costs close to half a second before any of its code has run. Measuring the compressed transfer number is right for the download cost, but you must remember the raw figure is what determines CPU cost — the two ceilings move together, which is exactly why a byte budget is a proxy for a time budget.

The main-thread lifecycle of a JavaScript chunk Five sequential stages show a 128 KB brotli chunk being downloaded, decompressed to 480 KB of source, parsed, compiled, and executed, with parse, compile, and execute all running on the main thread. One chunk, five stages: only download is off the main thread Download 128 KB brotli Decompress 480 KB source Parse build AST Compile to bytecode Execute run code main-thread work — blocks input Budget the compressed byte for download; remember the raw byte drives CPU cost.
Compression shrinks the download but not the parse: a 128 KB brotli chunk still forces the main thread to process ~480 KB of source before its code runs.

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.

JavaScript budget allocation by chunk role A horizontal byte-allocation bar divides a route's JavaScript into an initial entry chunk capped at 150 KB, a shared vendor chunk capped at 80 KB cached across routes, and on-demand lazy chunks each capped at 50 KB, all measured after brotli compression against a 280 KB transfer ceiling. Per-route JS budget (brotli transfer bytes) initial entry ≤ 150 KB vendor ≤ 80 KB lazy A ≤ 50 KB lazy B ≤ 50 KB headroom total transfer ceiling: 280 KB (gates the merge) build emits chunks dist/assets/*.js size-limit measures brotli per glob CI gate exit 1 on breach
Partition the per-route ceiling by chunk role — initial, vendor, lazy — and measure each glob after brotli; any chunk over its limit fails the build.

This chunk-role model is what makes byte budgets survive real feature growth. A flat "total JS under 300 KB" rule breaks the moment a product team ships a rich data-grid route: either the cap is generous enough to hide the grid's weight everywhere, or it is tight enough to block it and forces the team to route around the gate. Splitting the ceiling by role lets the initial entry stay lean for every user while the heavy grid pays for itself only on the route that loads it. For single-page apps, this same split extends to per-route chunks loaded on client-side navigation; the Single-Page App Performance Budgets reference covers how soft navigations change where you place the ceiling.

Prerequisites and 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].js filenames so chunk globs are stable across builds and CI can match them reliably.
  • size-limit ≥ 11 with @size-limit/preset-app (or bundlesize ≥ 0.18) — measures brotli/gzip transfer size per glob and exits non-zero on breach. Pin the exact minor version so a tool upgrade never silently changes the measured number.
  • A bundle analyzerwebpack-bundle-analyzer or rollup-plugin-visualizer to attribute bytes to dependencies when a budget breaks.
  • Node.js ≥ 18 — matching your CI runner so local and CI measurements agree to the byte.

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% and produces the single most common false failure, where a chunk passes on a developer laptop and fails in CI for no real regression.

Choosing the Compression Basis

The compression basis is the foundation every threshold sits on, so it is worth seeing the three numbers side by side. A representative first-party route chunk that reads as 480 KB of raw source shrinks to about 150 KB under gzip and about 128 KB under brotli — the same code, three very different figures. If you budget against the raw number you will set a ceiling three times too loose; if you budget against gzip while your CDN serves brotli, your gate is about 15% looser than reality and will wave through regressions that real users never benefit from.

Raw versus gzip versus brotli for one chunk Three horizontal bars compare the same JavaScript chunk measured as 480 KB raw source, 150 KB gzip transfer, and 128 KB brotli transfer, with brotli marked as the correct budget basis. Same chunk, three measurements raw source 480 KB gzip transfer 150 KB brotli transfer 128 KB — budget here Budget against what the CDN serves; brotli is ~15% under gzip and ~3.75x under raw.
The basis you pick moves the ceiling by up to 3.75x — always budget against the compressed bytes the CDN actually serves, which for modern CDNs is brotli.

There is one more subtlety: brotli has quality levels 1 through 11, and a CDN serving dynamic brotli usually compresses at a lower quality (4 to 6) than an offline build (11). If your ceiling was calibrated against a level-11 offline pass but the CDN serves level 5, real transfer is a few percent larger than your gate believes. For static assets served from an object store with precompressed .br files, match the CDN's quality in your size-limit config; for dynamically compressed responses, add a small margin so the gate stays conservative rather than optimistic.

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.

If you want the running: false estimate turned back on for the initial entry, size-limit will also report an estimated execution time on a throttled CPU profile — useful when you want the budget to track main-thread cost directly rather than as a byte proxy, at the cost of a slower and slightly noisier check.

Step-by-Step Implementation

  1. Install and add a script. Add size-limit and the app preset, then a package.json script.

    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:size is now defined.

  2. Run against a production build to establish where each chunk sits today.

    npm run build && npm run check:size

    Expected 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) ✓.

  3. 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.

Run the check as a pre-push hook as well as in CI so contributors catch a breach before the round trip through a pull request. A minimal hook keeps the feedback loop tight:

#!/usr/bin/env bash
set -euo pipefail
npm run build --silent
npm run check:size

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
Low-end mobile Slow 3G 80 KB 55 KB 35 KB 170 KB

The math behind a row is deliberate, not folklore. Take the mid-range mobile row: a P75 device on Fast 3G with a 4x CPU slowdown parses roughly 1 MB of raw JavaScript per second of main-thread time, and you have budgeted about 350 ms of that time for first-party script before interaction. That is ~350 KB of raw source, which decompresses from roughly 110 KB brotli — hence the 110 KB initial-entry ceiling. Change any input (a faster device floor, a larger main-thread allowance) and the byte ceiling moves with it. Because the P75 device on a slow connection is the binding constraint, desktop budgets are looser only because the CPU is faster, not because desktop users deserve heavier pages.

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. For SPA routes that load their code on client-side navigation, size the per-route chunk against the same device math but measure it separately from the shell; Route-Level Bundle Budgets for React Router shows the glob and assertion pattern.

The Ratchet Workflow

A budget only holds the line if the number moves in one direction: down, or flat, never quietly up. The ratchet workflow gives every limit a lifecycle — measure where the chunk sits, set the limit just above it as a warn, hold long enough to trust it against build-to-build noise, then promote it to a hard error, and repeat next sprint at a tighter target. The warn phase matters because it separates "this number is real" from "this number blocks merges": you never want a freshly guessed ceiling to fail a legitimate PR on day one and burn the team's trust in the gate.

The budget ratchet lifecycle A loop of four stages shows a budget being measured, set as a warning ten percent above current, held for two weeks, promoted to a hard error, then returning to tighten toward the device-class target next sprint. Ratchet: warn, hold, error, tighten Measure current e.g. 142 KB Set +10% warn limit 156 KB Hold 2 weeks confirm stable Promote to error blocks merge next sprint: re-measure and tighten toward the device-class target The limit only ever moves down or flat — never silently up.
Every ceiling earns its hard-fail status: measure, warn, hold, promote, then tighten next sprint so the number ratchets down toward the device-class target.

The one exception to "never move up" is a deliberate, reviewed increase: a genuinely new capability sometimes justifies more bytes, and the right response is a pull request that raises the limit with a written rationale in the diff, so the increase is a decision the team signs off on rather than a drift nobody noticed. That review step is the whole point — the gate does not forbid growth, it forbids silent growth.

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 }]
      }
    }
  }
}

The two tools answer different questions and are worth running together. size-limit answers "did any single chunk breach its role ceiling," measured on the built artifact with no browser; Lighthouse's resource-summary:script:size answers "did the total script the page actually requested breach the page ceiling," measured in a real navigation that includes third-party tags injected at runtime. A change can pass the first and fail the second — a new analytics tag adds no first-party chunk but lifts the page total — which is exactly the signal you want the page-level gate to catch.

Troubleshooting and Edge Cases

  • Glob matches the wrong chunk after a rename → pin filenames with [name].[contenthash].js and use role-prefixed names (index-, vendor-, route-) so the size-limit path stays stable across builds.
  • Budget passes locally but fails in CI → you measured gzip locally and brotli in CI (or vice versa); set brotli: true explicitly in every entry so the basis is identical everywhere.
  • Vendor chunk balloons after a dependency bump → run the analyzer (npx vite-bundle-visualizer or webpack-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": false in the dependency's package.json and that you import named exports, not the whole module.
  • Source maps counted in the budget → exclude *.map from the glob; only the .js transfer bytes are downloaded by users.
  • A polyfill bundle sneaks past the entry glob → give differential-loading legacy bundles their own named limit (legacy-*.js) rather than letting them fall outside every glob and go unmeasured.
  • The check is flaky by a few hundred bytes → hash-based filenames and identical Node versions remove most run-to-run drift; if a byte-exact number still wobbles, it usually means a non-deterministic build input such as an embedded timestamp or build id.

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.

Why budget compressed bytes when the CPU cost comes from raw bytes?

The compressed byte determines download time and the raw byte determines parse and compile time, but the two move together for a given codebase, so a compressed-byte ceiling is a reliable proxy for both. A 128 KB brotli chunk expands to roughly 480 KB of source that a P75 mid-range mobile device on Fast 3G parses at about 1 MB per second, so capping the transfer byte caps the CPU cost as well. If you need to gate execution time directly, turn on the running estimate in size-limit.

Should the gate ever allow a bundle to grow?

Yes, but only through a reviewed pull request that raises the limit with a written rationale, never through silent drift. The ratchet exists to forbid unnoticed growth, not deliberate, signed-off growth for a genuinely new capability. Keeping the limit in version control means every increase is a visible decision in the diff that a reviewer can accept or push back on.