Lesson 13 / 25
Suspense and Loading States
A component whose data is not ready halting its render and signaling the nearest suspense boundary; the invalid combinations manually held flags produce, the request waterfall boundary placement exposes, measuring spinner flash, and preserving old content during transitions.
Contents
The previous lesson separated a subtree’s failure from the rest of the tree. There is a second state that needs the same separation: a subtree that is not yet ready. The measurement table cannot render until data arrives from the server; the comparison panel waits for the second station’s records.
The similarity between the two states is structural. In both, a component runs into a problem it cannot resolve on its own and reports it upward; a boundary above catches the report and puts another view in the subtree’s place. In this lesson, the second mechanism will be called suspension, and the component that catches it a suspense boundary.
The Problem with Manually Held Flags
The common solution is to hold three separate pieces of state in every component: data, a loading flag, and an error. Three separate slots produce eight combinations, and only three of them are meaningful — loading, error, data. The remaining five combinations are invalid: loading and errored at once, having data while still loading, all three empty at once.
Invalid combinations do not show up in the code; they show up in edge cases: if the error flag is not reset when a second request starts, both a spinner and an error message appear on screen at once. The way to prevent this is to replace the three flags with a single discriminated state; but then every component still carries its own discriminated state, its own spinner, and its own error view. If a page has six data-reading components, six separate spinners spin.
Suspension solves both problems at once: the component holds no state beyond saying “I am not ready,” and the decision about the indicator is collected upward.
Suspension
The component tries to read its data. If the data is ready, it takes the value and continues rendering. If it is not ready, it starts the request and halts the render. The halt climbs upward, stops at the nearest suspense boundary, and that boundary puts a waiting view in the subtree’s place. When the data arrives, the subtree renders again, and this time the read succeeds.
// suspension.mjs — a component that is not ready signaling the nearest suspense boundary const SUSPEND = Symbol("suspend"); function store() { const records = new Map(); const started = new Map(); // the round in which a request started let round = 0; return { openRound: () => { round++; }, read(key) { const entry = records.get(key); if (!entry) { records.set(key, { status: "pending" }); started.set(key, round); // the request starts on the first read attempt throw SUSPEND; } if (entry.status === "pending") throw SUSPEND; return entry.data; }, respond(key, data) { records.set(key, { status: "ready", data }); }, summary: () => [...started].map(([k, r]) => `${k}: round ${r}`).join(", "), }; } const node = (name, opts = {}) => ({ name, boundary: !!opts.boundary, data: opts.data || null, children: opts.children || [] }); function render(n, source) { if (n.boundary) { try { const results = n.children.map((c) => render(c, source)); return { text: `${n.name}[${results.map((x) => x.text).join(" ")}]`, count: 1 + results.reduce((t, x) => t + x.count, 0) }; } catch (err) { if (err !== SUSPEND) throw err; return { text: `${n.name}[LOADING]`, count: 1 }; } } const label = n.data ? `${n.name}<${source.read(n.data)}>` : n.name; if (n.children.length === 0) return { text: label, count: 1 }; const results = n.children.map((c) => render(c, source)); return { text: `${label}(${results.map((x) => x.text).join(" ")})`, count: 1 + results.reduce((t, x) => t + x.count, 0) }; } const separateBoundaries = () => node("page", { children: [ node("title"), node("table-boundary", { boundary: true, children: [node("measurement-table", { data: "measurements" })] }), node("panel-boundary", { boundary: true, children: [node("comparison-panel", { data: "comparison" })] }), ] }); const sharedBoundary = () => node("page", { children: [ node("title"), node("shared-boundary", { boundary: true, children: [ node("measurement-table", { data: "measurements" }), node("comparison-panel", { data: "comparison" }), ] }), ] }); for (const [name, build] of [["two separate suspense boundaries", separateBoundaries], ["one shared suspense boundary", sharedBoundary]]) { console.log(name + ":"); const src = store(); const rounds = [ ["first render", null], ["measurements arrived", "measurements"], ["comparison arrived", "comparison"], ]; for (const [label, arrived] of rounds) { src.openRound(); if (arrived) src.respond(arrived, `${arrived} data`); const result = render(build(), src); console.log(` ${label.padEnd(21)} components showing content: ${result.count}`); console.log(` ${result.text}`); } console.log(` request start -> ${src.summary()}`); }
two separate suspense boundaries:
first render components showing content: 4
page(title table-boundary[LOADING] panel-boundary[LOADING])
measurements arrived components showing content: 5
page(title table-boundary[measurement-table<measurements data>] panel-boundary[LOADING])
comparison arrived components showing content: 6
page(title table-boundary[measurement-table<measurements data>] panel-boundary[comparison-panel<comparison data>])
request start -> measurements: round 1, comparison: round 1
one shared suspense boundary:
first render components showing content: 3
page(title shared-boundary[LOADING])
measurements arrived components showing content: 3
page(title shared-boundary[LOADING])
comparison arrived components showing content: 5
page(title shared-boundary[measurement-table<measurements data> comparison-panel<comparison data>])
request start -> measurements: round 1, comparison: round 2
The page title appears on the first round in both layouts: waiting halts only the data-reading subtree, not its siblings. This is the same isolation as in the error boundary.
Boundary Placement and the Request Waterfall
The difference between the two layouts appears on the second round. With separate boundaries, the measurement table appears the moment its data arrives; with a shared boundary, the screen does not change at all, because the fallback does not lift until the entire tree under the boundary is ready. A suspense boundary defines the parts that “need to appear together.”
The last line of the output shows a sneakier difference. With separate boundaries, both requests started in the first round. With a shared boundary, the measurement table’s halt blocks its sibling from rendering, so the comparison request only starts in the second round. The second request ends up waiting on the first one’s response; two independent requests become sequential. This is called a request waterfall — in the network panel from The Browser and the Web Platform course, this reads as a stepped, waterfall-shaped view.
The waterfall’s second and more common form arises in nested components: the parent component reads a station record, and the child reads measurements using the identifier from that record. The second request is genuinely dependent on the first, and placement does not fix it. The fix is not letting the request wait for the render to start it: the data request is started the moment the route resolves, so the request is already on its way by the time the component renders. The rule is: if a data requirement can be known before the render, the request is started before the render.
Spinner Flash
When the waiting view is shown is a separate decision, and it is measurable.
// spinner.mjs — when the loading indicator appears and how long it stays const RESPONSES = [80, 150, 220, 900]; // response times in ms const THRESHOLD = 200; // the indicator never appears for a response arriving before this duration const MIN_DURATION = 400; // if the indicator appears, it stays on screen at least this long function compute(policy, response) { if (policy === "immediate") return { starts: 0, ends: response, content: response }; if (response <= THRESHOLD) return { starts: null, ends: null, content: response }; if (policy === "delayed") return { starts: THRESHOLD, ends: response, content: response }; const ends = Math.max(response, THRESHOLD + MIN_DURATION); // delayed + minimum duration return { starts: THRESHOLD, ends, content: ends }; } for (const policy of ["immediate", "delayed", "delayed+min duration"]) { console.log(`${policy}:`); for (const response of RESPONSES) { const r = compute(policy, response); const duration = r.starts === null ? 0 : r.ends - r.starts; const line = [ ` response ${String(response).padStart(3)} ms ->`, `indicator ${r.starts === null ? "none" : `${r.starts}-${r.ends} ms`}`.padEnd(20), `visible for ${String(duration).padStart(3)} ms`, `| content at ${String(r.content).padStart(3)} ms`, ].join(" "); console.log(duration > 0 && duration < 300 ? `${line} <- flash` : line); } }
immediate: response 80 ms -> indicator 0-80 ms visible for 80 ms | content at 80 ms <- flash response 150 ms -> indicator 0-150 ms visible for 150 ms | content at 150 ms <- flash response 220 ms -> indicator 0-220 ms visible for 220 ms | content at 220 ms <- flash response 900 ms -> indicator 0-900 ms visible for 900 ms | content at 900 ms delayed: response 80 ms -> indicator none visible for 0 ms | content at 80 ms response 150 ms -> indicator none visible for 0 ms | content at 150 ms response 220 ms -> indicator 200-220 ms visible for 20 ms | content at 220 ms <- flash response 900 ms -> indicator 200-900 ms visible for 700 ms | content at 900 ms delayed+min duration: response 80 ms -> indicator none visible for 0 ms | content at 80 ms response 150 ms -> indicator none visible for 0 ms | content at 150 ms response 220 ms -> indicator 200-600 ms visible for 400 ms | content at 600 ms response 900 ms -> indicator 200-900 ms visible for 700 ms | content at 900 ms
Showing the indicator immediately produces a frame on fast responses that appears and disappears right away. The user perceives this not as loading but as a jump; two state changes are more jarring than a single wait being visible.
Delayed display never brings up the indicator for responses under the threshold. Yet for a response just above the threshold, it leaves a twenty-millisecond flash — the worst form of the problem.
The third policy closes this with a minimum display duration and makes its cost visible: content ready at two hundred twenty milliseconds is held back until six hundred. This is a deliberately paid delay; it does not give the user the reward of a fast response, and in exchange it gives a screen with no jarring flash. The choice among the three policies depends on the measured response distribution: if most responses fall under the threshold, the second policy is chosen; if they cluster around the threshold, the third.
Designing the Waiting View and Transitions
The waiting view has to hold the place of the content that is coming. A spinner smaller than the table it will be replaced by makes the page shift down when the data arrives; this is the layout shift defined in the Web Fundamentals and HTML course. In its place, a skeleton view is used that carries the dimensions of the layout to come: the same number of rows, the same column widths, the same height.
The second decision is about transitions. The first load and a subsequent load are different problems. When the user changes the filter, a working table is already on screen; falling back to the waiting view means erasing that table and reverting to a skeleton. Emptying a working screen also erases the user’s context.
Better behavior keeps the old content in place and adds a waiting indicator — a faded table, a small indicator in the header, a disabled filter. The rule can be written as: a suspense boundary is for filling an empty space, not for emptying a full one. While the old content is shown, it has to be marked as stale, or the user will assume they are looking at updated data.
The accessibility side is not skipped either. Both waiting and completion have to be announced to the screen reader; a visual indicator alone carries no meaning. The reason behind disabled controls is also communicated in text.
Summary
- Data, loading, and error flags held separately in every component produce eight combinations, and five of them are invalid.
- Suspension is a component whose data is not ready halting its render and signaling upward; the nearest suspense boundary catches the signal, puts a waiting view in the subtree’s place, and sibling subtrees continue rendering.
- A suspense boundary defines the parts that need to appear together; independent requests gathered under a single boundary become sequential and produce a request waterfall.
- If a data requirement can be known before the render, the request is started before the render.
- Showing the indicator immediately produces flash; threshold-based display filters out the indicator for short responses, and a minimum display duration closes the flash but delays the content.
- The waiting view has to carry the dimensions of the content to come; in transitions like a filter change, working content is not erased, it is preserved and marked as stale.
Next Step
The seven lessons so far have determined when a subtree renders: when state changes, when a dependency changes, when an error is caught, when data arrives. One question remains, and it concerns not time but place: where does a subtree render?
The default answer is obvious — a component’s position in the component tree determines its position in the document tree. There is a point on the measurement station page where this default breaks down. The dropdown list in the filter panel overflows its own panel’s box; the panel’s clipping cuts off the list, and the panel’s stacking context leaves the list underneath the table. The component is logically inside the panel but has to render visually at the very top of the page. The next lesson takes up this split, the two trees becoming independent of each other, and what this does to event propagation and accessibility.
To keep your progress and take notes, Log in
My notes
Log in to take notes.