Keep React Router Navigation Responsive with Deferred Loaders
Use React Router v6.4+ defer and Await to render route shells immediately while slow dependencies load in the background.
19 Sept 2025, 18:31 UTC

A slow route can freeze the whole navigation
Imagine a product page that also loads recommended products, reviews, and related videos. If every route loader must finish before React Router renders anything, the user clicks a link and sees a blank screen or a stuck spinner until the slowest API responds. That coupling between route entry and data completion makes the app feel sluggish even when the core content is ready.
The useful takeaway: separate critical data from non-critical data. Critical data stays awaited so the route has what it needs to paint. Non-critical data can be deferred so the route shell appears immediately and the slow dependency streams in afterward.
How defer fits the React Router data contract
A loader is a function attached to a route that React Router calls before rendering that route. In React Router v6.4+, the data APIs let loaders return promises, values, or the result of defer(). defer() wraps an object of promises and tells the router: render now, do not wait for every property to settle. The component then uses <Await> to suspend only the part of the UI that depends on a specific deferred promise.
This matters on the client during link transitions and on the server during server-side rendering (SSR), because the same loader contract runs in both environments.
Worked example: deferring comments on a post route
Put this in your route definition file, for example app/routes/post.jsx. No terminal command is required to read it; if you need to install the package, run npm install react-router-dom@^6.4.0 in your project root with write access to package.json. Reverting an upgrade means restoring the previous package.json and lockfile.
import { defer, Await, json } from "react-router-dom";
async function fetchComments(postId) {
const res = await fetch(`/api/comments?postId=${postId}`);
if (!res.ok) throw new Error("Comments failed to load");
return res.json();
}
export const loader = async ({ params }) => {
// Critical: post content blocks rendering until available.
const post = await fetchPost(params.postId);
// Non-critical: comments can arrive after the shell paints.
return defer({
post: json(post),
comments: fetchComments(params.postId),
});
};
export default function PostRoute() {
const data = useLoaderData();
return (
{data.post.title}
{data.post.body}
Comments
{(comments) => (
{comments.map((c) => - {c.text}
)}
)}
);
}
Where to run: in your application source, not a terminal. The component mounts while data.comments is still pending, so the post title and body paint immediately. The comments list shows a fallback or empty state until the promise resolves.
Expected checks: open the browser Network tab, navigate to the post route, and confirm the page shell appears before the comments endpoint finishes. In React DevTools, the PostRoute component should mount while the Await boundary is still pending.
Risks: if the deferred promise rejects, an unhandled rejection can surface unless you handle it. Wrap the Await children or provide an errorElement on the route so failures render a fallback instead of breaking the page.
Trade-offs and limitations
- Layout shift: when comments arrive, the page can reflow. Reserve space with a skeleton or fixed-height container.
- Error handling: deferred errors do not automatically fail the route. Use
Await's error prop or routeerrorElementto catch them. - SSR behavior: loaders run on the server too. Deferred streams can affect time-to-first-byte and streaming HTML, so verify your server adapter supports the pattern.
- Version drift: React Router v7 may adjust data API defaults. Confirm your installed version before adopting this pattern.
Verify before you ship
- Run
npm list react-router-domin your project root and confirm the version is 6.4.0 or later. - Navigate to the deferred route in a browser. The critical content should paint without waiting for the slow dependency.
- Inspect the Network panel: the slow request fires, but the route UI is already interactive.
- If the screen stays blank until the slow API returns, the loader is still awaiting that data. Move it into
defer()and render it under<Await>.
Deferring is not a license to make everything lazy. Use it for recommendations, comments, analytics, and other enhancements. Keep the data your layout depends on awaited, and you get a route tree that loads critical content first without holding the rest of the page hostage.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.