PerformanceGENERALENGINE-SPECIFICDEVICE-SPECIFICSIMPLIFIED

The Real Cost of JavaScript

Bytes are only the download. Parse, compile, execute and retain all cost more, they all scale with the device, and execution competes with rendering for the one thread that can paint.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

My bundle is smaller than it was and the app is not faster — what does shipping JavaScript actually cost?

The user intent

A person opens an application and expects it to work. They do not care how it was built; they care that the first tap does something.

The obvious build

JavaScript costs bytes. Track the gzipped bundle size, keep it under a budget, and performance follows.

Why it breaks

Compression shrinks the transfer and not the work. The engine parses and compiles the *decompressed* source, so a heavily-compressed bundle downloads quickly and then costs exactly as much CPU as it always did (Minification Is Not Compression).

How it breaks in a real browser
  • Compression shrinks the transfer and not the work. The engine parses and compiles the *decompressed* source, so a heavily-compressed bundle downloads quickly and then costs exactly as much CPU as it always did (Minification Is Not Compression).
  • A bundle that downloads in a moment on a fast connection can occupy a mid-range phone's main thread for a stretch long enough that every tap during it is ignored (Long Tasks).
  • Two bundles of identical size can cost very different amounts to execute. A hundred kilobytes of lookup tables is nearly free; a hundred kilobytes of module initialisation that builds objects, registers observers and walks the DOM is not.
  • Execution does not merely take time — it takes the *only* thread that can paint. Content that has arrived cannot be rendered while script is running (What the Main Thread Owns).
  • The code keeps costing after it has run: the objects it allocated are retained, garbage collection pauses grow with the live set, and memory pressure on a constrained device leads to the tab being discarded (Memory Leaks).
  • Tree shaking removes what is provably unused. A dependency with side effects at module scope is not provably unused, so the number on the dashboard goes down and the work does not (Tree Shaking).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Shipping a script has five distinct costs, in order: transfer, decompress, parse, compile, execute — and then a sixth that never ends, retain.
  • Transfer scales with the compressed size and the network. This is the only one a compression change improves, and it is the one everybody measures.
  • Parse scales with the uncompressed source size. The engine must at minimum scan the whole file to find function boundaries, and it does full parsing lazily for function bodies that are actually called.
  • Compile happens in tiers: a fast baseline compiler gets code running quickly, and an optimising tier recompiles the hot parts later. Startup pays the baseline cost for everything that runs; steady state pays optimisation and occasionally deoptimisation (JIT and Warm-Up: The First Thousand Requests Are a Different Program in Observability).
  • Execute is the part your code is about — but at load time most of it is module initialisation, not application logic: building registries, creating class hierarchies, instantiating clients, registering listeners.
  • Retain is the long tail. Everything reachable stays in memory, garbage collection cost scales with the live set, and a large live set on a constrained device is how a tab gets discarded (Garbage Collection: Pause, Throughput, Footprint — Pick Two in Observability).
  • All of parse, compile and execute happen on the main thread, so all of them are in direct competition with rendering and input (The Event Loop, Precisely).

What this makes the browser do

And which of it is avoidable.

  • Streaming compilation: engines can begin compiling script while it is still downloading, which hides some of the compile cost behind the transfer — but only for scripts delivered with the right content type and only for the portion already received.
  • Code caching: after a script has been executed, engines may cache compiled bytecode keyed by URL, so a repeat visit skips some of the parse and compile cost. Changing the URL on every deploy discards that cache, which is a real cost of aggressive content hashing (Content-Hashed Assets).
  • Lazy function compilation: function bodies are not fully compiled until first call, which is why a bundle that calls everything at startup costs disproportionately more than one of the same size that does not.
  • Avoidable: code shipped to a route that does not need it, polyfills shipped to browsers that do not need them, and module-scope work that could happen on demand. Unavoidable: the cost of the code that genuinely runs.

Six costs, one number on the dashboard

The bundle-size number on a dashboard is one input to one of six costs. That is not an argument against measuring it — it is the cheapest proxy available — but it explains the common experience of shrinking a bundle and seeing no change in anything users feel.

Walk the six for a specific script and it becomes obvious which lever applies. Compression only moves the second. Splitting moves the first five for the code that no longer ships. Moving work out of module scope moves the fifth without changing any of the others.

What a script costs, in order
  1. 1
    Transfer

    Compressed bytes cross the network, subject to bandwidth, latency and priority.

    fails by A large payload on a slow link, or a chunk discovered late in the dependency graph.

  2. 2
    Decompress

    The browser expands the response back to source before the engine sees it.

    fails by Rarely the bottleneck, and worth naming because it is the step people implicitly assume applies to everything after it.

  3. 3
    Parse

    The engine scans the uncompressed source, finds function boundaries, and builds an internal representation.

    fails by Scales with source size, not with compressed size — so a heavily-compressed bundle pays full price here.

  4. 4
    Compile

    A baseline tier gets code running; an optimising tier recompiles hot functions later.

    fails by Startup pays baseline compilation for everything that actually runs, and deoptimisations during execution pay again.

  5. 5
    Execute

    Module initialisation runs: registries built, clients constructed, observers registered, globals patched.

    fails by Work at module scope runs on every load whether or not the feature is used, and it holds the main thread while it does.

  6. 6
    Retain

    Everything reachable stays in memory for as long as it is reachable.

    fails by Garbage collection cost scales with the live set, and a large live set on a constrained device leads to discarded tabs (Memory Leaks).

Only the first step gets cheaper when a file compresses better. The other five scale with the source and with what it does.

The same bundle, two devices

DEVICE-SPECIFICThe two device columns describe the shape of the difference, not measured values: the point is that transfer and CPU scale on different axes, so the phase to attack differs between a laptop on office wifi and a phone on a congested mobile network.

The reason this reframe matters is that the six costs do not scale together. Network time scales with bandwidth and latency; the CPU phases scale with the processor, its caches and its thermal budget. Change the device and the ranking changes, which is why a profile from a development machine can point at the wrong phase entirely.

The column headings below deliberately do not carry numbers. What transfers is which phase grows when the device gets slower, and which lever moves it.

CostScales withOn a fast laptopOn a mid-range phoneWhat actually reduces it
TransferCompressed size, bandwidth, latency, priorityOften the largest single phaseLarge, and no longer dominantCompression, splitting, caching, fewer dependencies
ParseUncompressed source sizeSmall enough to ignoreSubstantial and CPU-boundShipping less source; modern syntax instead of transpiled output
CompileSource size and how much is actually calledMostly hidden behind downloadClearly visible in the traceCalling less at startup; code caching on repeat visits
ExecuteWhat the code does at module scopeFast, and hard to noticeThe phase that eats the first interactionMoving work out of module scope; lazy initialisation
RetainLive object graph sizePlenty of headroomDiscarded tabs and long collection pausesBounded caches, torn-down subscriptions, smaller working set
CompetitionHow long the thread is held at a timeFrames still landInput ignored, frames droppedYielding, chunking, moving work to a worker

Where the cost actually lives

Two modules of identical size can differ by an order of magnitude in what they cost at startup, and the difference is visible by reading them. The question to ask of any import is: what runs when this module is evaluated, before anyone has called anything?

The second version below is not smaller. It ships the same functions and the same locale tables. It simply does not do any of the work until something asks for it, which removes it from the startup phase entirely for the majority of sessions that never open the report screen.

Module-scope work versus work on demand
1// Costly at import: this all runs during the execution phase of
2// first load, whether or not the user ever opens a report.
3import { locales } from './locales' // large object literal
4const formatters = new Map(
5 locales.map((l) => [l, new Intl.NumberFormat(l)]),
6)
7export const analytics = new AnalyticsClient({ endpoint: '/t' })
8window.addEventListener('resize', recomputeLayoutCache)
9
10// ---------------------------------------------------------------
11
12// Same capability, none of it at import time.
13let formatters: Map<string, Intl.NumberFormat> | undefined
14export function formatterFor(locale: string) {
15 formatters ??= new Map()
16 let f = formatters.get(locale)
17 if (!f) { f = new Intl.NumberFormat(locale); formatters.set(locale, f) }
18 return f
19}
20
21// The report screen is the only thing that needs the client.
22export async function openReport() {
23 const { AnalyticsClient } = await import('./analytics-client')
24 return new AnalyticsClient({ endpoint: '/t' }).session()
25}

Three separate wins, only one of which shows up as bytes. The Intl formatters are no longer constructed for locales nobody uses; the analytics client moves into a chunk most sessions never request; and the module-scope resize listener — which also retained a layout cache for the life of the tab — is gone. A bundle report would rate these two files as roughly the same size.

How to build it

Most important first.

  • Ship less code to the first screen. Route-level splitting is the highest-value change, because it removes transfer, parse, compile and execute at once (Code Splitting).
  • Move work out of module scope. A module that only defines things costs parse and a little compile; a module that builds a registry at import time costs execution on every load whether or not the feature is used.
  • Audit dependencies by what they cost to run, not by what they weigh. A date library that installs locale data at import is more expensive than its size implies (Bundle Analysis).
  • Serve modern syntax to browsers that support it. Transpiling to an old target inflates the source the engine must parse — and transpilation and polyfilling are separate decisions with separate costs (Polyfills vs Transpilation).
  • Defer or lazily initialise anything not needed for the first interaction: analytics, feature flags, editors, chart libraries, internationalisation bundles for locales the user does not have (Lazy Loading).
  • If the work is genuinely CPU-bound and does not need the DOM, move it to a worker so it stops competing with rendering (When a Worker Is Actually the Answer).
  • Set the budget on the metric you care about — main-thread time to interactive on a representative device — and treat the byte budget as a proxy that needs re-checking (Measure Before Optimising).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Everything the main thread is doing delays the accessibility tree along with the pixels. A long execution phase means a screen reader is reading stale structure or nothing at all (The Accessibility Tree).
  • Before hydration, server-rendered controls are visible and inert. A keyboard user who tabs to a button and presses Enter during that window gets no response and no explanation (Hydration).
  • Shipping less JavaScript is often an accessibility improvement by itself, because the platform's own controls arrive with keyboard behaviour, focus handling and semantics already correct (What Native Elements Already Do).
  • If a feature must be lazily loaded, keep focus managed across the boundary: focus that lands on an element which is then replaced by the loaded component is lost (Focus Management).

What can go wrong

Failure modes
  • Splitting into many small chunks that are all requested at startup anyway, which converts one parse into many plus request overhead and dependency depth (The Module Graph).
  • Lazy-loading something the first interaction needs, so the user's first tap now waits for a network round trip that used to be free.
  • A byte budget met by moving code into a dynamically imported chunk that is imported immediately, which is a reporting change rather than a performance change.
  • Preloading every lazy chunk "to be safe", which restores the original cost while keeping the added complexity.
  • Assuming a worker is free. The main thread still pays to serialise the message and to apply the result to the DOM (Structured Clone and Transferables).
  • Aggressive content hashing on every deploy, which is correct for cache invalidation and throws away the engine's compiled code cache each time (Deploying a Frontend).
What can arrive out of order
  • Chunks requested in parallel arrive in an order the network chooses. Code that assumes one lazy module initialised before another has a race that only appears on a slow connection (ESM vs CommonJS).
  • Hydration and user input race: a tap during the execution phase is queued and replayed late, or dropped, depending on the framework (Hydration).
  • A deploy can change chunk URLs while a client is running, so a lazily imported chunk from the previous build can 404 mid-session (Long-Lived Clients and Version Skew).
Security
  • Every byte of JavaScript on the page is a byte an attacker can read, and every dependency is a supply-chain entry point that executes with the page's full authority (Third-Party Scripts and the Supply Chain).
  • Lazy chunks are fetched at runtime from URLs your bundler generated, which means your Content-Security-Policy must permit them and any dynamic chunk name is a policy consideration (Content Security Policy).
  • Shrinking a bundle by removing a validation library does not remove the need for validation. Client-side checks are a user-experience feature; the server remains the enforcement point (What the Frontend Is Responsible For in Auth).
  • Source maps published to production make the original source readable. That is often the right trade for debuggability and it should be a decision rather than an accident (Source Maps).
Misreads
  • "Our bundle is gzipped so it is small." Compressed size predicts transfer. Parse and compile scale with the uncompressed source, and execution scales with what the code does (Minification Is Not Compression).
  • "Minifying and compressing are the same thing." Minification rewrites the source before it is served; compression encodes bytes for transport and is undone before the engine sees them. They compose, and only one of them reduces parse cost.
  • "Tree shaking removed it." It removed what it could prove was unused. Side effects at module scope defeat that proof, and a smaller graph on paper can be the same work at runtime (Tree Shaking).
  • "The bundle is under budget, so we are done." A byte budget is a proxy for a CPU budget. Two bundles of the same size can differ severalfold in main-thread time.
  • "Performance equals bundle size." Bundle size is one input to one of six costs. Loading structure, rendering work, memory and interaction handling are not visible on that dashboard at all.
  • "A worker makes it free." It makes it parallel. Message serialisation and DOM application still happen on the main thread (Talking to a Worker).

Measuring it, and what changes in the field

How you would see this
  • Main-thread time attributed by script URL in the Performance panel — the single most useful view, because it prices dependencies by what they cost rather than by what they weigh.
  • The bundle analysis report, read for what initialises at import time and not only for what is large (Bundle Analysis).
  • A throttled-CPU profile of first load, which is the only local configuration where the execution phase is proportionate to the field (Measure Before Optimising).
  • Field data on responsiveness early in the session, which is where excess startup JavaScript shows up as input delay (Interaction Responsiveness).
  • The Observability domain's treatment of bundle cost as a production signal, for the cross-check between what you shipped and what users paid (JavaScript Costs Four Times, Not Once in Observability).
Slow device, slow network, large data, old tab
  • On a mid-range phone, parse, compile and execute all take multiples of what they take on a development laptop — routinely several times, and the multiplier applies to the phase that is already the largest.
  • On a slow network the transfer phase dominates and shrinking bytes genuinely helps; on a fast network with a slow device the CPU phases dominate and the same change does very little (Loading: Why Content Arrives Late).
  • On a repeat visit, code caching removes part of the parse and compile cost — unless the URL changed, in which case it removes none of it.
  • On a memory-constrained device the retain cost becomes the dominant one: background tabs are discarded, and returning to the app is a full cold start (Long-Lived Clients and Version Skew).
  • Under thermal throttling, a device that was fast at the start of a session is slower later, so the same code costs more the longer someone uses it (The First Ten Seconds Lie in Computer Architecture).
What this costs
  • Splitting reduces initial cost and adds requests, dependency depth and the risk of a lazy boundary in front of a common action. On high-latency connections that trade can invert (Code Splitting).
  • Serving modern syntax to modern browsers means maintaining more than one build output, or accepting that older browsers get a worse experience — a decision to make explicitly (Polyfills vs Transpilation).
  • Moving work to a worker removes main-thread cost and adds a serialisation boundary, a second copy of state, and a much harder debugging story (Web Workers and the DOM Boundary).
  • A framework that ships less runtime often does more at build time, which trades user CPU for build complexity and a stricter set of things you may write (Reactivity Models).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThat transfer, parse, compile, execute and retain are separate costs — and that all but the first happen on the main thread — is true of every browser engine, because it follows from having a single thread that owns both script and the DOM.
  • ENGINE-SPECIFICTiering, lazy parsing, code caching and streaming compilation are implementation strategies: V8, SpiderMonkey and JavaScriptCore all do versions of them with different heuristics and different cache keys, so the size of the win from a repeat visit differs by browser even for identical bytes.
  • DEVICE-SPECIFICThe multiplier between a development laptop and a mid-range phone applies to the CPU phases and not to transfer, so the ranking of the six costs is different on the two devices: the same bundle can be transfer-bound on one and compile-bound on the other.
  • SIMPLIFIEDReal engines interleave these phases aggressively — parsing during download, compiling lazily per function, optimising and deoptimising during execution. The linear ordering here predicts where cost lands correctly and understates how much overlap there is.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — lazy parsing, tiered compilation, inline caches and deoptimisation are engine mechanisms; this lesson only needs their shape, and the depth belongs there.
  • Compilers & Programming Languages — how a bundler builds the module graph, what a side effect means to a tree shaker, and why transpiling to an older target inflates the source the engine must parse.