---
title: 'Code Splitting and Lazy Loading'
source: 'https://academia.sh/en/courses/frontend-architecture/code-splitting-and-lazy-loading'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Code Splitting and Lazy Loading

Splitting the bundle along route boundaries; the dependency closure, forming chunks by usage signature, measuring the initial load cost, the extra download per navigation, prefetching, and the waiting and error states splitting introduces.

Routing now knows which screen is open to whom. What it does not know is when that
screen's code gets downloaded. Every screen in the North Slope Measurement Station
application comes down together in a single bundle: a user who came only to sign in
also downloads the measurement chart's drawing library and the station map.

The route definition already carries the information needed to make this split. Which
component is used on which path is written into the tree; which path the user is on is
the one thing known at runtime. **Code splitting** is the work of combining these two
pieces of information and dividing the bundle along route boundaries.

## The Dependency Closure

What a route actually needs is not limited to the modules it uses directly. Modules
depend on each other: the measurement chart uses the drawing module, and that in turn
uses date formatting. A route's cost is the sum of every module reachable from it in
the dependency graph. This set is called the **dependency closure**, and it is computed
with the graph traversal from the Data Structures course.

Once closures are derived, a second question follows: if a module is used in more than
one route, which chunk does it go into? Copied into every route's chunk, the same code
downloads more than once. Placed into a single shared chunk, routes that do not want it
are forced to download it anyway.

The solution is grouping modules by **usage signature**: a module's signature is the
set of routes that request it. Modules sharing the same signature download together,
because they are always needed together.

```js
// code-splitting.mjs — route dependency closure, signature-based chunking, and cost
const MODULE = {                       // size: transferred kB, deps: directly called modules
  "core": { size: 38, deps: ["design-system"] },
  "design-system": { size: 24, deps: [] },
  "table": { size: 18, deps: ["design-system"] },
  "chart": { size: 96, deps: ["design-system", "date-formatting"] },
  "map": { size: 120, deps: ["design-system"] },
  "form-validation": { size: 15, deps: ["design-system"] },
  "date-formatting": { size: 12, deps: [] },
  "export": { size: 30, deps: [] },
};

// Each route: the list of modules needed along its own layout chain.
const ROUTE = {
  "/session": ["form-validation"],
  "/station": ["table", "map"],
  "/station/:id": ["table", "date-formatting"],
  "/station/:id/measurements": ["chart", "table", "export"],
  "/station/:id/settings": ["form-validation"],
};

const ENTRY = ["core"];           // entry point downloaded on every visit

function closure(roots) {            // every module reachable in the dependency graph
  const seen = new Set(), stack = [...roots];
  while (stack.length) {
    const m = stack.pop();
    if (seen.has(m)) continue;
    seen.add(m);
    stack.push(...MODULE[m].deps);
  }
  return seen;
}

const total = (set) => [...set].reduce((t, m) => t + MODULE[m].size, 0);

const entryClosure = closure(ENTRY);
const routeClosure = Object.fromEntries(
  Object.entries(ROUTE).map(([route, k]) => [route, closure(k)]));

// Usage signature: the set of routes that request a module. Same signature = same chunk.
const signature = new Map();
for (const [module] of Object.entries(MODULE)) {
  if (entryClosure.has(module)) continue;                 // already in the entry chunk
  const requesters = Object.keys(ROUTE).filter((y) => routeClosure[y].has(module));
  if (requesters.length === 0) continue;
  const key = requesters.join("+");
  if (!signature.has(key)) signature.set(key, []);
  signature.get(key).push(module);
}

console.log("-- chunks --");
console.log(`entry${" ".repeat(3)}${String(total(entryClosure)).padStart(4)} kB  ${[...entryClosure].sort().join(", ")}`);
const chunkName = new Map();
let n = 0;
for (const [key, modules] of [...signature].sort((a, b) => b[0].split("+").length - a[0].split("+").length)) {
  const name = `chunk-${++n}`;
  chunkName.set(key, name);
  console.log(`${name} ${String(modules.reduce((t, m) => t + MODULE[m].size, 0)).padStart(4)} kB  ${modules.sort().join(", ")}`);
  console.log(`${" ".repeat(12)}requested by: ${key.split("+").join("  ")}`);
}

const singleBundle = total(new Set([...entryClosure, ...Object.values(routeClosure).flatMap((s) => [...s])]));
const split = (route) => total(new Set([...entryClosure, ...routeClosure[route]]));

console.log("-- initial load cost --");
console.log("route".padEnd(26), "single bundle", "split", "gain");
for (const route of Object.keys(ROUTE)) {
  const b = split(route);
  console.log(route.padEnd(26), String(singleBundle).padStart(9), String(b).padStart(9),
    `${String(Math.round((1 - b / singleBundle) * 100)).padStart(5)}%`);
}

console.log("-- extra download per navigation --");
for (const [previous, next] of [
  ["/station", "/station/:id"],
  ["/station/:id", "/station/:id/measurements"],
  ["/station/:id/measurements", "/station/:id/settings"],
  ["/session", "/station"],
]) {
  const downloaded = new Set([...entryClosure, ...routeClosure[previous]]);
  const missing = [...routeClosure[next]].filter((m) => !downloaded.has(m));
  console.log(`${previous} -> ${next}`.padEnd(52),
    String(missing.reduce((t, m) => t + MODULE[m].size, 0)).padStart(4), "kB ",
    missing.sort().join(", ") || "-");
}
```

```
-- chunks --
entry     62 kB  core, design-system
chunk-1   18 kB  table
            requested by: /station  /station/:id  /station/:id/measurements
chunk-2   15 kB  form-validation
            requested by: /session  /station/:id/settings
chunk-3   12 kB  date-formatting
            requested by: /station/:id  /station/:id/measurements
chunk-4  126 kB  chart, export
            requested by: /station/:id/measurements
chunk-5  120 kB  map
            requested by: /station
-- initial load cost --
route                      single bundle split gain
/session                         353        77    78%
/station                         353       200    43%
/station/:id                     353        92    74%
/station/:id/measurements        353       218    38%
/station/:id/settings            353        77    78%
-- extra download per navigation --
/station -> /station/:id                               12 kB  date-formatting
/station/:id -> /station/:id/measurements             126 kB  chart, export
/station/:id/measurements -> /station/:id/settings     15 kB  form-validation
/session -> /station                                  138 kB  map, table
```

The chunk list is the result of signature grouping. The design system falls into the
entry chunk because every route uses it. The table module is shared by three routes and
sits in a single chunk; it is not copied into three routes, nor is it loaded into the
sign-in screen that does not want it. The export and chart modules merge into the same
chunk, because only the measurement history requests either one — splitting them into
separate chunks would produce an extra request with no gain for any route.

## The Measured Gain

The second table states why splitting is worth doing, in numbers. A user arriving at
the sign-in screen downloads 77 kB instead of 353 kB. The gain varies by route: it is
smaller on routes that request the map or the chart, because the real weight sits
there.

Two decisions follow from this. Splitting a heavy module pays off far more than
splitting a light one; splitting effort is directed by size. And the gain is bounded by
the size of the entry: the 62 kB downloaded on every visit is not reduced by any amount
of splitting. Putting an unnecessary module into the entry chunk gives back what
splitting gained.

Splitting also has a cost that does not show up in the table: every chunk is a separate
request. This cost, defined in the Bundler Concept lesson of the Modules, Tooling and
the Ecosystem course, can outweigh the bytes downloaded for very small chunks. The rule
is this: a chunk is not split out if it does not pay for itself in avoided download.

## The Cost Per Navigation

The third table shows splitting's effect after the initial load. Moving from the
station detail view to the measurement history requires a new 126 kB download. This
download starts the moment the user presses the link; until it finishes, there is no
content on screen.

With a single bundle, this wait did not exist — the code had already downloaded.
Splitting redistributes the initial load time into navigation time. This trade-off
is not always worth it: delaying the code for a screen the great majority of users enter
gives back, during navigation, more than what was gained on initial load.

The mechanism that removes this trade-off is **prefetching**. A chunk is downloaded
ahead of need, during idle time. Routing has two good signals for this: links that
become visible on screen, and the link the user hovers over. The intersection observer
introduced in The Browser and the Web Platform course supplies the first; pointer
events supply the second.

Prefetching must never get ahead of the real request. It is done at low priority and
triggered in an idle window; on metered network connections it is not done at all.
Otherwise it competes with the data the user actually needs at that moment.

## Waiting and Error States

Loading a split chunk is an asynchronous operation; two new states follow from this,
and both have to be met in the interface.

The **waiting** state determines what shows while the chunk downloads. Leaving a blank
screen leads the user to think navigation is not working. The correct behavior is to
keep the preserved part of the layout chain on screen and place an indicator only in
the changing outlet: because the shell stays in place, the screen does not jar. For very
short waits, the indicator flashing on and off is also uncomfortable; it is shown only
after a small delay.

The **error** state is more often forgotten. The network may have dropped; the chunk
may have been removed from the server. The second case is more common than expected:
if a new version is published while the user has the page open, the chunk names the
old page requests no longer exist. This is a direct application of the Error and Retry
Patterns lesson from the Asynchronous JavaScript and the Runtime course: a failed load
is retried once, and if it fails again, an error view is shown suggesting the user
refresh the page.

A third trap intersects with guards. The chunk for a screen an unauthorized user could
never see should not be downloaded at all; the guard decision is made **before** the
chunk request. Otherwise, splitting both downloads unnecessary bytes and tells an
unauthorized user that the screen exists.

## Summary

- A route's cost is the closure of every module reachable from it in the dependency
  graph.
- Modules are grouped by usage signature — the set of routes that request them;
  modules sharing a signature go into the same chunk, so nothing is duplicated or
  downloaded unnecessarily.
- Splitting's gain is bounded by the size of the entry and concentrated in heavy
  modules; because every chunk carries a request cost, a split that does not pay off
  is not made.
- Splitting redistributes initial load time into navigation time; prefetching done
  during idle time at low priority softens this trade-off.
- Chunk loading is asynchronous: the waiting state keeps the preserved shell on screen,
  and the error state needs a retry and a clear error view.
- The guard decision is made before the chunk request; the code for a screen the user
  cannot see is never downloaded.

## Next Step

Routing is complete: the address determines which screen appears, who it is open to,
and which code gets downloaded. A kind of state carried by the address was also
defined — filter, sort, page. The rest of the application's state, though, is still
scattered: identity information sits in the guards, the station list somewhere else,
open panels inside components, measurements from the server somewhere else again.
Calling all of this "state" hides the most important difference among them: some of it
is produced by the application itself, some of it is a copy of a remote source. The
next topic starts by splitting state into its kinds and establishes the criteria that
decide where each kind belongs.
