Frontendbundle sizeparsecompileexecutecode splittingweb

JavaScript Costs Four Times, Not Once

A 400KB bundle is not one cost. It is downloaded, parsed, compiled and executed — and gzip only helps with the first of those. The last three are CPU on a device you did not choose and cannot upgrade.

Follow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
We compressed the bundle and the page is still slow to become interactive — which of JavaScript's four costs did that actually address?
Symptom
Transferred bytes drop after a compression or CDN change, and time-to-interactive on real devices barely moves. The gap between "loaded" and "responds to taps" stays stubbornly wide.
Signal
Main-thread time attributed to script: parse, compile and execute, measured on a throttled device. The misleading signal is transferred bytes, which is the only one of the four costs compression touches.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

Four costs hiding behind one number

Web-specific · JavaScript engine behaviour; specifics differ between V8, SpiderMonkey and JavaScriptCore

Bundle size is quoted as a single number, usually compressed, usually in a CI comment. That number describes exactly one of the four things the browser must do with your JavaScript, and it is generally the cheapest one on a modern connection.

Compression shrinks bytes on the wire. It does not shrink the parse work, because the engine parses the *decompressed* source. It does not shrink compilation, and it certainly does not shrink execution — if anything, the decompression itself adds a small amount of CPU. A team that celebrates a 40% gzip improvement has improved one quarter of the problem and left the expensive quarters untouched.

The execution cost is the one that matters most and is quoted least, because it is invisible in every network-shaped view of performance. It is also the one that scales with the *device*, which is why the gap between a developer's experience and a user's experience is so much wider here than anywhere else in the stack.

What each cost is, and what actually reduces it
CostScales withReduced byNot reduced by
DownloadConnection speed, bytes on the wireCompression, CDN, fewer/smaller filesAnything about code structure
ParseUncompressed source size, device CPUShipping less codeCompression — the engine parses decompressed source
CompileAmount of code actually run, device CPUShipping less code, lazy loadingCompression, CDN
ExecuteWhat the code does, device CPUDoing less work at startup, deferring, moving off main threadCompression, CDN, faster network

The device gap

Web-specific · CPU-bound work; the ratio between device classes varies by generation and thermal state

Network conditions vary by maybe an order of magnitude between a good and a bad connection. Device CPU varies by a similar factor between a current flagship and the mid-range phone a large share of users actually own — and unlike the network, it does not improve when the user walks into a coffee shop.

This produces the single most common false-negative in frontend performance: the feature is tested on the machine it was written on, where 600ms of script execution takes 90ms, and nobody notices. The bundle ships, the field data shifts, and the connection to the change is lost because the CI budget only tracked bytes.

The practical response is to make the slow device the default test target rather than an afterthought. A throttled profile in CI that approximates a mid-range phone turns an invisible regression into a failing build, which is the only reliable mechanism for something this easy to miss.

Identical 400KB bundle, two devices, cold loadILLUSTRATIVE
SignalValueWhat it tells youVerdict
Transferred (gzipped)128KB — both devicesThe number quoted in the PR. Identical either waynormal
Download — laptop, fibre90msFast link, cost is negligiblenormal
Download — mid-range phone, 4G410msNetwork cost is real but not dominantsuspect
Parse + compile — laptop55msInvisible during developmentnormal
Parse + compile — phone240msUntouched by compression or CDNsmoking gun
Execute (startup) — laptop90msFeels instant to the developer who shipped itnormal
Execute (startup) — phone610msThe dominant cost, and the one no byte budget measuressmoking gun

Splitting the bundle moves work; it does not delete it

Web-specific · Bundler code splitting and dynamic import; browser fetches each chunk on demand

Code splitting is the standard prescription and it is a good one, provided everybody is clear that it *relocates* cost rather than removing it. The route bundle gets smaller and the first paint arrives earlier; the deferred chunk still has to be downloaded, parsed, compiled and executed at some point, and that point is now during a user interaction rather than during a load the user already expected to wait through.

That trade is usually worth it, because the deferred work is often never needed at all — the user never opens the settings panel, never reaches the checkout step. But when the deferred chunk is on a common path, splitting can turn a slow load into a fast load followed by a laggy first tap, which shows up as an INP regression while every load metric improves.

The only reliable way to distinguish these outcomes is to measure both classes of metric before and after. This is the general "fixing one bottleneck exposes the next" pattern from The Bottleneck Moves After Every Fix, appearing here as a trade between load metrics and interaction metrics.

Everything at startup, because it is simpler
1// Every route, every modal, every chart library — one bundle
2import { Dashboard } from './dashboard'
3import { Settings } from './settings'
4import { ChartKit } from 'chart-kit' // 180KB, used on one route
5import { PdfExport } from './pdf-export' // 90KB, used by ~2% of sessions
6
7// Cost on a mid-range phone, every single visit:
8// download 410ms + parse/compile 240ms + execute 610ms
9// ...before the first screen renders anything.
Pay for what the first screen needs
1// First screen only
2import { Dashboard } from './dashboard'
3
4// Deferred until the route or interaction actually happens
5const Settings = lazy(() => import('./settings'))
6const ChartKit = lazy(() => import('chart-kit'))
7const PdfExport = lazy(() => import('./pdf-export'))
8
9// First visit gets a much smaller four-way cost.
10// BUT: the chart chunk now loads on interaction —
11// measure INP, not just LCP, or you have traded one metric for another.

The good column is usually right, and it is not free: work moved to interaction time is work the user now waits for while actively engaged, which is a harsher context than an expected page load.

Key points

  • JavaScript costs download, parse, compile and execute; compression and CDNs address only the first.
  • Parse, compile and execute are device CPU, so the gap between a developer laptop and a mid-range phone is large and invisible locally.
  • A byte budget in CI does not catch execution regressions — a main-thread time budget on a throttled profile does.
  • Code splitting relocates cost from load time to interaction time; that is usually a good trade and it must be measured as a trade.
  • The cheapest bundle byte is the one never shipped: deleting an unused dependency beats every optimisation applied to keeping it.

Follow the diagnosis

The causal chain, hop by hop — and the readings that invite the wrong conclusion.

  1. 1
    Build → bundle: a charting library and a PDF exporter are imported at module scope, so they land in the entry chunk regardless of route.
  2. 2
    Network → device: 128KB compressed arrives in 410ms on 4G — the only cost the CI byte budget measured.
  3. 3
    Engine → main thread: 400KB of *decompressed* source is parsed and compiled, costing 240ms of CPU that compression did nothing to reduce.
  4. 4
    Startup code → main thread: module initialisation executes for 610ms, during which the page cannot paint or respond to input.
  5. 5
    User → page: taps during that window are queued behind the long task, so INP records the wait even though the bytes arrived long ago.
What this evidence makes people conclude — wrongly
  • "We gzipped it, so bundle cost is handled." Compression addresses download only; the engine parses the decompressed source.
  • "It executes in 90ms." On the machine that built it. Measure on the device class your users actually hold.
  • "Bundle size is under budget, so we are fine." A byte budget is blind to execution time, which is usually the dominant cost.
  • "Code splitting made it faster." It made the load faster. Check whether interaction got slower before claiming a win.
  • "Tree shaking removed the unused code." It removes what it can statically prove is unused; side-effectful modules and dynamic access routinely defeat it. Verify with coverage.

Measure, fix, validate

An optimization is not finished until the metric that motivated it has moved.

How to measure it
  • • Uncompressed source size per bundle, not just the gzipped transfer number, because parse cost tracks the former.
  • • Main-thread time attributed to script parse, compile and execute during startup, on a throttled mid-range device profile.
  • • Total blocking time and long-task count during load, as the proxy for how unresponsive the page is while script runs.
  • • Per-chunk coverage: how much of the shipped code executes during a typical session, to find code paid for and never used.
  • • Field INP alongside field LCP, so a splitting change that trades one for the other is visible.
What actually fixes it
  • • Delete dependencies the first screen does not need — the only change that reduces all four costs at once.
  • • Split by route and by interaction so the entry chunk contains what the first screen genuinely requires, and measure INP alongside LCP.
  • • Move heavy startup computation off the critical path: defer it, chunk it with yields, or run it in a worker (see [[rendering-and-layout]]).
  • • Replace oversized dependencies with smaller ones where the API surface used is small — often a single utility justifying 80KB.
  • • Audit coverage per route and remove code that never executes in a real session.
How you know it worked
  • • Main-thread script time on the throttled profile, before and after, for the same route and the same cold-cache conditions.
  • • Field LCP *and* field INP p75 for the affected route and device segment, over a stable window — a split that improves one and degrades the other is a trade, not a win.
  • • Coverage data confirming the removed code is genuinely not being pulled in through another path.
  • • Total blocking time during load, which should fall if execution work actually left the critical path rather than being renamed.
What it costs
  • • Splitting adds request count and build complexity, and can produce waterfalls of chunk fetches if the graph is deep.
  • • Deferred work lands during interactions, where users are less tolerant of delay than during an expected page load.
  • • Replacing a mature dependency with a smaller one trades bytes for the risk and maintenance cost of a less-proven library.
  • • A throttled CI profile makes builds slower and needs pinning and maintenance to stay comparable over time.
Stop it coming back
  • A CI budget on main-thread script time on a pinned throttled profile, not only on transferred bytes.
  • A build check that fails when a new dependency lands in the entry chunk without an explicit exemption.
  • Per-route bundle composition reporting in the PR, so a 180KB library appearing in the entry chunk is visible at review time.
  • A field alert on INP p75 to catch work relocated into interaction time.

Accuracy

Performance numbers are conditional. These are the conditions.

What these numbers depend on
  • WEB-SPECIFICParse, compile and execute behaviour are JavaScript engine concerns; details differ between V8, SpiderMonkey and JavaScriptCore and change between versions.
  • ILLUSTRATIVEThe laptop-versus-phone numbers are invented to show the characteristic ratio. Actual costs depend on engine version, device generation, thermal state and what the code does.
  • RUNTIME-SPECIFICEngines apply lazy compilation and caching heuristics that change which costs are paid when. Measure on the target engine rather than reasoning from a general model.

Misconceptions

Claim
“Gzip or Brotli solves bundle size.”
Reality
It solves transfer size. The engine parses and compiles the decompressed source, and execution is unaffected — three of the four costs are untouched.
Claim
“Bundle size in kilobytes is the metric that matters.”
Reality
It is a proxy for one cost. Main-thread time is the metric that correlates with the user waiting, and two bundles of identical size can differ severalfold in execution cost.
Claim
“Tree shaking removes everything unused.”
Reality
It removes what the bundler can statically prove is unused. Side effects, dynamic imports and re-export barrels routinely defeat it, which is why coverage data disagrees with the theory.

Apply it

Where the depth lives

Build tooling
Bundle composition and module graph analysis

Which dependency pulled 180KB into the entry chunk is a build-graph question; the performance domain only tells you that it cost 240ms of parse time on a phone.