Skip to solution
hardFrontend

How do Suspense data-fetching waterfalls happen, and how do you avoid them?

819 views
01

Understand the problem

Question presented to candidate: "You have Suspense boundaries and the page still loads slowly, one section at a time. What is going wrong?"

What a strong answer should cover:

  • A waterfall is requests running in sequence when they could overlap. Suspense does not cause it, but it makes it easy to write and easy to miss.
  • The mechanism: a component suspends, so its children never render, so their requests never start until the parent's resolves.
  • use() reads a promise; it does not create or cache one. A promise created during render restarts on every render attempt and can suspend forever.
  • The fix is render-as-you-fetch: start the requests before or while rendering, and pass the promises down — rather than fetch-on-render, where each level begins only after the one above finished.
  • Promise.all for independent requests within one component.
  • Separate Suspense boundaries so an independent slow section does not delay a fast one.
  • A genuine dependency — you need the user before you can fetch their orders — cannot be parallelised; contain it behind its own boundary instead.
  • Framework loaders and Server Components exist largely to hoist fetching above rendering.

Clarifying questions expected:

  • "Are these requests genuinely dependent, or just written sequentially?"
  • "Where are the promises created — during render, or before it?"

Code / implementation expected: Yes — the sequential version and the hoisted version, with timings.

reactsuspenseperformance
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for senior React interviews — assumes Suspense basics. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. Every timing below was measured on React 19.2.8 by rendering and polling unt

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

Sequential versus hoisted promises, timed — plus the never-resolving trap
Run Playground
import { Suspense, use, useState } from "react";

const slow = (ms, value) => new Promise((r) => setTimeout(() => r(value), ms));

// Promises must be created OUTSIDE render and cached, or every retry makes a
// new one. This tiny cache is what a loader or framework would give you.
const cache = new Map();
function fetchThing(key, ms) {
  if (!cache.has(key)) cache.set(key, slow(ms, key));
  return cache.get(key);
}

// ── ❌ SEQUENTIAL: the child's key does not exist until the parent resolves,
//    so its request cannot start any earlier. Two round trips, end to end. ──
function Parent({ onDone }) {
  const a = use(fetchThing("seq-A", 600));
  return <Child prefix={a} onDone={onDone} />;
}
function Child({ prefix, onDone }) {
  const b = use(fetchThing(prefix + "-then-B", 600));
  onDone();
  return <Result label="sequential" value={b} />;
}

// ── ✅ HOISTED: both promises are created up front, before any rendering, so
//    they are already in flight together. ──────────────────────────────────
function Both({ onDone }) {
  const a = use(fetchThing("par-A", 600));
  const b = use(fetchThing("par-B", 600));
  onDone();
  return <Result label="hoisted" value={a + " + " + b} />;
}

// ── ⚠️ THE TRAP: a promise created during render. React retries the suspended
//    component, the function runs again, and it suspends on a NEW promise. ──
function NeverResolves() {
  const v = use(slow(300, "never gets here"));
  return <span>{v}</span>;
}

function Result({ label, value }) {
  return <p style={{ margin: "4px 0", fontSize: 14 }}><strong>{label}:</strong> {value}</p>;
}

function Timer({ label, ms }) {
  return (
    <p style={{ margin: "4px 0", fontSize: 14 }}>
      <code style={{ display: "inline-block", minWidth: 110 }}>{label}</code>
      {ms === null ? "…" : <strong>{ms}ms</strong>}
    </p>
  );
}

export default function App() {
  const [run, setRun] = useState(false);
  const [showTrap, setShowTrap] = useState(false);
  const [times, setTimes] = useState({ seq: null, par: null });
  const [t0] = useState(() => ({ v: 0 }));

  const start = () => {
    cache.clear();
    t0.v = performance.now();
    setTimes({ seq: null, par: null });
    setRun(true);
  };
  const done = (which) => () => {
    setTimes((t) => (t[which] === null ? { ...t, [which]: Math.round(performance.now() - t0.v) } : t));
  };

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 520 }}>
      <p><button onClick={start}>run both (two 600ms requests each)</button></p>

      <Timer label="sequential" ms={times.seq} />
      <Timer label="hoisted" ms={times.par} />

      {run && (
        <div style={{ border: "1px solid #ddd", borderRadius: 8, padding: 10 }}>
          <Suspense fallback={<p style={{ color: "#888", margin: 4 }}>sequential loading…</p>}>
            <Parent onDone={done("seq")} />
          </Suspense>
          <Suspense fallback={<p style={{ color: "#888", margin: 4 }}>hoisted loading…</p>}>
            <Both onDone={done("par")} />
          </Suspense>
        </div>
      )}

      <hr />
      <p>
        <button onClick={() => setShowTrap((s) => !s)}>
          {showTrap ? "hide" : "show"} the promise-created-in-render trap
        </button>
      </p>
      {showTrap && (
        <Suspense fallback={<p style={{ color: "crimson" }}>loading forever — the promise is recreated on every retry</p>}>
          <NeverResolves />
        </Suspense>
      )}

      <p style={{ color: "#666", fontSize: 13 }}>
        The sequential pair lands at roughly twice the hoisted time, with the
        same two requests. The trap never resolves at all.
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 85 of 119 decoded in the React.js track. One more won't hurt.

Back to track