Asserting Streaming Suspense Boundaries

Suspense boundaries decide what a user sees while data loads: which parts of a page appear immediately, which show a skeleton, and in what order the real content replaces each fallback. With streaming server rendering, those decisions also shape the HTML that arrives first and the order chunks are flushed. Tests of this behaviour tend to go one of two ways — they wait for everything to finish and assert the final page, which checks nothing about the loading experience; or they race real timers against real fetches and fail randomly. This guide shows how to take control of when each suspended resource resolves, so tests can assert the fallback, resolve one boundary at a time, verify the reveal order, check error boundaries, and confirm in a real browser that the shell streams before slow data. It is part of React state and hydration testing.

Root Cause Analysis

The underlying problem is that the resolution order of suspended data is usually decided by the network or a fake delay, not by the test. When two boundaries load in parallel, their completion order varies from run to run, and a test asserting “the summary appears before the reviews” passes only when the network happens to agree. Replacing delays with promises the test resolves explicitly makes the order a test input rather than an accident.

A second issue is that Suspense behaviour depends on how the promise is created. A component that creates a new promise on every render suspends forever, because each render starts a new request; a promise cached outside the component resolves once and the boundary reveals. Tests that construct promises inline inherit this bug and hang until timeout, which looks like a test-environment problem rather than the component bug it is.

A third is the gap between client rendering and streaming. A client test in jsdom sees the fallback swap to content, but not whether the server sent the shell first. A boundary misplaced above a slow component forces the server to wait before flushing anything, and only a test that observes the HTML arriving over time catches that regression.

Letting the test decide the resolution order With real delays, the order in which boundaries resolve depends on timing and varies between runs. With deferred promises that the test resolves explicitly, each boundary reveals exactly when the test says so. real delays order decided by timing reveal order varies fallback may never be seen deferred promises order decided by the test each boundary on demand fallback asserted first
A deferred promise turns "wait and hope" into "resolve this one now".

Reproducible Setup

A product page with two boundaries: the summary and the reviews, each reading a resource through React’s use. Data loaders are injected so tests can replace them.

// src/product/ProductPage.tsx
type Loaders = { summary: (id: string) => Promise<Summary>; reviews: (id: string) => Promise<Review[]> };

export function ProductPage({ id, loaders }: { id: string; loaders: Loaders }) {
  // Promises are created once per page render tree, not inside the suspending children.
  const [summaryP] = useState(() => loaders.summary(id));
  const [reviewsP] = useState(() => loaders.reviews(id));
  return (
    <main>
      <h1>Product</h1>
      <Suspense fallback={<p role="status">Loading summary…</p>}>
        <SummaryPanel promise={summaryP} />
      </Suspense>
      <ErrorBoundary fallback={<p role="alert">Reviews are unavailable.</p>}>
        <Suspense fallback={<p role="status">Loading reviews…</p>}>
          <ReviewList promise={reviewsP} />
        </Suspense>
      </ErrorBoundary>
    </main>
  );
}

A tiny deferred helper gives the test control over each promise.

// test/deferred.ts
export function deferred<T>() {
  let resolve!: (v: T) => void, reject!: (e: unknown) => void;
  const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
  return { promise, resolve, reject };
}

Implementation

Step 1 — Assert the shell and both fallbacks render first. Nothing has resolved, so both boundaries show their fallback while the heading is already present.

test('renders the shell with both fallbacks before any data', async () => {
  const s = deferred<Summary>(), r = deferred<Review[]>();
  render(<ProductPage id="p1" loaders={{ summary: () => s.promise, reviews: () => r.promise }} />);

  expect(screen.getByRole('heading', { name: 'Product' })).toBeInTheDocument();
  expect(screen.getByText('Loading summary…')).toBeInTheDocument();
  expect(screen.getByText('Loading reviews…')).toBeInTheDocument();
});

Step 2 — Resolve one boundary and check the other still waits. This is the property that makes boundaries worth having: independent reveal.

test('reveals the summary without waiting for reviews', async () => {
  const s = deferred<Summary>(), r = deferred<Review[]>();
  render(<ProductPage id="p1" loaders={{ summary: () => s.promise, reviews: () => r.promise }} />);

  await act(async () => s.resolve({ name: 'Mug', price: 12 }));
  expect(await screen.findByText('Mug — £12')).toBeInTheDocument();
  expect(screen.getByText('Loading reviews…')).toBeInTheDocument();

  await act(async () => r.resolve([{ id: 'r1', text: 'Lovely' }]));
  expect(await screen.findByText('Lovely')).toBeInTheDocument();
  expect(screen.queryByRole('status')).not.toBeInTheDocument();
});

Step 3 — Resolve in the opposite order. The page must behave sensibly when the slower resource wins the race; this is the order real networks sometimes produce.

test('shows reviews first when they arrive first', async () => {
  const s = deferred<Summary>(), r = deferred<Review[]>();
  render(<ProductPage id="p1" loaders={{ summary: () => s.promise, reviews: () => r.promise }} />);
  await act(async () => r.resolve([{ id: 'r1', text: 'Lovely' }]));
  expect(await screen.findByText('Lovely')).toBeInTheDocument();
  expect(screen.getByText('Loading summary…')).toBeInTheDocument();
});
Assertions at each stage of the reveal Stage one shows the shell with two fallbacks. Stage two, after the summary resolves, shows the summary with the reviews fallback still present. Stage three, after reviews resolve, shows the complete page with no fallbacks. 1. shell heading visible two fallbacks 2. summary resolved summary visible reviews still loading 3. complete all content visible no status regions left
Each stage is a separate assertion point the test reaches by resolving exactly one promise.

Step 4 — Reject a resource and assert the error boundary contains it. A failing reviews request must not take the summary down with it.

test('a reviews failure is contained to its own boundary', async () => {
  const s = deferred<Summary>(), r = deferred<Review[]>();
  vi.spyOn(console, 'error').mockImplementation(() => {}); // React logs caught errors
  render(<ProductPage id="p1" loaders={{ summary: () => s.promise, reviews: () => r.promise }} />);

  await act(async () => { s.resolve({ name: 'Mug', price: 12 }); r.reject(new Error('503')); });
  expect(await screen.findByRole('alert')).toHaveTextContent('Reviews are unavailable.');
  expect(screen.getByText('Mug — £12')).toBeInTheDocument();
});

Step 5 — Verify the stream in a real browser. In Playwright, delay the reviews API at the network layer and assert that the shell and summary are visible while reviews still show their fallback. If a boundary were misplaced, the whole page would wait and the first assertion would time out.

test('streams the shell before slow reviews', async ({ page }) => {
  let release!: () => void;
  const gate = new Promise<void>((r) => (release = r));
  await page.route('**/api/reviews/*', async (route) => { await gate; await route.continue(); });

  await page.goto('/products/p1');
  await expect(page.getByRole('heading', { name: 'Product' })).toBeVisible();
  await expect(page.getByText('Loading reviews…')).toBeVisible();

  release();
  await expect(page.getByText('Loading reviews…')).toBeHidden();
});

For server-side fetches the route interception above does not apply; point the server at a mock backend with a controllable delay instead, as described in testing React Server Components with Playwright.

A note on transitions: when navigation or a filter change is wrapped in startTransition, React keeps showing the previous content instead of falling back to the skeleton. That is usually the desired experience, and it deserves its own test — resolve the first data, trigger the transition with a new deferred promise, and assert that the old content stays visible, perhaps with a pending indicator, rather than being replaced by the fallback. Without that test, a refactor that drops the transition wrapper silently reintroduces a flash of loading state on every change, which users notice long before any assertion does.

Verification

Move the reviews Suspense boundary so it wraps both panels, then rerun Step 2. The test must fail: the summary no longer appears independently because it now shares a boundary with the unresolved reviews. That failure is the regression these tests exist to catch.

npx vitest run src/product/ProductPage.test.tsx --reporter=verbose
# ✓ renders the shell with both fallbacks before any data
# ✓ reveals the summary without waiting for reviews
# ✓ shows reviews first when they arrive first
# ✓ a reviews failure is contained to its own boundary

Then create the promise inside ReviewList instead of the parent and rerun. The reviews test now hangs until timeout — the infinite-suspend bug the setup is designed to avoid, surfaced by the test rather than by users.

Which layer checks which property Component tests with deferred promises check fallbacks, independent reveal, order and error containment. Browser tests with a gated network check that the shell really streams before slow data arrives. component tests fallbacks, reveal order error containment browser tests shell streamed first real hydration of chunks
Most boundary behaviour is cheap to pin in component tests; a couple of browser tests confirm streaming itself.

Troubleshooting

Symptom: the test hangs and times out with the fallback still shown. Diagnosis: a new promise is created on each render, so the component suspends forever. Fix: create promises once — in the parent with useState, in a cache, or in a data library — and pass them down.

Symptom: “A component suspended inside an act scope” warning. Diagnosis: a promise resolved outside act, so React’s update was not flushed. Fix: resolve inside await act(async () => …) as in the examples, or rely on findBy queries which wait for the update.

Symptom: the fallback is never observed. Diagnosis: the loader returns an already-resolved value synchronously, or the resource was cached by a previous test. Fix: use deferred promises for every test that asserts a fallback, and reset caches between tests.

Symptom: the error boundary test prints a large stack trace. Diagnosis: React logs caught render errors to console.error. Fix: silence it for that test with a spy, and restore mocks after each test so real errors elsewhere are still visible.

FAQ

Do I need fake timers to test Suspense?

Usually not. Deferred promises control resolution directly, which is clearer than advancing a clock past a simulated delay. Use fake timers only when the component itself has time-based behaviour, such as a minimum fallback duration.

How do I test Suspense with React Query or SWR?

Enable their suspense modes and control the query function with deferred promises in the same way, using a fresh cache per test. See testing React Query hooks with a fresh cache.

Can jsdom test streaming server rendering?

It can render the result of renderToPipeableStream into a string, but it does not model the browser receiving and hydrating chunks over time. Use a real browser for the streaming claim and keep component tests for boundary logic.

Should every boundary have a test?

Every boundary that encodes a product decision — what loads independently, what may fail on its own — deserves one. A boundary added only to satisfy a framework requirement is covered by the page-level test.