Streaming with React Suspense allows apps to speed up initial renders by deferring non-critical data and unblocking UI rendering.
React Router supports React Suspense by returning promises from loaders and actions.
React Router awaits route loaders before rendering route components. To unblock the loader for non-critical data, return the promise instead of awaiting it in the loader.
import type { Route } from "./+types/my-route";
export async function loader({}: Route.LoaderArgs) {
// note this is NOT awaited
let nonCriticalData = new Promise((res) =>
setTimeout(() => res("non-critical"), 5000),
);
let criticalData = await new Promise((res) =>
setTimeout(() => res("critical"), 300),
);
return { nonCriticalData, criticalData };
}
Note you can't return a single promise, it must be an object with keys.
The promise will be available on loaderData, <Await> will await the promise and trigger <Suspense> to render the fallback UI.
import * as React from "react";
import { Await } from "react-router";
// [previous code]
export default function MyComponent({
loaderData,
}: Route.ComponentProps) {
let { criticalData, nonCriticalData } = loaderData;
return (
<div>
<h1>Streaming example</h1>
<h2>Critical data value: {criticalData}</h2>
<React.Suspense fallback={<div>Loading...</div>}>
<Await resolve={nonCriticalData}>
{(value) => <h3>Non critical value: {value}</h3>}
</Await>
</React.Suspense>
</div>
);
}
If you're using React 19, you can use React.use instead of Await, but you'll need to create a new component and pass the promise down to trigger the suspense fallback.
<React.Suspense fallback={<div>Loading...</div>}>
<NonCriticalUI p={nonCriticalData} />
</React.Suspense>
function NonCriticalUI({ p }: { p: Promise<string> }) {
let value = React.use(p);
return <h3>Non critical value {value}</h3>;
}
By default, loaders and actions reject any outstanding promises after 4950ms. You can control this by exporting a streamTimeout numerical value from your entry.server.tsx.
// Reject all pending promises from handler functions after 10 seconds
export const streamTimeout = 10_000;
React Router waits for all loaders to settle (via Promise.all) before it begins streaming the response. Once streaming has started, React Router catches subsequent rejections of your streamed promises and surfaces them to your <Await> (or React 19 React.use) error UI.
However, if a streamed promise rejects before all of the route's loaders have settled, React Router has not yet been able to attach a handler to it. In Node, an unhandled promise rejection will crash the process unless you have a top-level handler registered.
For example, this can happen if a parent route's loader takes longer to resolve than a child route's streamed promise takes to reject:
// parent.tsx — slow loader
export async function loader() {
await new Promise((r) => setTimeout(r, 1000));
return { parent: "data" };
}
// child.tsx — fast-rejecting streamed promise
export async function loader() {
let lazy = new Promise((_, reject) =>
setTimeout(() => reject(new Error("boom")), 100),
);
return { lazy };
}
When lazy rejects before the parent loader resolves, the rejection bubbles to the node process as an unhandled rejection, which will crash the process without a user-defined handler.
To prevent this, register a process-level unhandledRejection handler in your server entry:
process.on("unhandledRejection", (reason, promise) => {
console.error(
"Unhandled Rejection at:",
promise,
"reason:",
reason,
);
});