Sitemap

Next.js Data Fetching Patterns: Request Memoization and Avoiding Waterfalls

6 min readSep 30, 2026

--

Press enter or click to view image in full size

Server Components make it natural to fetch data wherever you need it, which is powerful and dangerous. Done carelessly, you get duplicate queries and sequential waterfalls that slow every page.

If you are a junior Next.js developer, this is the difference between a page that loads in 400ms and one that loads in 2 seconds, with the exact same database and the exact same code. The only change is when your requests start.

What is a waterfall?

A waterfall happens when requests run one after another instead of at the same time. Each one waits for the previous one to finish, so the delays add up.

Picture a coffee shop with one barista who makes the drinks strictly in order, even when the orders have nothing to do with each other. Everyone waits for the slowest drink ahead of them.

In code, it looks harmless:

  • Fetch the user, then wait
  • Fetch the posts, then wait
  • Fetch the comments, then wait

If each takes 300ms, your page takes 900ms. Run them together and it takes 300ms.

The rest of this article is about two tools that fix this: memoization (do not repeat the same request) and parallel fetching (do not wait when you do not have to).

Automatic memoization for fetch

Within a single render, identical fetch calls are deduplicated automatically. That means multiple components can request the same URL without extra network calls:

async function getUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
// Header and Sidebar can both call getUser(1): one request is made

This is why you do not need to fetch everything at the top and pass it down through props. Each component can ask for what it needs, and Next.js makes sure the network only gets hit once.

Want the basics first? If Server Components, async pages, and await params still feel new, start here:

Learning Next.js — Basics takes a junior from the first page to a small shipped app, slowly enough that nothing is assumed.

Memoizing database queries with cache()

ORM and database calls don’t go through fetch, so wrap them with React's cache:

import { cache } from 'react';
export const getUser = cache(async (id) => {
return db.user.findUnique({ where: { id } });
});

Now any component in the same request calling getUser(1) shares one query. This lets you fetch where the data is used instead of prop-drilling from the top.

A good habit: put every data function in a lib/data.js file and wrap each read in cache. Then any component can call it freely without worrying about duplicate queries.

Avoiding sequential waterfalls

The classic mistake is awaiting one request before starting the next when they’re independent:

// Slow: 400ms + 300ms = 700ms
const user = await getUser();
const posts = await getPosts();
// Fast: max(400, 300) = 400ms
const [user, posts] = await Promise.all([getUser(), getPosts()]);

Promise.all starts both requests immediately and waits for both to finish. Total time is the slowest one, not the sum.

If two requests genuinely depend on each other, a waterfall is unavoidable. For example, you cannot fetch a user’s orders before you know who the user is. Otherwise start them together.

A quick test to run in your head: “Does request B need any result from request A?” If the answer is no, they should run together.

The preload pattern

When a child needs data that a parent doesn’t, you can kick off the request early without awaiting it:

export default async function Page({ params }) {
const { id } = await params;
const postPromise = getPost(id); // start now
const author = await getAuthor(id); // do other work
return <Post postPromise={postPromise} author={author} />;
}

Pass the promise down and resolve it inside a Suspense boundary. The request runs in parallel with everything else.

The key idea: calling an async function starts the work immediately. The await is only where you choose to wait. If you delay the await, the work happens in the background while you do other things.

Get Don Bosco’s stories in your inbox

Join Medium for free to get updates from this writer.

Building with AI? AI features make data fetching even more important, because model calls are slow and expensive. If you want to go from fast pages to shipping AI features like RAG in a real Next.js app, start here:

Ship the Model takes a Next.js developer from a working app to AI features in production.

Spotting a waterfall in the wild

Open the Network tab or add timing logs. If request B only starts after request A completes, and B doesn’t need A’s data, you have an avoidable waterfall.

A subtler kind hides in component trees: a parent awaits its data, then renders a child that awaits its own. Because the child can’t start until the parent finishes rendering, the delays add up.

Fix it in one of two ways:

  • Fetch independent data at the same level with Promise.all
  • Use the preload pattern to start early

A simple debugging trick is to log the time before and after each fetch:

console.time('user');
const user = await getUser();
console.timeEnd('user');

If the logs show timings stacking up one after another, you have found your waterfall.

A before and after example

// Before: nested waterfall
export default async function Page({ params }) {
const { id } = await params;
const project = await getProject(id); // 300ms
return <Tasks projectId={project.id} />; // Tasks then fetches: +400ms
}
// After: independent work started together
export default async function Page({ params }) {
const { id } = await params;
const [project, tasks] = await Promise.all([
getProject(id),
getTasks(id), // needs only the id, not the project
]);
return <ProjectView project={project} tasks={tasks} />;
}

Look at what changed. The tasks query only needed the id, which was available from the start. It never needed the project. The original code created a dependency that did not exist.

Total time drops from roughly 700ms to roughly 400ms without any infrastructure changes.

Common mistakes

  • Awaiting inside loops. Use Promise.all with map for independent items instead of for with await.
  • Over-fetching. Requesting whole records when a page needs two fields wastes time and bandwidth.
  • Assuming cache() persists across requests. It only lasts for a single render, so use unstable_cache or fetch caching for cross-request storage.
  • Creating artificial dependencies. Don’t pass data down only because it was convenient. If a child can fetch its own data using a memoized function, let it.
  • Fetching in a parent just to pass a single ID down. If the child only needs the ID from the URL, it can start its own fetch immediately.

FAQ

Is memoization the same as caching? No. Memoization lasts only for one render pass. Caching persists across requests based on your revalidation settings.

When should I use Promise.all? Whenever the requests are independent. If one needs the other’s result, keep them sequential.

What if one request in Promise.all fails? The whole Promise.all rejects. If you want the other results anyway, use Promise.allSettled and handle each outcome.

Do I still need Suspense? Yes, for slow data you want to stream. Parallel fetching makes the total shorter, and Suspense lets the fast parts show before the slow ones finish.

Bottom line

Memoize repeated reads, parallelize independent ones, and start early with the preload pattern. Most slow Next.js pages are slow because of sequential awaits, not slow databases.

Next time a page feels slow, check the order of your awaits before you touch the database.

Keep writing like this alive

This post is free. The handbooks take weeks.

If it saved you an afternoon of guessing, you can support the work here:

ovyqen.gumroad.com

Buy a book if you need one. Pay what you want if you just want to say thanks. That is what funds the next junior-friendly guide.

The Next.js set:

--

--