Skip to the solution
MediumFrontend7 min read

Async Server Components & waterfall prevention — fetching without blocking

332 views

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.

  • rsc
  • data-fetching

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

7 min read · 9 sections

Try it in the playground

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

ReactSequential versus parallel, and deferring an await by passing the promise
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>
  );
}

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 63 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 nextMedium

What are Error Boundaries in React?

Continue