Why Compressed Size Is the Wrong JavaScript Budget

This guide makes a specific argument within JavaScript Performance Budgets, part of JavaScript Bundle Optimization & Code Splitting. Most bundle-size tooling defaults to reporting gzip or brotli size, and most published budgets are expressed that way ("keep JavaScript under 170KB gzipped"). Compressed size is the right number for one question — how long the download takes — and the wrong number for the question that matters most for interactivity: how long the main thread is busy processing the code.

Compression ratios vary widely by content. Generated code, large data structures embedded as JavaScript, and repetitive component markup compress extremely well — 10:1 or better — while dense, minified library logic compresses perhaps 3:1. Two bundles with the same compressed size can therefore differ by a factor of three in what the engine must parse and compile. A budget that only watches compressed bytes will happily let the expensive one through.

Two bundles with the same brotli size Bar chart comparing brotli size, uncompressed size and compile time for two bundles that are the same size when compressed. Two bundles with the same brotli size Bundle A brotli (KB) 60 Bundle B brotli (KB) 60 Bundle A raw (KB) 190 Bundle B raw (KB) 640 A compile+eval (ms) 78 B compile+eval (ms) 236 Bundle B embeds a large JSON dataset and generated route tables that compress 10:1 but still have to be parsed.

Rapid Diagnosis

  • Compare compression ratios across chunks. Divide raw size by brotli size for each chunk. Ratios above ~5:1 indicate highly compressible content that a compressed budget undercounts.
  • Look for data in JavaScript. Large inline JSON, translation tables, SVG paths as strings, or generated configuration often sit in chunks as literal data.
  • Compare budgets with TBT. If compressed size is flat but TBT or scripting time is rising, the budget is measuring the wrong thing.
  • Check what your tooling reports by default. Many tools show gzip unless configured otherwise.

Root Cause Analysis

1. Compression hides volume. Repetitive content compresses well, but the engine sees the decompressed text. Parse and compile scale with what the engine sees.

2. Data embedded as code. JSON or lookup tables imported as modules must be parsed as JavaScript object literals; JSON.parse on a string is typically faster than parsing the equivalent JavaScript literal, and loading data with fetch keeps it off the critical path entirely.

3. Execution is invisible to any size budget. A small module that does heavy work at startup costs more than a large one that defines functions for later. No byte budget sees this.

4. Transfer is a shrinking share of cost. On 4G and better, download time for typical bundles is often smaller than CPU time on mid-tier devices; budgets centred on transfer optimise the cheaper phase.

What each budget unit captures Comparison of compressed size, uncompressed size and main-thread time budgets by what costs they capture. What each budget unit captures Budget unit Download Parse + compile Execution Compressed (gzip/brotli) yes poorly no Uncompressed (raw) indirectly yes no Main-thread time (lab) no yes yes

Step-by-Step: Better Budgets

1. Budget uncompressed size as the primary byte metric

javascript
// .size-limit.cjs — raw size first, brotli as a secondary transfer check.
module.exports = [
  { name: 'landing raw', path: 'dist/assets/index-*.js', limit: '300 KB', gzip: false, brotli: false },
  { name: 'landing brotli', path: 'dist/assets/index-*.js', limit: '95 KB', brotli: true },
];
// trade-off: raw-size budgets are sensitive to minifier settings and target.
// Changing either moves the number without changing real cost much — re-baseline
// budgets when you change build configuration.

Expected outcome: a budget that responds to the volume of code the engine must process.

2. Add a main-thread time budget in the lab

Byte budgets cannot see execution. Add a throttled Lighthouse assertion on scripting time or TBT for each key route.

javascript
// lighthouserc.js
module.exports = { ci: { assert: { assertions: {
  'bootup-time': ['error', { maxNumericValue: 1200 }],          // ms of JS execution on the profile
  'total-blocking-time': ['error', { maxNumericValue: 300, aggregationMethod: 'median' }],
} } } };
// trade-off: timing assertions are noisier than byte checks. Run at least
// three times and assert on the median, and keep thresholds a little above
// typical values so only real regressions fail.

Expected outcome: code that is small but expensive at startup is caught.

3. Move data out of JavaScript

Load large datasets with fetch and JSON.parse (or in a worker), not as imported modules.

javascript
// Before: the whole table is parsed as a JS object literal at startup.
import countries from './countries.js';
// After: fetched when needed; JSON.parse is faster than JS literal parsing.
const countries = await fetch('/data/countries.json').then((r) => r.json());
// trade-off: fetching adds a request and makes the data asynchronous. For small
// tables (a few KB) importing is simpler and the parse cost is negligible.

Expected outcome: raw JavaScript size falls by the data size, and the parse cost moves off the startup path.

4. Keep compressed budgets for transfer-sensitive pages

Compressed size still matters for LCP on slow networks when JavaScript is on the critical rendering path (client-rendered content). Keep a brotli budget for those routes, as the secondary check.

A budget set that covers all three costs Three complementary JavaScript budgets that together cover download, parse and execution cost. A budget set that covers all three costs Uncompressed bytes per route Primary — tracks parse and compile cost; every commit Brotli bytes per route Secondary — tracks transfer for client-rendered routes Throttled TBT and bootup time Catches small-but-expensive code; every pull request 1 2 3

Verification

After switching to raw-size budgets, check that the chunks with the highest compression ratios now show up as the largest — they were previously under-weighted. Over the following releases, compare budget changes with lab TBT changes: raw-size and timing budgets should move together far more consistently than compressed size did.

Worked Example: The Translations Chunk

An internationalised app imported all translation strings for the current language as a JavaScript module: 410KB raw, but only 38KB brotli thanks to repetitive keys and phrases. The team's gzip budget for the entry chunk had room, so nobody flagged it. A throttled trace showed the module costing 70ms to compile and evaluate on a mid-tier phone — more than the framework runtime. Moving translations to JSON loaded via fetch for the active namespace, and parsing them in the route that used them, cut 340KB of raw JavaScript from the entry and about 60ms from startup. The compressed-size dashboard barely moved; TBT fell by 55ms.

Common Mistakes

  • Comparing budgets across projects in compressed bytes. Different content mixes make compressed comparisons meaningless; compare raw sizes or, better, measured costs.
  • Optimising compression settings to meet a budget. Raising brotli quality makes the number smaller without making the page faster for CPU-bound users.
  • Counting inline scripts as free. Scripts inlined in HTML still need parsing and compiling, and they are not cached separately.
  • Forgetting source maps. Some tools include .map files in totals if globs are loose; budget only .js files.

FAQ

Is compressed size ever the better primary budget?

For pages where JavaScript is not on the critical path for rendering and devices are fast — internal tools on desktops, say — transfer may dominate and compressed size is a fair proxy. For consumer sites with significant mobile traffic, CPU cost usually dominates, so uncompressed size and timing budgets are the better primary signals.

How do I explain this to stakeholders used to gzip numbers?

Show two chunks with similar compressed sizes and very different compile times from a throttled trace, as in the example above. Then express budgets in milliseconds on a named device class, with bytes as the mechanism. "Under 150ms of startup scripting on a mid-tier phone" is easier to reason about than any byte count.

Does HTTP/2 or HTTP/3 change this?

They make transfer more efficient — multiplexing, better loss recovery — which further reduces the share of cost attributable to compressed size. CPU cost is unaffected by the protocol.

What raw-size budget corresponds to a typical gzip budget?

For typical minified application code, raw size is roughly three to four times the gzip size, so a 170KB gzip budget corresponds to around 500–650KB raw. The ratio varies with content, which is the whole point — derive your raw budget from your own bundles' current ratios and timings rather than from a fixed multiplier.