Skip to main content
Technology & EngineeringRemix254 lines

Streaming

Streaming responses and deferred data loading with defer, Await, and Suspense in Remix

Quick Summary26 lines
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 lines
Paste into your CLAUDE.md or agent config

Streaming — 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. Writing const reviews = await getReviews() and then passing reviews to defer defeats the purpose. The response is blocked until reviews resolve, making it identical to json(). 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 errorElement on <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 await so they execute concurrently, then pass the still-pending ones to defer.

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 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.

Common Pitfalls

  • 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.

Install this skill directly: skilldb add remix-skills

Get CLI access →