Skip to content
academia.sh

Lesson 05 / 22

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.

Contents

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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close