Here's something that trips up almost everyone moving from a fetch-based API to a direct MongoDB connection in Next.js, and it's costing real database load on projects that don't even realize it's happening.
Next.js automatically deduplicates fetch() calls with identical parameters within a single render pass. If your layout, your page, and three nested components all call fetch('/api/user'), Next.js is smart enough to only actually make that request once and reuse the result everywhere else it's called. This is called request memoization, and it's one of the quieter, genuinely useful things the App Router does for you automatically.
Direct database queries get none of this. Nothing.
What This Actually Looks Like
// lib/queries/user.ts
import { connectDB } from '@/lib/db';
import User from '@/models/User';
export async function getCurrentUser(userId: string) {
await connectDB();
return User.findById(userId).lean();
}
// app/dashboard/layout.tsx
export default async function DashboardLayout({ children }) {
const user = await getCurrentUser(userId); // query #1
return <div><Sidebar user={user} />{children}</div>;
}
// app/dashboard/page.tsx
export default async function DashboardPage() {
const user = await getCurrentUser(userId); // query #2, same user, same request
return <Header user={user} />;
}
// components/UserMenu.tsx (Server Component, rendered inside the page)
export default async function UserMenu() {
const user = await getCurrentUser(userId); // query #3
return <Menu user={user} />;
}
Three separate calls to getCurrentUser, same user ID, same single incoming request from the browser. If this were fetch('/api/user') instead of a direct Mongoose query, Next.js would automatically collapse these into one actual network call. Since it's a direct database query, none of that automatic deduplication applies. This is three real round trips to MongoDB, for data that was identical all three times, on every single page load.
Why This Doesn't Show Up in Development
On a local database with almost no latency and no real load, three redundant queries feel instant, indistinguishable from one. This is exactly why it's so easy to ship without noticing. The cost only becomes visible under real production conditions, real network latency to your database, real concurrent traffic, where those extra round trips add up into measurably slower page loads and meaningfully higher database load than the page actually needed.
The Actual Fix: React's cache() Function
React ships a cache() function specifically for this. It memoizes a function's result within a single render pass, exactly the same behavior fetch gets automatically, just applied manually to whatever function you wrap with it.
// lib/queries/user.ts
import { cache } from 'react';
import { connectDB } from '@/lib/db';
import User from '@/models/User';
export const getCurrentUser = cache(async (userId: string) => {
await connectDB();
return User.findById(userId).lean();
});
That's the entire fix. Every call to getCurrentUser with the same userId, anywhere in that same render pass, layout, page, nested component, now returns the same cached promise instead of triggering a new query. Three calls become one actual database hit.
The Part People Get Wrong About cache()
This is request-scoped, not persistent across requests. It deduplicates calls within one single render of one single incoming request, then resets completely for the next request. It is not the same thing as unstable_cache, which persists data across multiple requests and needs explicit revalidation.
// cache() - deduplicates within ONE request, resets every time
export const getCurrentUser = cache(async (userId: string) => { ... });
// unstable_cache - persists ACROSS requests, needs revalidateTag to update
export const getPosts = unstable_cache(async () => { ... }, ['posts'], {
revalidate: 3600,
});
Mixing these up causes two different problems. Using cache() where you needed unstable_cache means you're still hitting the database on every single request, just once instead of three times. Using unstable_cache where you needed cache() means you're persisting data across requests when you actually just wanted to avoid redundant calls within one page load, which can serve stale data longer than intended.
Where I'd Actually Add This
Not everywhere, that would be over-engineering a solution for calls that only ever happen once per request anyway. The pattern worth watching for specifically: any query function, getCurrentUser, getTenant, getSession-adjacent lookups, that reasonably gets called from multiple places in the same component tree, a layout, a page, and a few nested components all independently needing "who is the current user."
Check Your Own Project
Grep your query functions for ones called from more than one component in the same tree, and check whether they're wrapped in cache(). If they're not, and they're not already going through unstable_cache or an equivalent persistent layer, you likely have the exact redundant-query pattern above running silently in production right now.
Curious how many people actually knew about this before reading this post versus assumed Next.js handled it automatically the same way it does for fetch. Drop your honest answer in the comments, genuinely curious how well-known this actually is.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)