Streaming
Streaming responses and deferred data loading with defer, Await, and Suspense in Remix
You are an expert in Remix streaming patterns, including `defer`, the `<Await>` component, `Suspense` boundaries, and progressive rendering for fast initial page loads.
## Key Points
- Await data that is critical for the page (content, auth, SEO metadata) and defer data that is supplementary (comments, recommendations, analytics).
- Always provide meaningful skeleton or spinner fallbacks inside `<Suspense>` — avoid blank spaces.
- Use `errorElement` on `<Await>` to gracefully handle promise rejections without crashing the page.
- For SEO-critical pages, detect bots and await all data so crawlers receive fully rendered HTML.
- Set an abort timeout in `entry.server.tsx` to prevent indefinitely hanging streams.
- Accidentally `await`-ing the promise before passing it to `defer` — this defeats the purpose and blocks the response just like `json`.
- Not wrapping `<Await>` in `<Suspense>` — React will throw an error because `<Await>` suspends.
- Forgetting `errorElement` on `<Await>` — a rejected promise will bubble to the nearest error boundary instead of showing inline fallback UI.
- Using `defer` on hosting platforms that do not support streaming (some serverless environments buffer the response). Verify your deployment target supports chunked transfer encoding.
- Not starting promises early enough — if you create the promise after an `await`, the deferred fetch only starts after the blocking fetch completes, reducing the parallelism benefit.
## Quick Example
```tsx
<Suspense fallback={<Spinner />}>
<Await resolve={reviews} errorElement={<p>Failed to load reviews.</p>}>
{(resolvedReviews) => <ReviewsList reviews={resolvedReviews} />}
</Await>
</Suspense>
```skilldb get remix-skills/StreamingFull skill: 254 linesStreaming — Remix
You are an expert in Remix streaming patterns, including defer, the <Await> component, Suspense boundaries, and progressive rendering for fast initial page loads.
Overview
Remix supports streaming HTTP responses, allowing you to send the shell of a page immediately while slower data loads in the background. The defer utility lets a loader return a mix of resolved data and unresolved promises. The resolved data renders instantly, while pending promises are streamed to the client as they resolve. The <Await> component and React <Suspense> render fallback UI until the deferred data arrives.
Core Concepts
defer vs json
json() waits for all data before sending the response. defer() sends what is ready and streams the rest:
import { defer } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
// Critical data — awaited before sending the response
const product = await getProduct(params.id!);
// Non-critical data — streamed as it resolves
const reviewsPromise = getReviews(params.id!);
const recommendationsPromise = getRecommendations(params.id!);
return defer({
product, // resolved
reviews: reviewsPromise, // Promise — streamed
recommendations: recommendationsPromise, // Promise — streamed
});
}
Await and Suspense
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export default function Product() {
const { product, reviews, recommendations } = useLoaderData<typeof loader>();
return (
<div>
{/* Renders immediately */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
{/* Streams in when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews}>
{(resolvedReviews) => <ReviewsList reviews={resolvedReviews} />}
</Await>
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Await resolve={recommendations}>
{(resolvedRecs) => <RecommendationsGrid items={resolvedRecs} />}
</Await>
</Suspense>
</div>
);
}
Error Handling with Await
<Suspense fallback={<Spinner />}>
<Await resolve={reviews} errorElement={<p>Failed to load reviews.</p>}>
{(resolvedReviews) => <ReviewsList reviews={resolvedReviews} />}
</Await>
</Suspense>
Implementation Patterns
Skeleton Loading UI
function ReviewsSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-4 w-1/3 rounded bg-gray-200" />
<div className="mt-2 h-3 w-full rounded bg-gray-200" />
<div className="mt-1 h-3 w-2/3 rounded bg-gray-200" />
</div>
))}
</div>
);
}
Mixing Critical and Non-Critical Data
The key decision is which data to await and which to leave as a promise:
export async function loader({ request, params }: LoaderFunctionArgs) {
const user = await requireUser(request); // must resolve — needed for auth
const article = await getArticle(params.slug!); // must resolve — it is the page content
return defer({
user,
article,
comments: getComments(params.slug!), // non-critical, stream it
relatedArticles: getRelatedArticles(params.slug!), // non-critical, stream it
});
}
Streaming Entry Server Configuration
For streaming to work, entry.server.tsx must use renderToReadableStream or renderToPipeableStream:
// app/entry.server.tsx (Node.js)
import { PassThrough } from "node:stream";
import { renderToPipeableStream } from "react-dom/server";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import type { EntryContext } from "@remix-run/node";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
Conditional Deferral Based on User Agent or Request
export async function loader({ request, params }: LoaderFunctionArgs) {
const userAgent = request.headers.get("User-Agent") ?? "";
const isBot = /bot|crawl|spider/i.test(userAgent);
const reviewsPromise = getReviews(params.id!);
// For search engine crawlers, await everything so they see full content
if (isBot) {
return json({
product: await getProduct(params.id!),
reviews: await reviewsPromise,
});
}
return defer({
product: await getProduct(params.id!),
reviews: reviewsPromise,
});
}
Parallel Deferred Promises
Start all non-critical fetches before the defer call so they run in parallel:
export async function loader({ params }: LoaderFunctionArgs) {
const productPromise = getProduct(params.id!);
const reviewsPromise = getReviews(params.id!);
const inventoryPromise = getInventory(params.id!);
// Await only the critical one
const product = await productPromise;
return defer({
product,
reviews: reviewsPromise,
inventory: inventoryPromise,
});
}
Core Philosophy
Streaming in Remix is about prioritizing what the user needs to see immediately and deferring what can wait. The defer utility encodes this decision directly in the loader: awaited data is critical and blocks the initial response, while promises are non-critical and stream in as they resolve. This is not a performance optimization to apply everywhere but a content prioritization tool to apply deliberately.
The streaming model acknowledges that not all data on a page has equal importance. Product details, article text, and authentication state are critical and must be present in the initial HTML for both user experience and SEO. Comments, recommendations, and analytics are supplementary and can arrive a few hundred milliseconds later without harming the experience. The defer/Await/Suspense pattern makes this hierarchy explicit in code.
The <Await> component's errorElement prop embodies resilient UI design. When a deferred promise rejects, the error is contained to that specific section of the page. The critical content remains visible, and the failed section shows a graceful fallback. This is fundamentally different from a page-level error boundary: the user sees a complete page with one section that says "Could not load reviews" rather than a blank error screen.
Anti-Patterns
-
Accidentally awaiting promises before passing them to
defer. Writingconst reviews = await getReviews()and then passingreviewstodeferdefeats the purpose. The response is blocked until reviews resolve, making it identical tojson(). Pass the promise directly:reviews: getReviews(). -
Deferring data that is critical for SEO. Search engine crawlers may not execute JavaScript to resolve deferred content. If a piece of data must be present for indexing, await it in the loader. Use bot detection to conditionally await data for crawlers while deferring it for regular users.
-
Omitting
errorElementon<Await>components. Without an error element, a rejected promise bubbles to the nearest error boundary, which may take down a much larger section of the page. Always provide inline error handling so failures are contained. -
Not wrapping
<Await>in<Suspense>. The<Await>component suspends while the promise is pending. Without a<Suspense>boundary, React throws an error. Every<Await>must have a<Suspense>ancestor with an appropriate fallback. -
Creating promises sequentially instead of in parallel. If you start one promise, await it, and then start the next, you lose the parallelism benefit of deferral. Start all promises before any
awaitso they execute concurrently, then pass the still-pending ones todefer.
Best Practices
- Await data that is critical for the page (content, auth, SEO metadata) and defer data that is supplementary (comments, recommendations, analytics).
- Always provide meaningful skeleton or spinner fallbacks inside
<Suspense>— avoid blank spaces. - Use
errorElementon<Await>to gracefully handle promise rejections without crashing the page. - For SEO-critical pages, detect bots and await all data so crawlers receive fully rendered HTML.
- Set an abort timeout in
entry.server.tsxto prevent indefinitely hanging streams.
Common Pitfalls
- Accidentally
await-ing the promise before passing it todefer— this defeats the purpose and blocks the response just likejson. - Not wrapping
<Await>in<Suspense>— React will throw an error because<Await>suspends. - Forgetting
errorElementon<Await>— a rejected promise will bubble to the nearest error boundary instead of showing inline fallback UI. - Using
deferon hosting platforms that do not support streaming (some serverless environments buffer the response). Verify your deployment target supports chunked transfer encoding. - Not starting promises early enough — if you create the promise after an
await, the deferred fetch only starts after the blocking fetch completes, reducing the parallelism benefit.
Install this skill directly: skilldb add remix-skills
Related Skills
Actions
Server-side form mutations with action functions, progressive enhancement, and optimistic UI in Remix
Authentication
Authentication patterns in Remix using sessions, cookies, JWT, OAuth flows, and route protection
Deployment
Deploying Remix applications to various platforms including Vercel, Fly.io, Cloudflare, and Node.js servers
Error Handling
Error boundaries, catch boundaries, and structured error handling strategies in Remix applications
Loaders
Server-side data loading with loader functions, useLoaderData, and type-safe data fetching in Remix
Routing
Nested routes, dynamic segments, outlets, and file-based routing conventions in Remix