Streaming UI with Remix's defer API: Progressive Rendering Without Blocking
Learn how Remix's defer API streams data loaders to render fast UI instantly while slow data arrives in background chunks, keeping code simple and type-safe.
13 Jun 2026, 06:16 UTC

Problem: Slow data blocks the whole page
When a Remix loader fetches a slow-running resource (e.g., a remote API that takes seconds), the server holds the response until every promise resolves. The browser receives a blank screen or a full-page fallback, which hurts perceived performance.
Thesis: Remix's defer API lets you stream data
By returning a DeferredData object from a loader, Remix renders the page immediately with the values that are already ready and sends the remaining promises as separate streamed chunks. The UI shows placeholders for the pending parts and replaces them as each promise resolves, all while keeping loader code simple and type-safe.
How defer works
- The loader returns
defer({ fast: promiseA, slow: promiseB }). - Remix awaits only the settled promises needed for the initial HTML; unresolved promises become placeholders.
- On the server, the initial HTML chunk contains the resolved values; additional chunks are flushed as each deferred promise settles.
- On the client,
useLoaderDatagives you the same shape, and<Await>boundaries (or theawaitoperator in JSX) render fallbacks until the data arrives. - TypeScript infers the resolved shape; you still type the loader as returning
Promise<DeferredData<Shape>>or simplyDeferredData<Shape>because Remix handles the wrapping.
Worked example
// app/routes/dashboard.tsx
import type { LoaderFunction } from '@remix-run/node';
import { defer, json } from '@remix-run/node';
import { Await } from '@remix-run/react';
type DashboardData = {
user: { id: string; name: string };
posts: Array<{ id: string; title: string }>;
};
export const loader: LoaderFunction = async () => {
// fast request - e.g., read from a cookie or local DB
const userPromise = fakeDb.getUser(); // resolves quickly
// slow request - e.g., remote API with latency
const postsPromise = fetch('https://api.example.com/posts')
.then(res => res.json());
// Return a DeferredData object; Remix will stream the unresolved promises.
return defer({ user: userPromise, posts: postsPromise });
};
export default function Dashboard() {
const data = useLoaderData();
return (
Welcome, {data.user.name}
{/* Await boundary shows a fallback while posts are pending */}
{ (posts) => (
{posts.map(p => (
- {p.title}
))}
)}
{/* fallback */}
Loading posts...
);
}
When you visit /dashboard with JavaScript disabled, the HTML sent from the server contains the rendered user name and a placeholder comment where the posts list will appear. With JavaScript enabled, the browser receives the initial chunk, displays the user name instantly, and shows Loading posts... until the streamed chunk with the posts arrives.
Trade‑offs and limitations
- If a deferred promise takes a long time, the UI may show a loading fallback for an extended period. Design meaningful placeholders or skeleton UI to mitigate perceived delay.
- Remix does not automatically cancel deferred requests when the user navigates away. You must attach an AbortController inside the loader (e.g., pass the signal to fetch) to avoid wasted work.
- Streaming works only when the server supports chunked transfer encoding (the default in Remix's Node and Cloudflare adapters). Edge runtimes that buffer the whole response will not see the streaming benefit.
Practical verification steps
- Start the Remix dev server (remix dev).
- Open the Network tab, enable Preserve log, and request /dashboard.
- Observe the first response chunk: it should contain the user name and a placeholder marker for posts.
- After the artificial delay (e.g., 2 seconds), a second chunk arrives with the posts data.
- Disable JavaScript in the browser, reload the page, and view the page source: you’ll see the user name rendered and a noscript-friendly placeholder where the posts list will be hydrated.
Actionable closing
If your route mixes fast-moving UI (user profile, navigation) with slower data (lists, charts), wrap the slow parts in defer and use Await boundaries. Keep the loader function simple, retain full TypeScript safety, and remember to add cancellation logic for a smooth user experience.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.