Streaming SSR Is the React 18 Feature Nobody's Talking About
Streaming SSR Is the React 18 Feature Nobody's Talking About#
Every React 18 blog post I've seen focuses on startTransition and concurrent rendering. I get it — those are the flashy features. But after five months of building with React 18, I'm convinced the features that will actually reshape how we build React apps are useId, Suspense for data fetching, and streaming SSR with renderToPipeableStream.
Nobody's writing about these. So here I am.
useId: Small API, Big Deal#
Let me start with the boring one because it solves a problem that's been annoying me for literally years.
Accessible UI needs element associations by ID: htmlFor/id, aria-describedby, aria-controls. Those IDs must be unique per page. When a component renders multiple times, each instance needs different IDs.
Every solution before useId was broken:
// Bad: not unique when you render two EmailFields
const FIELD_ID = "email-field";
// Bad: new UUID every render, causes hydration mismatch
const id = useMemo(() => crypto.randomUUID(), []);
// Bad: counter resets on the client, IDs don't match server
let counter = 0;
const id = useMemo(() => `field-${counter++}`, []);
I've used all three of these in production. All three have caused bugs. useId kills the entire category:
import { useId } from "react";
function FormField({
label,
hint,
error,
children,
}: {
label: string;
hint?: string;
error?: string;
children: (props: { id: string; "aria-describedby"?: string }) => React.ReactNode;
}) {
const id = useId();
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="form-field">
<label htmlFor={id}>{label}</label>
{children({ id, "aria-describedby": describedBy })}
{hint && <p id={hintId} className="hint">{hint}</p>}
{error && <p id={errorId} className="error" role="alert">{error}</p>}
</div>
);
}
// Usage — each instance gets unique, hydration-safe IDs
function RegistrationForm() {
return (
<form>
<FormField label="Email" hint="We'll never share your email.">
{(props) => <input type="email" {...props} name="email" />}
</FormField>
<FormField label="Password" hint="At least 8 characters." error={passwordError}>
{(props) => <input type="password" {...props} name="password" />}
</FormField>
</form>
);
}
The generated IDs look weird — :r0:, :r1: — but they're guaranteed unique and stable across server and client. Don't try to make them pretty. Just use them.
This is the kind of API that doesn't make for exciting conference talks but eliminates real bugs in real codebases. I've already replaced every ID-generating hack in two projects.
Suspense for Data Fetching: The Bigger Picture#
Suspense has been around since React 16.6 for code-splitting (React.lazy). React 18 makes the data-fetching use case stable. But here's what most posts gloss over: React itself doesn't fetch data. It just defines the "suspend while loading" contract. Your library implements it.
The contract is simple — throw a Promise, React shows the fallback. Resolve the Promise, React renders the component:
// The mechanism under the hood (simplified)
// Libraries like React Query, SWR, and Relay implement this
function useSuspenseQuery(key, fetcher) {
const cached = cache.get(key);
if (!cached) {
const promise = fetcher().then((data) => {
cache.set(key, { status: "success", data });
});
cache.set(key, { status: "pending", promise });
throw promise; // React catches this, shows Suspense fallback
}
if (cached.status === "pending") throw cached.promise;
if (cached.status === "error") throw cached.error;
return cached.data;
}
With a library that implements this (React Query v4 has experimental suspense: true, Relay does it fully), the component code gets clean:
function UserProfile({ userId }: { userId: string }) {
// throws if not cached — Suspense catches it
const { data: user } = useQuery(
["user", userId],
() => fetchUser(userId),
{ suspense: true }
);
// user is ALWAYS defined here. No loading check. No null check.
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
function ProfilePage({ userId }: { userId: string }) {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
);
}
No if (loading) branch. No if (!user) branch. The component only runs when it has data. Loading and error are handled by Suspense and Error Boundaries respectively. This is the architectural win — component code that only deals with the happy path.
Nested Suspense: Independent Loading#
And here's where it gets interesting. You can nest Suspense boundaries so independent sections load independently:
function AccountPage({ accountId }: { accountId: string }) {
return (
<div className="account-layout">
<Suspense fallback={<AccountHeaderSkeleton />}>
<AccountHeader accountId={accountId} />
</Suspense>
<div className="account-body">
<Suspense fallback={<TransactionsSkeleton />}>
<RecentTransactions accountId={accountId} />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<SpendingSummary accountId={accountId} />
</Suspense>
</div>
</div>
);
}
Each section loads and reveals on its own. The page isn't held hostage by the slowest query. The user sees progressive content instead of staring at a blank screen for 800ms.
Streaming SSR: This Changes Everything#
And now the feature that should be getting all the attention.
Traditional SSR: React renders the entire component tree to a string on the server. When the slowest data fetch completes, the full HTML ships to the browser. Everything waits for everything.
Streaming SSR with renderToPipeableStream: the server sends HTML in chunks. The shell (nav, layout, skeletons) goes immediately. Each Suspense boundary's content ships as it resolves. The browser starts rendering before the server is even done.
import { renderToPipeableStream } from "react-dom/server";
import express from "express";
const app = express();
app.get("*", (req, res) => {
const { pipe, abort } = renderToPipeableStream(
<App url={req.url} />,
{
bootstrapScripts: ["/client.js"],
onShellReady() {
// Shell = everything outside Suspense boundaries
// Send it NOW, don't wait for data
res.setHeader("Content-Type", "text/html");
res.statusCode = 200;
pipe(res);
},
onShellError(error) {
res.statusCode = 500;
res.send("<!doctype html><p>An error occurred</p>");
},
onError(error) {
console.error(error);
},
}
);
// don't let it hang forever
setTimeout(abort, 10000);
});
What the user actually sees:
- ~50ms: Browser gets initial HTML. Header, nav, skeleton placeholders visible.
- ~200ms: Fast queries resolve. Account header replaces its skeleton.
- ~800ms: Slow analytics query finishes. Spending summary appears.
- ~1000ms: JS bundle loads. React hydrates. Page is interactive.
Compare to traditional SSR: the browser gets nothing until ~800ms when the slowest query completes, then gets everything at once. Streaming gives you perceived performance for free.
And here's the part that blew my mind: React 18 also does selective hydration. If the user clicks a component that hasn't hydrated yet, React prioritizes hydrating that component first. No code changes — it just works. A slow device with a big JS bundle no longer means the user is locked out of already-visible content.
How All Three Compose#
These features are designed to work together. This is the real architecture:
// Server-rendered page: streaming + Suspense + useId
function ProductPage({ productId }: { productId: string }) {
return (
<div>
{/* Shell: renders immediately, no data deps */}
<PageHeader />
{/* First stream: product loads fast */}
<Suspense fallback={<ProductDetailSkeleton />}>
<ProductDetail productId={productId} />
</Suspense>
{/* Second stream: reviews load slower */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
</div>
);
}
// useId works correctly even when this component streams later
function ProductDetail({ productId }: { productId: string }) {
const { data: product } = useQuery(["product", productId], fetchProduct, { suspense: true });
const descId = useId();
return (
<article aria-describedby={descId}>
<h1>{product.name}</h1>
<p id={descId}>{product.description}</p>
</article>
);
}
The server renders this with renderToPipeableStream. PageHeader arrives instantly. ProductDetail arrives when its query resolves. ProductReviews arrives last. useId generates matching IDs on server and client so hydration doesn't break.
This is the foundation that Next.js 13's App Router is built on. The whole App Router architecture — layouts, loading.tsx, error.tsx — is essentially a file-system API on top of streaming SSR + Suspense + selective hydration.
My Take#
I think the React community got distracted by the wrong features. startTransition is great for specific UI patterns, but streaming SSR is an architectural shift that changes how entire applications are structured. It's the difference between "a new hook" and "a new rendering model."
Here's my honest take: if you're building a new server-rendered app today, use renderToPipeableStream. Not renderToString. Structure your components with Suspense boundaries at the data-fetch level. If you're on an existing app, seriously evaluate Next.js 13 — the App Router is the first production-ready framework that makes this accessible.
useId is undersold. It eliminates an entire category of accessibility bugs that every React app I've worked on has had. I've spent hours debugging hydration mismatches caused by ID generation. Never again.
And Suspense for data fetching changes how you think about component responsibilities. Components that only render the happy path, with loading and error states handled structurally by boundaries — that's just better code. Cleaner. More testable. Easier to reason about.
I know nobody's going to write "useId" on their conference talk abstract. But in two years, these three features will have had more impact on real-world React codebases than concurrent rendering.
Next post: the biggest conceptual shift since hooks — React Server Components. The mental model is different enough that it needs its own post before we get into Next.js 13 specifics.