I respect your privacy. Unsubscribe at any time.
React Router v7 gives you incredible flexibility in how you fetch and deliver data through loaders. But flexibility can mean you can accidentally slow things down. Here are four practical ways to make your loaders noticeably faster — with real before/after examples.
1. Batch Independent Work with Promise.all
If your loader calls multiple independent tasks, run them in parallel instead of waiting for each one to finish before starting the next.
❌ Before — Waterfall
export async function loader({ params }: Route.LoaderArgs) { const product = await fetchProduct(params.productId!); // 400ms const reviews = await fetchProductReviews(params.productId!); // 300ms const variations = await fetchProductVariations(params.productId!); // 200ms return { product, reviews, variations }; // total: ~900ms}
Each request waits for the previous one, adding up all their durations.
✅ After — Parallelized with Promise.all
export async function loader({ params }: Route.LoaderArgs) { const id = params.productId!; const [product, reviews, variations] = await Promise.all([ fetchProduct(id), // 400ms fetchProductReviews(id), // 300ms fetchProductVariations(id), // 200ms ]); return { product, reviews, variations }; // total: ~400ms}
Now the loader resolves in roughly the time of the slowest call — not the sum of all three.
2. Reduce Database Round-Trips
If you control your ORM or SQL, avoid multiple small queries and fetch all related data in one shot. This will speed up your loaders by reducing network latency and leveraging database joins to get everything in one go.
❌ Before — Two Separate Queries
export async function loader({ params }: Route.LoaderArgs) { const id = params.productId!; const product = await db.product.findUnique({ where: { id } }); // 200ms const [reviews, variations] = await Promise.all([ db.review.findMany({ where: { productId: product.id } }), // 150ms db.variation.findMany({ where: { productId: product.id } }), // 100ms ]); return { product, reviews, variations }; // total: ~350ms}
Two separate DB round-trips even if parallelized are still suboptimal.
✅ After — One Query with Relations
export async function loader({ params }: Route.LoaderArgs) { const id = params.productId!; const product = await db.product.findUnique({ where: { id }, include: { reviews: { take: 20, orderBy: { createdAt: "desc" } }, variations: true, }, }); // 200ms return { product }; // total: ~200ms}
This reduces network hops and leverages database joins or relation loading internally. It’s often the single biggest win when optimizing Remix/React Router v7 loaders, especially on high-latency DB connections or far-away servers.
3. Stream Slow Data with the New Loader Props API
Loaders can return promises directly, and those promises are streamed to the component as they resolve — no special APIs required. So you can leverage React’s built-in Suspense to show loading states for slow data without blocking the whole page.
❌ Before — Blocking Everything
export async function loader({ params }: Route.LoaderArgs) { const id = params.productId!; const product = await getProductBundle(id); // 500ms const alsoBought = await getAlsoBoughtRecommendations(id); // 3000ms return { product, alsoBought }; // total: ~3500ms}// resolved in ~3500msexport function ProductRoute({ loaderData }: Route.ComponentProps) { return ( <> <ProductHero data={loaderData.product} /> <Recommendations items={loaderData.alsoBought} /> </> );}
Here the getAlsoBoughtRecommendations
call blocks the whole loader, delaying HTML streaming and TTFB. It takes it 3 seconds to resolve after the initial product data is ready. This would bring your app to a standstill on slow connections.
✅ After — Stream Promises
In v7 framework mode, just return the promise and React Router handles streaming + suspense automatically:
export async function loader({ params }: Route.LoaderArgs) { const id = params.productId!; const product = await getProductBundle(id); // 500ms return { product, alsoBought: getAlsoBoughtRecommendations(id), // returns a Promise (no await) };} // resolved in ~500msexport function ProductRoute({ loaderData }: Route.ComponentProps) { return ( <> <ProductHero data={loaderData.product} /> <React.Suspense fallback={<SkeletonRecommendations />}> <Recommendations items={loaderData.alsoBought} /> </React.Suspense> </> );}
This pattern keeps your loader fast and your slow secondary requests off the critical path.
4. Location Still Matters — Latency Kills
Even with perfect code, distance between your server and your database adds unavoidable round-trip time. If your app runs in Frankfurt but your DB is in Virginia, every loader call adds ~100 ms latency minimum. When possible:
- Host your DB and your server in the same region.
- Use read replicas close to your server for heavy read routes.
- Check your ORM logs or APM traces — if DB latency is consistently > 30 ms, co-location can save more time than any code tweak.
Wrap-Up
React Router v7 framework mode gives you the best of both worlds — flexible async loaders and automatic streaming. But performance still depends on how you structure your work:
- Batch independent work (Promise.all)
- Reduce database round-trips
- Stream slow data using promise-aware loaderData
- Co-locate your DB with your server Small fixes here often shave hundreds of milliseconds off TTFB and let React Router’s new data-driven rendering shine.