React 19's `use` Hook in Server Components: The Waterfall We Didn't See Coming
The `use` hook promised cleaner data fetching in React 19. Then our TTFB doubled. Here's what went wrong, how we spotted it, and the patterns that actually work in Next.js App Router.

We shipped a dashboard refactor last quarter that leaned hard on React 19's use hook inside server components. On paper, it was elegant: no more async function components for simple reads, promises passed down like props, Suspense boundaries doing the heavy lifting. Then our p75 TTFB nearly doubled on the account overview page, and we spent two days with a flame graph open figuring out why.
This is that story, plus the mental model we wish we'd had before refactoring.
What the use hook actually does on the server
In React 19, use(promise) lets a component unwrap a promise without being an async function. It suspends the component until the promise resolves, then re-renders with the value. It works in both client and server components, which is the part that trips people up.
On the server, when a component calls use(somePromise), React suspends that subtree and continues rendering siblings. The promise resolves, the subtree renders, and the stream flushes. Sounds great.
The catch: where you create the promise matters more than where you unwrap it.
The pattern that looks clean but isn't
Here's the shape of the code we shipped first. A parent server component fetches a user, then renders children that use promises for related data:
// app/account/page.tsx
import { use } from 'react';
import { getUser, getInvoices, getUsage } from '@/lib/data';
export default async function AccountPage() {
const user = await getUser();
return (
<>
<Header user={user} />
<Invoices invoicesPromise={getInvoices(user.id)} />
<Usage usagePromise={getUsage(user.id)} />
</>
);
}
function Invoices({ invoicesPromise }: { invoicesPromise: Promise<Invoice[]> }) {
const invoices = use(invoicesPromise);
return <InvoiceTable rows={invoices} />;
}
At a glance this looks like parallel fetching. getInvoices and getUsage are both called before any await, so the promises start in flight together. And they do — that part works.
So where did the latency come from?
The waterfall was one level up
The await getUser() at the top of the page component is the entire story. Nothing below it can start — not the invoices query, not the usage query, not even the render of Header — until getUser resolves. That's a serial fetch, and because getUser hit an auth service that itself fanned out to two downstreams, it was slow enough to dominate the trace.
The use hook made us feel like we'd parallelised everything. We hadn't. We'd just made the second tier of fetches parallel to each other, while the first tier blocked the whole render.
In the old pattern — plain async components with their own awaits — the waterfall would have been just as bad. But because we were migrating specifically to "fix fetching", we assumed the new code was faster. It wasn't; it was the same shape with nicer syntax.
How we spotted it
Two signals, in this order:
- Server timing headers. We instrument every server component boundary with a
performance.now()delta and emit aServer-Timingheader. ThegetUsersegment was 340ms on p75. Everything downstream started at t=340ms. - The React DevTools Profiler on a streamed response. You can see when each Suspense boundary resolves relative to the stream start. All three of ours resolved within 60ms of each other — but 400ms into the response.
If you don't have Server-Timing wired up yet, do that before you refactor anything. It's the single highest-leverage observability change for App Router work.
The fix: hoist promise creation, not awaits
The real pattern that unlocks parallelism is to kick off every independent promise before you await anything. If getInvoices and getUsage only need the user's ID — and the ID is in the session cookie, not the user record — you don't need to wait for getUser to start them.
// app/account/page.tsx
import { use } from 'react';
import { cookies } from 'next/headers';
import { getUser, getInvoices, getUsage } from '@/lib/data';
export default async function AccountPage() {
const userId = (await cookies()).get('uid')!.value;
// Fire all three in parallel. Do not await yet.
const userPromise = getUser(userId);
const invoicesPromise = getInvoices(userId);
const usagePromise = getUsage(userId);
return (
<>
<HeaderShell userPromise={userPromise} />
<Suspense fallback={<InvoicesSkeleton />}>
<Invoices invoicesPromise={invoicesPromise} />
</Suspense>
<Suspense fallback={<UsageSkeleton />}>
<Usage usagePromise={usagePromise} />
</Suspense>
</>
);
}
function HeaderShell({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
return <Header user={user} />;
}
Now all three requests race. The slowest one determines TTFB for its section, not for the whole page. And because each section has its own Suspense boundary, the shell streams immediately with skeletons, and sections pop in as their promises resolve.
Our p75 TTFB on this page dropped back to the pre-refactor baseline and the LCP improved by roughly 200ms, because the header rendered as soon as getUser resolved rather than waiting for the slowest downstream.
Why use is still worth it
After the fix, you might ask: why bother with use at all? Couldn't we just make Invoices an async component?
Yes, and sometimes that's cleaner. But use has two real advantages here:
- It works in client components too. If
Invoicesneeds to be a client component for interactivity, you can still pass it a server-created promise and unwrap it withuse. Anasyncclient component is not a thing. - It composes with conditional rendering. You can call
useinside anifbranch; you can'tawaitconditionally in an async component without restructuring.
So the rule we landed on: use async components when the component is server-only and the fetch is unconditional. Reach for use when you need a client component to consume a server promise, or when conditional logic makes async awkward.
Gotchas we hit along the way
A few other things bit us during this migration that are worth flagging.
Promises passed to client components get serialised
When you pass a promise from a server component to a client component, React serialises the resolved value across the RSC payload. That means the promise value must be serialisable — no class instances, no functions, no Date objects without care (they come back as strings in some setups). We had a Decimal.js instance in an invoice total and spent an embarrassing hour debugging why toFixed was undefined on the client.
use does not deduplicate
If two sibling components both call use on separately created promises for the same resource, you get two requests. React's cache() from react dedupes within a single render pass, so wrap your data functions:
import { cache } from 'react';
export const getInvoices = cache(async (userId: string) => {
// ...
});
Now calling getInvoices(userId) twice in the same request returns the same promise. Combine this with Next.js's fetch cache for the actual network layer.
Error boundaries matter more than ever
A rejected promise passed into use throws at the point of unwrapping, not where the promise was created. If you don't have an error.tsx or an explicit <ErrorBoundary> around the Suspense boundary, you get a full-page error for a single failed widget. We now wrap every independently-fetching section in both Suspense and an error boundary, as a default.
A quick checklist before you refactor to use
If you're about to move a page onto this pattern, run through this:
- Have you confirmed which fetches are actually independent? Draw the dependency graph on paper first.
- Are you creating every independent promise before the first
awaitin the component? - Is each independently-loading section wrapped in its own Suspense boundary with a sensible skeleton?
- Is every data function wrapped in
cache()for request-scoped deduplication? - Do you have error boundaries at the same granularity as your Suspense boundaries?
- Are the values you're resolving actually serialisable across the RSC boundary?
Miss any one of these and you'll either regress performance, double your request count, or ship a page that explodes on a single downstream hiccup.
Where we'd start
If you're staring at an App Router page that feels slow and you suspect waterfalls: don't rewrite anything yet. Add Server-Timing headers to every data function, load the page, and look at the trace. Nine times out of ten the fix isn't use versus async — it's hoisting a promise two lines up so three requests fly in parallel instead of in sequence. The hook is a tool, not a strategy. If you want a second set of eyes on a slow App Router page, that's the kind of work our team does on our web engineering engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Dynamic `og:image` Routes in Next.js: Why Ours Rendered Blank for Two Weeks
Our OpenGraph images looked perfect in local dev and broken in every Slack preview. Here's the postmortem on dynamic `og:image` routes, Satori fonts, caching headers, and the bot UA quirks nobody warns you about.

Request Memoization in the App Router: The Hidden Cost of Fetching the Same Thing Twice
Next.js memoizes fetch() within a request, but the rules are stricter than most teams realize. Here's what actually dedupes, what silently doesn't, and how to stop paying for the same database call four times per page.

Streaming Suspense Boundaries: Why Your TTFB Looks Great and Your LCP Still Sucks
Streaming SSR in the Next.js App Router makes TTFB look amazing. But if your Suspense boundaries are drawn wrong, LCP gets worse, not better. Here's how we diagnosed and fixed it on a real site.
