Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Parallel Queries in Route Loaders

Hello! Welcome back to our module on Advanced Architecture and Optimization.

In our last lesson, we focused on optimizing when data is fetched by implementing route prefetching on user intent. We saw how to use Tanstack Router's preload capabilities to start loading data on a link hover, making navigation feel instantaneous.

Today, we shift our focus to optimizing how data is fetched for a single route. This lesson directly addresses the learning outcome: Optimize data fetching by executing parallel queries within route loaders. We'll explore how to run multiple independent data requests concurrently, significantly reducing the total time the user waits for a page to load.

1. The Problem: The Data Fetching "Waterfall"

When a route needs data from multiple sources (e.g., user details, a list of posts, and notifications), a naive implementation in the loader might fetch them one after another.

Consider this loader function:

// ANTI-PATTERN: Sequential fetching
export const Route = createFileRoute('/dashboard')({
  loader: async ({ context }) => {
    console.time('loader');
    
    // 1. Fetch user details (e.g., takes 500ms)
    const user = await context.queryClient.fetchQuery(userQueryOptions);
    
    // 2. Fetch posts (e.g., takes 800ms)
    const posts = await context.queryClient.fetchQuery(postsQueryOptions);
    
    // 3. Fetch notifications (e.g., takes 300ms)
    const notifications = await context.queryClient.fetchQuery(notificationsQueryOptions);
    
    console.timeEnd('loader'); // Total time: ~1600ms (500 + 800 + 300)
    
    return { user, posts, notifications };
  },
  // ...
});

Each await pauses the function's execution until the promise resolves. This creates a "waterfall" where the total time is the sum of all individual request times. If these requests are independent, we're introducing an unnecessary delay.

What a Data Router/Loader is and why you need it!

To understand why this is a critical performance issue, let's review the concept of 'waterfall loading' and how modern routers are designed to prevent it.

Read the sections 'Enter the Router Loader' and its subsection 'Parallel Data Fetching'. This will frame the problem and introduce the high-level solution that router loaders enable.

2. The Solution: Parallel Queries with Promise.all

Since you have a strong background in development, you're likely familiar with Promise.all. It's the standard JavaScript mechanism for handling multiple concurrent asynchronous operations. It takes an array of promises and returns a single promise that resolves with an array of all the results once every promise has completed.

By wrapping our independent fetch calls in Promise.all, we can execute them in parallel. The total execution time is now dictated by the longest-running query, not the sum of all of them.

Let's refactor our previous example:

// GOOD PATTERN: Parallel fetching
export const Route = createFileRoute('/dashboard')({
  loader: async ({ context }) => {
    console.time('loader');
    
    await Promise.all([
      // These all start at the same time
      context.queryClient.prefetchQuery(userQueryOptions),
      context.queryClient.prefetchQuery(postsQueryOptions),
      context.queryClient.prefetchQuery(notificationsQueryOptions)
    ]);
    
    console.timeEnd('loader'); // Total time: ~800ms (the longest query)
    
    // Data is in the cache, no need to return it.
    // The component will use useSuspenseQuery to read it.
    return {};
  },
  // ...
});

Notice two key changes:

  1. We use Promise.all to fire all requests at once.
  2. We use prefetchQuery (or insureQueryData) instead of fetchQuery. This populates the TanStack Query cache without returning the data directly from the loader. The component can then read from the cache using useSuspenseQuery. This is the recommended pattern as it fully leverages TanStack Query's capabilities.

Let's watch a demonstration of this exact pattern.

The Best Data Fetching Pattern in React

This video shows the practical implementation of converting sequential fetches into parallel ones within a Tanstack Router loader.

Watch from 08:18 to 10:32. The speaker starts with separate fetches and then explicitly refactors them to use Promise.all for parallel execution, explaining the performance benefits. This is the core technique of this lesson.

3. Tanstack Router's Built-in Parallelism

In addition to what we can do inside a loader, Tanstack Router provides another layer of optimization automatically. When a navigation matches multiple routes in a hierarchy (e.g., a parent layout route and a child page route), the router runs the loader function for each of these routes in parallel.

For example, for the URL /posts/my-first-post:

  • The loader for /posts (e.g., fetching a list of all post titles for a sidebar)
  • The loader for /posts/$postId (e.g., fetching the content of my-first-post)

...will both be executed concurrently.

Complete TanStack Router Tutorial - Build Type-Safe React Apps with File-Based Routing

This short clip demonstrates the router's automatic parallel fetching across nested routes.

Watch from 00:39:36 to 00:40:42. The narrator points out that three separate API calls for a nested route page are all initiated at the same time, showing the router's built-in parallel fetching capability.

This behavior is a fundamental part of the router's design, as confirmed in the official documentation's description of the route loading lifecycle.

Data Loading | TanStack Router React Docs

The official documentation outlines the router's execution sequence, explicitly noting that the loading phase is parallel.

Briefly review the list under the heading 'The route loading lifecycle'. Notice that 'Route Loading' is marked as '(Parallel)', confirming that route.loader functions for all matched routes run concurrently.

Combining these two concepts gives you a highly optimized data-loading strategy: the router runs loaders for different routes in parallel, and within each loader, you use Promise.all to run independent queries in parallel.

4. An Important Nuance: Dependent Queries

Promise.all is a powerful tool, but it's crucial to remember it only works for independent asynchronous tasks. If one query depends on the result of another, you must fetch them sequentially.

For example, if you need to fetch a user's profile and then use their id to fetch their specific posts, a waterfall is unavoidable and correct.

// CORRECT PATTERN for dependent data
export const Route = createFileRoute('/users/$userId/posts')({
  loader: async ({ params, context }) => {
    const { queryClient } = context;

    // 1. First, fetch the user profile. We MUST await this.
    const user = await queryClient.fetchQuery(userQueryOptions(params.userId));

    // 2. Then, use the result to fetch the posts.
    const posts = await queryClient.fetchQuery(postsForUserQueryOptions(user.id));

    // Here, you might return the data or just let the component
    // read from the now-populated cache.
    return { user, posts };
  },
  // ...
});

Attempting to parallelize these would fail because the input for the second query (user.id) is not available when the calls are initiated.

Conclusion

You've now learned how to significantly cut down data loading times by identifying and parallelizing independent queries. This is a fundamental optimization technique for building high-performance server-rendered applications.

Key Takeaways:

  • Avoid waterfalls: Sequentially await-ing independent promises in a loader is a performance anti-pattern.
  • Use Promise.all: Wrap independent fetch or prefetchQuery calls in Promise.all to execute them concurrently within a single loader. The total time will be that of the slowest query, not the sum.
  • Leverage the framework: Tanstack Router automatically runs loaders for nested routes in parallel, providing another layer of optimization.
  • Recognize dependencies: For queries that depend on each other's results, sequential fetching is necessary and correct.

Next Up:

So far, we've focused on making the data fetching on the server as fast as possible. But what if the user could see the page layout even before all the data is ready? In our next lesson, we'll explore how to implement streaming SSR with React Suspense, a technique that improves perceived performance by sending the UI to the browser in chunks as it becomes ready.

Can't find a good explanation? Sign up and we'll make it for you

Sign up