loke.dev
Header image for Why Is Your React Server Component App Secretly Drowning in N+1 Queries?

Why Is Your React Server Component App Secretly Drowning in N+1 Queries?

Learn why the 'fetch-where-you-use' pattern in React Server Components can accidentally resurrect the classic sequential database waterfall and how to fix it.

· 4 min read

You’re building a dashboard. You’ve fully embraced the React Server Component (RSC) hype. You love that you can just make a component async and await a database call right there in the markup. No more useEffect, no more loading states, no more Redux boilerplate. It’s glorious.

But then you look at your database logs, and your heart sinks. A single page load is hitting your Postgres instance 51 times. You’ve accidentally resurrected the N+1 query problem, and this time, it’s hiding behind the beautiful facade of "component colocation."

The "Innocent" Component

Let’s look at how this happens. You have a Post component that fetches its own author because, hey, that's what RSCs are for, right?

// Author.tsx
async function Author({ userId }: { userId: string }) {
  const author = await db.user.findUnique({ where: { id: userId } });
  
  return <span>{author.name}</span>;
}

// Post.tsx
export async function Post({ post }: { post: any }) {
  return (
    <div className="border-b p-4">
      <h2>{post.title}</h2>
      <Suspense fallback={<span>Loading author...</span>}>
        <Author userId={post.authorId} />
      </Suspense>
    </div>
  );
}

Now, you render a list of fifty posts:

// PostList.tsx
export default async function PostList() {
  const posts = await db.post.findMany();

  return (
    <div>
      {posts.map(post => (
        <Post key={post.id} post={post} />
      ))}
    </div>
  );
}

On the surface, this looks clean. But under the hood, React starts rendering PostList. It sees fifty Post components. Inside each Post, it hits an await inside the Author component. Because these are rendered in a loop, and each one triggers its own database request, you aren't getting one query for all authors—you're getting fifty individual trips to the database.

This is the Sequential Waterfall. Your server is basically saying: "Get user 1. Okay, done. Get user 2. Okay, done. Get user 3..." and your database is crying.

Why "Fetch-Where-You-Use" is a Double-Edged Sword

The promise of RSC is that we can stop over-fetching data. We only fetch what the component needs, right where it needs it. This is great for maintenance—if you delete the component, you delete the data fetch too.

The problem is that React renders components. It doesn't know your components are about to hammer a database. It just executes the code. Unlike a GraphQL engine that can batch these things naturally through its resolver tree, RSCs are just JavaScript functions running on a server.

Fixing it with the cache function

React provides a cache utility (specifically for Server Components) that memoizes data fetches for the duration of a single request. It won't fix the sequential nature of the waterfall entirely if the components are nested deep, but it prevents the *same* data from being fetched multiple times.

import { cache } from 'react';

export const getAuthor = cache(async (id: string) => {
  return await db.user.findUnique({ where: { id } });
});

// Now, even if Author is called 10 times with the same ID, 
// the DB is only hit once.

However, cache only helps with deduplication. It doesn’t help with the "I need 50 different authors" problem.

The "Preload" Pattern

If you want to keep your components decoupled but avoid the waterfall, you can use the Preload Pattern. You trigger the fetch as high up as possible without actually waiting for the data until you're deep in the tree.

// Author.tsx
export const preloadAuthor = (id: string) => {
  void getAuthor(id); // Kick off the promise but don't await it
};

export async function Author({ userId }: { userId: string }) {
  const author = await getAuthor(userId); // This will use the cached promise
  return <span>{author.name}</span>;
}

// PostList.tsx
export default async function PostList() {
  const posts = await db.post.findMany();
  
  // Kick off all author fetches in parallel before rendering the children
  posts.forEach(post => preloadAuthor(post.authorId));

  return (
    <div>
      {posts.map(post => (
        <Post key={post.id} post={post} />
      ))}
    </div>
  );
}

By calling preloadAuthor in the loop, we start all those network requests in parallel. By the time the Author component actually renders and hits await getAuthor(userId), the data is likely already back or at least well on its way.

Don't Forget: You Still Have a Database

Sometimes, we get so caught up in "The React Way" that we forget we're running on a server with access to a powerful query engine. The absolute fastest way to solve an N+1 query in RSC is often to not have N+1 queries in the first place.

If you know your PostList always needs author names, just join the tables in your initial fetch:

const posts = await db.post.findMany({
  include: { author: true }
});

I know, I know. This "breaks colocation." Now PostList needs to know that Author needs a name. But performance is a feature. If you're fetching a list of 100 items, one heavy query with a JOIN is almost always going to beat 100 tiny queries plus the overhead of React managing 100 promises.

The Trade-off

So, when do you use which?

1. Direct Joins: Use these when the relationship is tight and you're rendering a list. It’s the most performant and the least "clever."
2. `cache()` + Parallel Fetching: Use this when components are scattered across the page and might need the same data, but aren't necessarily part of a single database join.
3. Dataloader Pattern: If you're coming from GraphQL, you can still use the dataloader library in RSC to batch IDs into a single WHERE id IN (...) query. It’s a bit more setup but provides the best of both worlds.

The magic of RSC is that it feels like the frontend, but it performs like the backend. Just remember: with great power comes the classic responsibility of not DOSing your own database. Keep an eye on those logs.