Skip to solution
mediumFrontend

Async Server Components & waterfall prevention — fetching without blocking

332 views
01

Understand the problem

Question presented to candidate: "In the App Router, a Server Component can be async and await its own data. What does that buy you, and where does it go wrong?"

What a strong answer should cover:

  • A Server Component can be async and await directly in the component body. No useEffect, no loading state, no race condition — and the fetch runs adjacent to the data source.
  • Zero client JavaScript for that component, and no data round-tripped through client state.
  • The waterfall trap is the real subject: sequential awaits for independent data serialise round trips that should overlap.
  • Fixes: Promise.all for independent requests; start the promise early and pass it down to be awaited later; hoist fetches out of deeply nested components.
  • Suspense boundaries make waterfalls survivable — the shell streams immediately and each section arrives as it resolves, so a slow request delays one region rather than the page.
  • Request deduplication: React cache() and the framework's extended fetch dedupe identical requests within one render pass, which makes colocating fetches safe.
  • The genuine sequential case: when one request truly depends on the previous result, the waterfall is unavoidable — you make it visible with Suspense instead of pretending it is parallel.
  • Client components cannot be async; use() plus a promise passed from the server is the bridge.

Clarifying questions expected:

  • "Are these requests actually independent, or does one need the first result?"
  • "Is this Next.js App Router, or another RSC implementation?"

Code / implementation expected: Yes — the sequential-versus-parallel contrast, and passing a promise down to defer the await.

rscdata-fetching
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 React interviews — assumes Server Components basics. Difficulty: Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. A note on verification: unlike most docs in this set, the behaviour

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

Sequential versus parallel, and deferring an await by passing the promise
Run Playground
import { Suspense, use, useState } from "react";

// This playground is a CLIENT environment, so it cannot run real Server
// Components. The timing behaviour is identical though: what matters is
// whether you await sequentially or start the requests together.
const delay = (ms, value) => new Promise((r) => setTimeout(() => r(value), ms));
const getUser = () => delay(700, { name: "Ada Lovelace" });
const getPosts = () => delay(700, ["Notes on the Engine", "On Numbers"]);
const getStats = () => delay(700, { followers: 1843 });

async function runSequential() {
  const t0 = performance.now();
  const user = await getUser();     // each await blocks the next line
  const posts = await getPosts();
  const stats = await getStats();
  return { ms: Math.round(performance.now() - t0), user, posts, stats };
}

async function runParallel() {
  const t0 = performance.now();
  // All three start immediately; total is the SLOWEST, not the sum.
  const [user, posts, stats] = await Promise.all([getUser(), getPosts(), getStats()]);
  return { ms: Math.round(performance.now() - t0), user, posts, stats };
}

// "Start early, await late": the promise is created by the parent and passed
// down. On the server this is how you stop a child fetch from waiting for the
// parent to finish rendering.
function PostList({ promise }) {
  const posts = use(promise);
  return <ul style={{ margin: "6px 0" }}>{posts.map((p) => <li key={p}>{p}</li>)}</ul>;
}

export default function App() {
  const [seq, setSeq] = useState(null);
  const [par, setPar] = useState(null);
  const [busy, setBusy] = useState(false);
  const [postsPromise, setPostsPromise] = useState(null);

  const compare = async () => {
    setBusy(true); setSeq(null); setPar(null);
    setSeq(await runSequential());
    setPar(await runParallel());
    setBusy(false);
  };

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6 }}>
      <button onClick={compare} disabled={busy}>
        {busy ? "measuring…" : "Compare sequential vs parallel"}
      </button>

      <table style={{ marginTop: 12, borderCollapse: "collapse" }}>
        <tbody>
          <tr>
            <td style={{ padding: "4px 16px 4px 0" }}>three sequential awaits</td>
            <td style={{ color: "#a33" }}><strong>{seq ? seq.ms + "ms" : "—"}</strong></td>
          </tr>
          <tr>
            <td style={{ padding: "4px 16px 4px 0" }}>Promise.all</td>
            <td style={{ color: "#161" }}><strong>{par ? par.ms + "ms" : "—"}</strong></td>
          </tr>
        </tbody>
      </table>

      <hr />
      <h4 style={{ margin: "0 0 8px" }}>Start early, await late</h4>
      <button onClick={() => setPostsPromise(getPosts())}>
        Start the posts request and pass the promise down
      </button>
      {postsPromise && (
        <Suspense fallback={<p style={{ color: "#888" }}>streaming posts…</p>}>
          <PostList promise={postsPromise} />
        </Suspense>
      )}

      <p style={{ color: "#666", fontSize: 13 }}>
        Three 700ms requests: sequential lands near 2100ms, parallel near 700ms.
        Identical work, identical latency — only the arrangement differs.
      </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 63 of 119 decoded in the React.js track. One more won't hurt.

Back to track