Lesson 09 / 19
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.
Contents
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.
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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.