---
title: 'Suspense and Loading States'
source: 'https://academia.sh/en/courses/component-based-development/suspense-and-loading-states'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:03+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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