---
title: 'Code Splitting'
source: 'https://academia.sh/en/courses/rendering-strategies/code-splitting'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# Code Splitting

Separating static and dynamic edges; determining chunk roots, computing what downloads at first load versus later, per-request cost, and merging over-split chunks.

The chunks in the previous lesson were all born from entry points, and all of them were
needed at first load. All six modules in the browser entry's closure downloaded before
the user touched anything. But that is not true of the North Slope Measurement Station
application: the measurement list is visible on every visit, while the station
operator's panel is needed only once that panel opens. The panel's chart rendering and
export code should not be among the bytes a visitor who has only come to read the page
downloads.

The source has a syntax that marks this distinction. An `import()` call adds a
dependency to the graph but separates it from the first load. This lesson's question is
how that marker changes the output chunks, and where splitting pays off and where it
turns into a loss.

## Two Kinds of Edge

The graph has two kinds of edge, and the distinction is time.

A **static edge** is a top-of-source import declaration. By the rule established in the
module systems topic, these declarations are resolved before the module is evaluated: a
dependency must be ready before the module that depends on it. Two modules connected by
a static edge are needed at the same time.

A **dynamic edge** is an `import()` call. The call returns a promise; the dependency is
not loaded until the call executes. Two modules connected by a dynamic edge are not
needed at the same time, and a time boundary sits between them.

This time boundary turns into a file boundary on the output side. A dynamic import's
target can be placed in a separate file along with its own dependencies; that file
downloads when the call executes. Such a target is called a **chunk root**. In a build,
the set of chunk roots is the union of the entry points and the dynamic import targets.

## The Panel Splits Off

The panel and its dependencies are added to the source tree; instead of importing the
chart directly, the browser entry requests the panel dynamically. The block below writes
over the previous lesson's source tree.

The block below writes over the `source/` tree set up in the Module Bundling lesson;
that lesson's setup block must be run in this directory before running this script.

```bash
mkdir -p source

cat > source/alert.js <<'EOF'
import { TEMP_MIN, TEMP_MAX } from "./units.js";

export const thresholdBreaches = (measurements) =>
  measurements.filter((m) => m.temperature < TEMP_MIN || m.temperature > TEMP_MAX);
EOF

cat > source/csv-export.js <<'EOF'
import { dualUnit } from "./format.js";
import { dayKey } from "./date.js";

const escape = (a) => (a.includes(",") ? "\"" + a + "\"" : a);

export function buildCsv(measurements) {
  const headers = ["day", "timestamp", "temperature", "humidity"];
  const rows = measurements.map((m) =>
    [dayKey(m.timestamp), m.timestamp, dualUnit(m), m.humidity].map(escape).join(","));
  return [headers.join(","), ...rows].join("\n");
}
EOF

cat > source/panel.js <<'EOF'
import { temperaturePath } from "./chart.js";
import { buildCsv } from "./csv-export.js";
import { heading } from "./template.js";

export async function renderPanel(root, station, measurements) {
  const { thresholdBreaches } = await import("./alert.js");
  const breaches = thresholdBreaches(measurements);
  root.innerHTML = heading(station + " panel") +
    "<svg><path d=\"" + temperaturePath(measurements, 320, 120) + "\"/></svg>" +
    "<p>" + breaches.length + " breaches</p>";
  return { csv: () => buildCsv(measurements) };
}
EOF

cat > source/entry-browser.js <<'EOF'
import { list, heading } from "./template.js";

export function start(root, station, measurements) {
  root.innerHTML = heading(station) + list(measurements);
  return {
    async openPanel(panelRoot) {
      const { renderPanel } = await import("./panel.js");
      return renderPanel(panelRoot, station, measurements);
    },
  };
}
EOF
```

Two dynamic boundaries formed: the entry point requests the panel, and the panel
requests the threshold-alert module dynamically as well. The second is a nested
boundary, and its cost will be measured separately.

## From Chunk Roots to Chunks

The previous lesson's signature criterion applies unchanged, only the set that produces
the signature changes: chunk roots instead of entry points. A module's signature is the
set of roots that can reach it statically. A module whose signature contains the entry
point downloads at first load; a module whose signature does not contain it downloads
when one of the roots in its signature is requested.

```js
// split.mjs — chunk graph, cost, and merging from static and dynamic edges.
import { readFileSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";

const ENTRY = "source/entry-browser.js";
const STATIC_RE = /^\s*(?:import|export)[^"']*from\s*["']([^"']+)["']/gm;
const DYNAMIC_RE = /\bimport\(\s*["']([^"']+)["']\s*\)/g;

const graph = new Map();                        // path -> { size, staticImports[], dynamicImports[] }
const queue = [ENTRY];
while (queue.length > 0) {
  const filePath = queue.shift();
  if (graph.has(filePath)) continue;
  const source = readFileSync(filePath, "utf8");
  const resolve = (re) => [...source.matchAll(re)].map(([, b]) => relative(".", join(dirname(filePath), b)));
  const staticImports = resolve(STATIC_RE), dynamicImports = resolve(DYNAMIC_RE);
  graph.set(filePath, { size: statSync(filePath).size, staticImports, dynamicImports });
  queue.push(...staticImports, ...dynamicImports);
}

// Chunk root: the entry point and every dynamic import target.
const roots = [ENTRY, ...[...graph.values()].flatMap((d) => d.dynamicImports)];
const closures = new Map(roots.map((root) => {
  const visited = new Set(), stack = [root];
  while (stack.length > 0) {
    const filePath = stack.pop();
    if (visited.has(filePath)) continue;
    visited.add(filePath);
    stack.push(...graph.get(filePath).staticImports);
  }
  return [root, visited];
}));

// Signature: the set of roots that statically request the module. Same signature = same chunk.
const groups = new Map();
for (const filePath of graph.keys()) {
  const key = roots.filter((r) => closures.get(r).has(filePath)).join("|");
  if (!groups.has(key)) groups.set(key, []);
  groups.get(key).push(filePath);
}

const name = (p) => p.replace("source/", "").replace(".js", "");
const bytes = (paths) => paths.reduce((t, p) => t + graph.get(p).size, 0);
let chunks = [...groups].map(([key, modules], i) => ({
  name: "P" + (i + 1),
  roots: key.split("|"),
  modules,
  size: bytes(modules),
  initial: key.split("|").includes(ENTRY),
}));

const RTT = 60, BANDWIDTH = 192;                // ms; B/ms (1.5 Mbit/s)
const cost = (list) => list.length * RTT + list.reduce((t, c) => t + c.size, 0) / BANDWIDTH;
const asyncChunksFor = (list, root) => list.filter((c) => c.roots.includes(root) && !c.initial);

function report(title, list) {
  console.log(title);
  for (const c of list.sort((a, b) => a.name.localeCompare(b.name))) {
    console.log("  " + c.name + "  " + String(c.size).padStart(4) + " B  " +
      (c.initial ? "initial    " : "async      ") + c.modules.map(name).sort().join(", "));
  }
  const initialList = list.filter((c) => c.initial);
  console.log("  first load       " + initialList.length + " request  " +
    String(initialList.reduce((t, c) => t + c.size, 0)).padStart(4) + " B  " +
    cost(initialList).toFixed(1).padStart(6) + " ms");
  for (const root of roots.slice(1)) {
    const asyncList = asyncChunksFor(list, root);
    if (asyncList.length === 0) continue;
    console.log("  " + (name(root) + " boundary").padEnd(17) + asyncList.length + " request  " +
      String(asyncList.reduce((t, c) => t + c.size, 0)).padStart(4) + " B  " +
      cost(asyncList).toFixed(1).padStart(6) + " ms");
  }
}

console.log("-- graph --");
for (const [filePath, d] of [...graph].sort()) {
  console.log("  " + name(filePath).padEnd(16) + String(d.size).padStart(4) + " B  static: " +
    (d.staticImports.map(name).join(", ") || "-") + "  dynamic: " + (d.dynamicImports.map(name).join(", ") || "-"));
}
console.log("  chunk root: " + roots.map(name).join(", "));

report("-- chunks by signature (RTT " + RTT + " ms, bandwidth 1.5 Mbit/s) --", chunks);

// 1. Chunks that download together with the entry are not kept separate: they all merge into one initial chunk.
const initialChunks = chunks.filter((c) => c.initial);
if (initialChunks.length > 1) {
  const merged = {
    name: "I1", roots: [...new Set(initialChunks.flatMap((c) => c.roots))],
    modules: initialChunks.flatMap((c) => c.modules), size: bytes(initialChunks.flatMap((c) => c.modules)),
    initial: true,
  };
  chunks = [merged, ...chunks.filter((c) => !c.initial)];
  console.log("-- merge: " + initialChunks.map((c) => c.name).join(" + ") + " -> I1 (all of these download at first load)");
}

// 2. An async chunk under the threshold and requested from only one place joins its requester's chunk.
const THRESHOLD = 300;
for (const c of [...chunks]) {
  if (c.initial || c.size >= THRESHOLD || c.roots.length !== 1) continue;
  const requesters = [...graph].filter(([, d]) => d.dynamicImports.includes(c.roots[0])).map(([filePath]) => filePath);
  const targets = [...new Set(requesters.map((filePath) => chunks.find((chunk) => chunk.modules.includes(filePath))))];
  if (targets.length !== 1 || targets[0].initial) continue;
  const target = targets[0];
  target.modules.push(...c.modules);
  target.size += c.size;
  chunks = chunks.filter((q) => q !== c);
  console.log("-- merge: " + c.name + " (" + c.size + " B < " + THRESHOLD +
    " B, single requester) -> " + target.name);
}

report("-- after merging --", chunks);
```

```
$ node split.mjs
-- graph --
  alert            185 B  static: units  dynamic: -
  chart            439 B  static: units  dynamic: -
  csv-export       428 B  static: format, date  dynamic: -
  date             222 B  static: -  dynamic: -
  entry-browser    335 B  static: template  dynamic: panel
  format           417 B  static: date, units  dynamic: -
  panel            536 B  static: chart, csv-export, template  dynamic: alert
  template         244 B  static: format  dynamic: -
  units            174 B  static: -  dynamic: -
  chunk root: entry-browser, panel, alert
-- chunks by signature (RTT 60 ms, bandwidth 1.5 Mbit/s) --
  P1   335 B  initial    entry-browser
  P2   883 B  initial    date, format, template
  P3  1403 B  async      chart, csv-export, panel
  P4   185 B  async      alert
  P5   174 B  initial    units
  first load       3 request  1392 B   187.3 ms
  panel boundary   1 request  1403 B    67.3 ms
  alert boundary   1 request   185 B    61.0 ms
-- merge: P1 + P2 + P5 -> I1 (all of these download at first load)
-- merge: P4 (185 B < 300 B, single requester) -> P3
-- after merging --
  I1  1392 B  initial    date, entry-browser, format, template, units
  P3  1588 B  async      alert, chart, csv-export, panel
  first load       1 request  1392 B    67.3 ms
  panel boundary   1 request  1588 B    68.3 ms
```

## Reading the Output

The first table gives the raw signature grouping. The panel itself, the chart, and the
export module are gathered into a single async chunk: only the panel root requests all
three. The template, format, and date modules are at first load because both the entry
point and the panel can reach them — there is no reason to make the panel wait for them,
they have already downloaded.

Splitting's gain is in the first line: of the total 2980 bytes, 1392 download at first
load. The panel's 1588 bytes never download at all if that panel is never opened. The
ratio is for show in a small example; in a real application, the weight of modules like
chart rendering and export can be several times the first load.

`units.js` falling into its own chunk is a direct result of the signature criterion: all
three roots request it, so its signature differs from the others'. The criterion makes
this distinction correctly, but the result is not always useful; the next section
corrects it.

## The Cost of Over-Splitting

Every chunk is a request. Per-request latency can be larger than the transferred byte's
own transfer time; the cost lines sum these two items separately. At first load, three
chunks take 187.3 milliseconds, while the same bytes download in 67.3 milliseconds as a
single file. The 120.0 milliseconds in between are two extra requests that buy nothing.

This yields two merge rules.

**Chunks that download together are not kept separate.** Every chunk whose signature
contains the entry point downloads at first load; the only effect of keeping these in
separate files is increasing the request count. Three chunks merge into one initial
chunk, and first load drops from 187.3 milliseconds to 67.3 milliseconds. This rule has
one exception, and it concerns caching: a chunk shared by two different entry points —
like the browser-and-server overlap in the previous lesson — downloads once for each
target when kept separate. The same reasoning holds across releases as well, and will be
measured in the fifth lesson.

**A chunk under the threshold is not split off.** The threshold-alert module is 185
bytes and is requested from a single place. Kept as a separate file, it costs itself a
61.0-millisecond request; joined to the panel's chunk, it raises the panel's download
from 67.3 milliseconds to 68.3 milliseconds. The difference runs one way: splitting takes
more than it gives back.

The nested dynamic boundary has one more hidden cost. The threshold alert is requested
after the panel has loaded; the two requests are sequential. When the user opens the
panel, the wait is 67.3 + 61.0 = 128.3 milliseconds; after merging, 68.3 milliseconds.
The serial request chain is the same phenomenon measured in the rendering models topic
as the cost of client-side rendering; it works the same way at the chunk level.

## Where the Boundary Goes

The criterion for placing a split boundary is four questions, and all four are
measurable.

**Is it needed on the first screen?** If so, it is not split off; splitting only adds
latency. The answer is no for the panel, yes for the measurement list.

**Does the byte count avoided cover the added request?** If not, it is not split off.
The threshold in the example is 300 bytes and is for demonstration; a real threshold is
computed from measured request latency and bandwidth.

**Does the user action have a natural wait?** A short wait after a button press is
acceptable; waiting while the page first opens is not. Split boundaries are placed at
action boundaries.

**How often is the code requested?** Delaying the code for a screen most visitors open
gives back, during navigation, more than the first load gained. The Code Splitting and
Lazy Loading lesson in the Application Architecture course took up this trade-off at
route boundaries and set prefetching as the resolution; the criterion is the same, the
source of the boundary differs — the route definition there, the location of the
`import()` call here.

## The Obligations Splitting Brings

A split output brings three obligations a single-file output does not carry.

**Chunk names must be resolved at runtime.** The entry chunk must know which file to
request when the `import()` call executes. This information is written into the output
as a mapping; the fifth lesson will build this mapping together with content-hashed
names.

**A missing chunk is a runtime error.** If a new release is published while the user has
the page open, the chunk names the old page requests may no longer exist on the server.
This is directly the subject of the rollback lesson in the deployment topic, and the fix
is keeping the old release's files live for a while longer.

**Load order must be preserved.** An async chunk cannot run until the initial chunk it
depends on has been evaluated. The loader in the output tracks this dependency; as the
chunk count grows, so does the number of dependencies to track.

## Summary

- The graph has two kinds of edge separated by time: a static import declares same-time
  necessity, `import()` declares a dependency with a time boundary.
- The set of chunk roots is the union of entry points and dynamic import targets; a
  module whose signature contains the entry point downloads at first load, a module
  whose signature does not contain it downloads when its root is requested.
- In the example, of the total 2980 bytes, 1392 download at first load; the panel's 1588
  bytes download only if that panel opens.
- Every chunk is a request: keeping chunks that download together separate raises first
  load from 67.3 milliseconds to 187.3 milliseconds, and splitting off a chunk under the
  threshold takes more than it gives back.
- Nested dynamic boundaries serialize requests; merging two boundaries into one chunk
  drops the wait from 128.3 milliseconds to 68.3 milliseconds.
- A split boundary is placed by four criteria: necessity on the first screen, the
  avoided byte covering the request cost, the action's natural wait, and how often the
  code is requested.

## Next Step

Chunks are now split correctly both in number and in time. But their content is still
determined at module level: the graph says a module is needed, it does not say which
name inside it is used. The `units` module in the shared chunk exports four names, and
not all of them are used; the format module's second function is called only along the
export path. The next lesson brings the graph down from module level to name level:
eliminating unused exports, the conditions under which that elimination cannot happen,
and how the package declares this to make it possible.
