skip to content

Bundle Size and Code Splitting

Shipping less JavaScript is the highest-leverage frontend optimization there is. This group covers where to split, how to hold a size budget, and what third-party scripts really cost.

on this pageshow

explore

questions

24

In a single-page app, route-based code splitting ships each route's JavaScript as a separate chunk fetched on navigation. What does that change about the first page load, and what does it not change about the total JavaScript a user who eventually visits every route downloads?

level: juniorimportance: must knowfreq 72%

answer

  1. moves bytes, does not delete them
  2. first screen versus whole session
  3. parse and execute, not just transfer
  4. navigation now pays a round trip
  5. total across all routes is slightly larger

basics

~20 s

Route-based splitting shrinks the first download to the landing route's code, so the app becomes interactive sooner. It does not reduce total JavaScript: a user who visits every route still fetches all of it, just later and in more requests.

solid answer

~50 s

Route splitting is a **scheduling** change, not a size reduction. Instead of one bundle containing every screen, the entry chunk carries only the shell plus the landing route, and each other route is fetched when the user navigates to it. The win is on first load: fewer bytes to download, parse, and execute before the page is usable, which is what most first-visit metrics actually measure. What it does not do is make the app smaller. Someone who tours every route downloads roughly the same total code — slightly more, in fact, because of per-chunk overhead and worse compression on many small files — and each navigation now costs a network round trip that the monolithic bundle had already paid for. So it trades total bytes and navigation latency for a much cheaper first paint, which is almost always the right trade because most sessions touch only one or two routes.

code

javascript · 16 lines
javascript
const routes = {
  '/': () => import('./routes/home.js'),
  '/reports': () => import('./routes/reports.js'),
  '/admin': () => import('./routes/admin.js'),
};

async function navigate(path) {
  const loader = routes[path];
  if (!loader) return;
  const mod = await loader();
  mod.render(document.querySelector('#app'));
}

// Cover the navigation round trip: warm the likely next route.
document.querySelector('a[href="/reports"]')
  ?.addEventListener('pointerenter', () => routes['/reports'](), { once: true });

go deeper

for a junior

Be able to say plainly that splitting delays code rather than deleting it, and that the benefit lands on the first load. Know that the router imports each route on demand instead of up front.

for a middle

Explain the three costs that shrink — transfer, parse/compile, execute — and the new cost introduced at navigation time. Be ready to describe prefetching the likely next route to hide that round trip.

for a senior

Show judgment about which routes earn a boundary: weigh unique code size against the share of sessions that reach the route, and point out that splitting a first-paint dependency moves bytes later without removing them from the critical path.

for a principal

Frame it as optimizing the distribution of real sessions rather than a worst-case tour, and connect chunk boundaries to cache lifetime across deploys — per-route hashing means a change to one screen no longer invalidates everyone's cached bundle.

## What route-based splitting is Without splitting, a build produces one JavaScript file containing every screen, dialog, and helper the application can ever show. The browser must download that file, parse it, and execute it before the app's own code can run — even if the visitor only ever looks at the login page. Route-based splitting means the router does not statically import the modules for each screen. Instead it holds a function per route that dynamically imports the module, and the bundler turns each of those import boundaries into a separate output file (a *chunk*). Only the entry chunk — application shell, router, shared framework code, plus whatever the landing route needs — is fetched up front. ```js const routes = { '/': () => import('./routes/home.js'), '/reports': () => import('./routes/reports.js'), '/admin': () => import('./routes/admin.js'), }; async function navigate(path) { const mod = await routes[path](); mod.render(document.querySelector('#app')); } ``` ## What the first load gains Three costs shrink together, and they are not the same cost: 1. **Transfer** — fewer compressed bytes over the network, which matters most on slow or metered connections. 2. **Parse and compile** — engines must parse the JavaScript they receive; this is roughly proportional to uncompressed size and is felt hardest on low-end mobile CPUs. 3. **Execution** — module bodies for screens the user never opens no longer run at startup. The second and third are why splitting helps more than the byte count alone suggests. A 200 KB reduction in transfer is also a few hundred milliseconds of main-thread work removed from the critical startup window on a mid-range phone. ## What it does not change **Total code over a full session.** Splitting moves bytes; it does not delete them. A user who visits home, reports, and admin fetches all three chunks. If anything they fetch slightly *more* than the single bundle would have been: each chunk carries a little module bookkeeping, and compression algorithms find fewer repeated patterns in small files than in one large one, so the sum of compressed chunk sizes usually exceeds the compressed monolith. **Dead code.** Splitting is orthogonal to removing unused exports. A module that is 90% unused is still 90% unused inside its own chunk. **Navigation cost.** The monolithic bundle had already paid for every route's code, so route changes were instant. With splitting, the first visit to a route waits on a network request. On a good connection that is tens of milliseconds; on a bad one it is a visible stall, and it lands during an interaction, where users are least tolerant of delay. Mitigations are prefetching the likely-next route during idle time and keeping route chunks small enough that a fetch is cheap. **Whether the split helps at all.** If a module is needed to render the first screen, moving it into a lazy chunk does not remove its bytes from the critical path — it just makes them arrive one round trip later. Splitting only pays when the split-off code is genuinely not needed yet. ## Why the trade is usually right Session data on most products is heavily skewed: a large share of visits touch one or two routes and then leave. Optimizing for the total bytes of an exhaustive tour optimizes for a user who does not exist. Optimizing for the first screen optimizes for nearly everyone, including first-time visitors who have nothing cached and are the most likely to bounce. There is a caching angle too. With one bundle, changing any line invalidates the whole file for every returning user. With per-route chunks and content-hashed filenames, editing the admin screen invalidates only the admin chunk; everything else stays warm in the HTTP cache. ## What good answers mention - Splitting is a *deferral*, not a diet: name total-bytes-unchanged explicitly. - The parse/execute saving, not just the transfer saving. - The new cost it introduces — a round trip at navigation time — and how you cover it (prefetch on intent or during idle). - Route boundaries are the natural first split because they align with what the user is not looking at yet, and because there are only a handful of them, which keeps chunk count sane.

  • If total bytes go up, why is route splitting still considered a performance win?
    Because the metric that matters is bytes on the critical path of a real session, not bytes summed over an exhaustive tour of the app. Most sessions visit one or two routes, so the extra total is paid by almost nobody, while every visitor benefits from a smaller, faster-parsing first load. The overhead per chunk is also small relative to a route's own code.
  • A team splits a component that renders in the landing route's first paint. What happens?
    Nothing improves and something gets worse. The bytes are still required before first paint, so they stay on the critical path — but now they are discovered only after the parent chunk executes, adding a network round trip. You also risk a visible placeholder or a layout shift where there was none. Split code the first screen does not need.
  • How would you decide which routes are worth splitting first?
    Look at two numbers per route: how much code it uniquely owns, and what share of sessions reach it. The best candidates are large and rarely visited — admin panels, settings, editors, onboarding flows, anything behind a permission check. A small route visited by everyone is not worth a chunk boundary.

saying these in an interview costs you the question

  • Claims splitting reduces the app's total JavaScript size
  • Thinks splitting removes unused code, confusing it with tree shaking
  • Splits code the first screen needs and expects a win
  • Forgets that each navigation now costs a network request
  • Assumes more chunks is always better

context

open as a page

A product manager wants evidence before agreeing to drop an analytics vendor from your site. How would you measure what that one third-party script actually costs the page, in numbers rather than adjectives?

level: middleimportance: must knowfreq 62%

basics

~20 s

Attribute cost per origin: count the requests and bytes that vendor's hosts pull, measure the main-thread time its scripts occupy, then block the domain and re-measure the same journey. The difference is the number to report.

open as a page

In a bundled web app you add `import { debounce } from 'lodash'` for one small helper, and the production bundle grows by roughly 70 kB even though the build has tree shaking enabled. How do you work out why, and what are your options for getting those bytes back?

level: middleimportance: must knowfreq 62%

basics

~20 s

Tree shaking cannot narrow what the module format hides: lodash's main published build is CommonJS, so a named import still drags in the whole library. Confirm the size in the build output, then deep-import lodash/debounce, switch to lodash-es, or drop the dependency.

open as a page

A frontend team writes its JavaScript bundle budget as "150 KB". Which measurement should that number refer to — the file on disk or the bytes on the wire — and why does the budget also have to name the compression algorithm?

level: juniorimportance: should knowfreq 55%

basics

~20 s

A transfer budget should be measured on the compressed bytes actually sent, not the file on disk, and it must name the algorithm: brotli output for the same JavaScript is typically noticeably smaller than gzip, so "150 KB" is ambiguous otherwise.

open as a page

In a JavaScript bundle treemap report such as the one webpack-bundle-analyzer produces, what does the area of each rectangle represent, and what does the report not tell you about the code inside it?

level: juniorimportance: should knowfreq 55%

basics

~20 s

Each rectangle's area is the byte size that module contributes to an emitted chunk, nested by its folder path. The report shows what is in the bundle and how heavy it is — never whether that code ever runs.

open as a page

Your team adds a vendor chat widget by pasting a single `<script src>` tag pointing at the vendor's own CDN, and the file it downloads is about 40 KB compressed. Why is the real cost of that widget to the page usually far larger than that number?

level: juniorimportance: should knowfreq 52%

basics

~20 s

The tag is only an entry script. It opens a connection to a new origin, then fetches more code and data at runtime, and everything it pulls parses and executes on your main thread, on the vendor's release schedule rather than yours.

open as a page

In a bundled ES-module app, does writing `import * as utils from './utils.js'` and calling only `utils.formatDate(d)` cost more bytes than `import { formatDate } from './utils.js'`?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Usually not: a modern bundler can see that only formatDate is read off the namespace and drops the rest. It costs bytes the moment the namespace object escapes — passed to a function, spread, or indexed with a variable — because then every export must be kept.

open as a page

Why is a bundle budget expressed only in compressed transfer bytes an incomplete control on the cost of JavaScript, and what second number would you budget alongside it?

level: middleimportance: should knowfreq 42%

basics

~20 s

Compressed bytes only measure download time. Parse, compile, execute and memory cost scale with the decompressed bytes, which can be many times larger and vary wildly by content — so budget uncompressed size, or estimated execution time, alongside transfer size.

open as a page

A bundle report for a production build shows the same package — say date-fns — appearing twice under two different node_modules paths at two different versions. Why does the bundler emit both copies, and how do you get down to one?

level: middleimportance: should knowfreq 50%

basics

~20 s

Two dependents asked for incompatible version ranges, so the package manager installed a nested second copy. The bundler resolves each import to its own file on disk, so both are distinct modules and both ship. You fix it by aligning the ranges, not in the bundler.

open as a page

In a single-page app, two lazily loaded route chunks each import the same heavy date-formatting library. What are the two ways a bundler can handle that shared dependency, and how do you decide between them?

level: middleimportance: should knowfreq 48%

basics

~20 s

Either the library is duplicated into both route chunks, or it is extracted into a shared chunk both routes load. Duplication costs repeat bytes for users who visit both routes; extraction costs an extra request per route. Decide by module size and how often routes are visited together.

open as a page

A web app's build places every dependency from node_modules into one large shared vendor chunk, separate from the application code. What is the caching argument for that layout, and what tends to break it on a real project?

level: middleimportance: should knowfreq 54%

basics

~20 s

The argument is cache lifetime: dependencies change less often than app code, so a separate vendor chunk stays cached across deploys. It breaks because one dependency bump rewrites the whole chunk's content hash, invalidating megabytes for every returning user.

open as a page

What is the facade (click-to-load) pattern for an expensive third-party embed such as a video player or a live chat widget, and what does adopting it trade away?

level: middleimportance: should knowfreq 46%

basics

~20 s

A facade is a cheap static stand-in you build yourself — a poster image with a play button, a plain chat button — that loads the real third-party code only when the user shows intent. Most visitors never pay the cost; those who engage wait once.

open as a page

An app has one budget for "total JavaScript" and it has been green for months, yet the checkout route's payload has doubled. How should byte budgets be scoped so a regression like that fails the build instead of hiding?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Budget each route's initial payload — its entry chunk plus the shared chunks that route loads — instead of the sum of all emitted JavaScript. An app-wide total grows with every route added, absorbs offsetting changes, and hides one route doubling.

open as a page

In a code-split single-page app, how do you work out how much JavaScript a cold first visit to one specific route actually downloads, and why is the size of that route's own chunk a misleading answer on its own?

level: seniorimportance: should knowfreq 40%

basics

~20 s

The honest figure is the sum of every chunk that visit must fetch — runtime, shared and vendor chunks, the route's chunk, and anything it lazily pulls in before rendering. A route chunk's own size counts only the last of those.

open as a page

Your production JavaScript ships as a handful of minified chunks with hashed filenames. How do you attribute those bytes back to the original source files and npm packages, and what do you have to be careful about when you do?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Build the production bundle with source maps and run a source-map-based analyzer such as source-map-explorer over each chunk and its map. It walks every generated byte range back to an original file, giving a size breakdown of what genuinely shipped after minification.

open as a page

Opening a route in a single-page app takes three sequential network round trips: the route chunk downloads, then executing it triggers a dynamic import of a chart module, which in turn dynamically imports a locale bundle. How do you confirm this chunk waterfall from a trace, and what are the ways to flatten it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Confirm it in a network trace: each chunk request starts only after the previous one finished and executed, forming a staircase. Flatten it by making child chunks discoverable up front with preload hints, starting the imports in parallel, or merging small children into the parent.

open as a page

A team proposes self-hosting a third-party vendor's JavaScript from your own domain instead of loading it from the vendor's CDN. What does that buy you in performance terms, and what do you take on?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Self-hosting removes an extra origin handshake, lets you set your own cache lifetime and priority, and lets you pin a reviewed version. In exchange you own updating it, some vendors break or forbid it, and the script often still calls home anyway.

open as a page

On a site where a marketing team publishes tags through a tag-manager container, page weight and main-thread time keep growing in weeks when the frontend team ships nothing at all. Why is that cost so hard to control, and what would you put in place?

level: seniorimportance: should knowfreq 42%

basics

~20 s

A tag-manager container is a second deploy channel that bypasses code review, CI and your bundle checks, and each tag can load further vendors at runtime. Control it with an owner and expiry per tag, publishing restrictions, a script-source allowlist, and field monitoring by origin.

open as a page

A feature flag was fully rolled out six months ago, yet both branches of `if (flags.newCheckout) { ... } else { ... }` are still in the production bundle. What determines whether a build can delete the dead branch, and what would you change?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Dead-code elimination is static: a branch is removed only when the condition folds to a constant at build time. A flag read from runtime configuration is opaque, so both branches ship. The real fix is retiring the flag and deleting the code, not making the build smarter.

open as a page

Bundle analysis shows one dependency accounts for roughly 40 percent of your main bundle. How do you decide between replacing it, deferring it, or accepting the cost?

level: principalimportance: should knowfreq 36%

basics

~20 s

Decide from what the dependency does for users, not its share of the bundle. Establish who needs it and when, how much of it is genuinely used, what a lighter path would cost in migration risk, and whether removing it would move a metric anyone tracks.

open as a page

As the engineer accountable for a site's speed, how would you decide which third-party vendors are worth their performance cost, and what would you do about the ones that are not?

level: principalimportance: should knowfreq 33%

basics

~20 s

Put cost and value in the same table: measure each vendor's requests, bytes and main-thread time, then run a holdback to see whether its claimed business value survives its absence. Keep what pays, contain the rest by tier, and make admission a reviewed decision.

open as a page

You inherit a frontend app whose main bundle is already several times larger than any budget you would sensibly set. How do you introduce bundle-size budgets without turning the build red on day one?

level: seniorimportance: nice to knowfreq 36%

basics

~20 s

Set the initial baseline at today's measured size plus a small tolerance, so the check fails only on regression, then ratchet it downward automatically whenever a build comes in under. Keep a separate, user-derived target as the destination the ratchet is heading toward.

open as a page

A build fails because a genuinely needed feature pushes a route's JavaScript over its size budget. What policy around bundle budgets keeps that failure useful, rather than something teams routinely override?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Treat the breach as a forced decision with named options — pay for the feature by cutting or deferring something else, ship it lighter, or raise the budget deliberately with an owner and a recorded reason — and never let the raise happen silently in the same change that caused it.

open as a page

A charting dependency your product relies on publishes only a CommonJS build and accounts for a large share of your main bundle. As the person making the call, how do you weigh working around it, forking it, replacing it, or simply accepting the bytes?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Decide by who pays and what the alternatives cost, not by whether the bytes offend you. Establish how many users actually reach the feature, try deep imports first, price a replacement's migration honestly, and treat forking as taking on permanent maintenance.

open as a page