Skip to the solution
HardFrontend6 min read

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

820 views

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.

  • react
  • suspense
  • performance

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

6 min read Β· 9 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactSequential versus hoisted promises, timed β€” plus the never-resolving trap
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>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 85 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

Suspense streaming in React 19 & Next.js 15 β€” how does HTML stream?

Continue