fetch() in Server Components is automatically cached by Next.js Data Cache — but database queries via ORM/driver are NOT, causing N+1 query waterfalls on every page load
Parallel data fetching with Promise.all() reduces total page load time from serial-latency-sum to max-latency — 3s to 800ms in a typical dashboard
fetch() calls with the same URL and options in the same render pass are automatically deduplicated — but database queries, Prisma calls, and direct DB access require manual caching with unstable_cache or React cache()
The 'force-dynamic' route config disables ALL caching — use it only when you understand the performance cost (every request hits every data source)
Sequential data fetching (waterfall) is the #1 cause of slow Server Component pages — each await pauses rendering until the previous one completes
✦ Definition~90s read
What is Data Fetching in Next.js 16?
Data fetching in Next.js Server Components uses the async component pattern — components are async functions that directly await data. This replaces the traditional client-side useEffect + fetch pattern with server-side rendering that produces HTML directly.
★
Imagine ordering a 5-course meal where the kitchen starts Course 2 only after you finish Course 1.
Server Components can fetch data from any source: HTTP APIs (via fetch()), databases (via Prisma, Drizzle, pg, or any ORM), file systems, or cached services.
The caching behavior differs by data source. fetch() calls are automatically intercepted by Next.js's Data Cache layer — identical URLs and options are deduplicated within a render pass and cached across requests. Database queries receive NO automatic caching — each call hits the database unless explicitly wrapped.
React's cache() function provides request-scoped deduplication (multiple callers within one HTTP request share one promise), while Next.js's unstable_cache() provides persistent cross-request caching with configurable revalidation and tag-based invalidation.
The most common performance issue is the data waterfall — writing independent data fetches sequentially with individual await statements. Three 300ms queries written sequentially take 900ms; with Promise.all(), they take 300ms. Waterfalls are often hidden: child Server Components that each fetch their own data create a cascade where the parent renders each child sequentially, multiplying total load time by the number of children.
The fix is consolidating all data fetching to parent components, parallelizing with Promise.all(), and caching aggressively.
Suspense boundaries add progressive loading: wrap independent data-fetching components in Suspense, and each one streams its content as the data query completes. The page shell renders immediately with skeleton or loading states, and widgets appear progressively — the fastest queries appear first, slowest last.
Combined with parallel fetching and caching, this pattern turns slow data-heavy pages into fast, progressively loading experiences.
Plain-English First
Imagine ordering a 5-course meal where the kitchen starts Course 2 only after you finish Course 1. That's waterfall data fetching — each query waits for the previous one to complete before starting. Parallel fetching is like ordering all courses at once and having them arrive as they're ready. Most teams build waterfalls without realizing it because they write each await on its own line, one after another. The fix is Promise.all() — start all queries simultaneously and wait for all of them together.
⚙ Browser compatibility
Latest versions — ✓ supported
Chrome
Firefox
Safari
Edge
✓
✓
✓
✓
Server Components changed data fetching in Next.js dramatically. The async component pattern lets you await data directly in the component, no useEffect, no loading states, no client-side fetch wrapper. But this power comes with a trap: sequential awaits create waterfalls that multiply your page load time by the number of queries.
The assumption that 'fetch() caches, so all data fetching is cached' is dangerously wrong. fetch() calls to external HTTP APIs are cached by Next.js's Data Cache — but database queries made through Prisma, Drizzle, pg, or any ORM are NOT cached by default. A page that fetches a user, their orders, order items, product details, and reviews hits the database 5+ sequential times. A dashboard with 12 data widgets can hit 47+ database queries per page load if each widget fetches its own data sequentially.
In this article, you'll learn the exact caching behavior of fetch() vs database queries, how to eliminate waterfalls with parallel fetching, how to deduplicate identical database queries across components, and the production patterns for caching, revalidating, and preloading data in Server Components.
The fetch() Auto-Cache — What It Covers and What It Misses
When you call fetch() in a Server Component, Next.js automatically caches the response in its Data Cache. The same URL + options returns the cached result on subsequent requests — no network call. This is zero-config caching for HTTP APIs. But it only covers fetch().
Direct database queries — Prisma, Drizzle, Kysely, pg, Sequelize, TypeORM — do NOT go through the Next.js Data Cache. Every page load hits your database unless you explicitly add caching. The same query in two different Server Components on the same page executes twice against the database.
The confusion stems from Next.js docs showing fetch() examples for data fetching. Teams extrapolate 'fetch caches' to 'all data fetching caches' — then wonder why their database is hammered on every request. The fix: wrap database queries with unstable_cache() for persistent caching or Reactcache() for request-scoped deduplication.
fetch-vs-db-caching.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// ============================================// fetch() — auto-cached by Next.js// ============================================// These are automatically cached by Next.js Data Cache// Same URL + options = one network call, repeated cache hitsasyncfunctiongetTodosViaFetch() {
const res = await fetch('https://api.example.com/todos', {
next: { revalidate: 60 } // optional: revalidate every 60s
})
return res.json()
}
// ============================================// Database query — NOT cached by default// ============================================// Prisma: every call hits the database// Same query called from 3 components = 3 database queriesimport { prisma } from'@/lib/prisma'asyncfunctiongetTodosViaPrisma() {
// This hits the database EVERY TIME — no cachingreturnawait prisma.todo.findMany()
}
// ============================================// Fix 1: React cache() — request-scoped deduplication// ============================================import { cache } from'react'// Multiple calls to getTodosCached() in the SAME render pass// result in only ONE database queryconst getTodosCached = cache(async () => {
returnawait prisma.todo.findMany()
})
// ============================================// Fix 2: unstable_cache() — persistent cross-request cache// ============================================import { unstable_cache } from'next/cache'// Cached across requests with 60s revalidation// After 60s, the next request triggers a fresh queryconst getTodosPersistent = unstable_cache(
async () => {
returnawait prisma.todo.findMany()
},
['todos-list'], // cache key
{ revalidate: 60, tags: ['todos'] } // 60s TTL + cache tag
)
fetch() Caches ORM Calls Don't — Don't Confuse the Two
fetch() goes through Next.js Data Cache automatically. Prisma, Drizzle, pg, and all direct database calls do NOT. Always wrap database queries with React cache() for request-scoped deduplication and unstable_cache() for cross-request persistence.
Production Insight
A team using Prisma with Server Components saw their database connection pool exhausted under moderate traffic. Each page load of the dashboard made 47 Prisma queries — 47 connections from the pool. With 100 concurrent users, that's 4,700 concurrent database queries. The pool maxed out at 500. Adding React cache() dropped duplicate queries from 47 to 8 unique queries per page — a 6x reduction in database connections.
Rule: database queries are NOT cached unless you explicitly cache them. For every Server Component data fetch, ask: 'Is this fetch() (auto-cached) or database (manual cache needed)?'
Key Takeaway
fetch() HTTP calls are auto-cached by Next.js Data Cache.
Database queries (Prisma, Drizzle, pg) are NOT cached — wrap with cache() or unstable_cache().
Identical calls to the same database query in different components are NOT auto-deduplicated.
thecodeforge.io
Nextjs Data Fetching Server Components
Waterfalls Are the #1 Cause of Slow Server Component Pages
A waterfall happens when data fetches are sequential — each await waits for the previous one to complete. Three queries with 300ms latency each = 900ms total. With Promise.all(), the same three queries take 300ms total (the max of the three, not the sum).
Waterfalls are easy to write accidentally. The natural code pattern is:
const user = await getUser() const orders = await getOrders(user.id) // must wait for user const products = await getProducts(orders.map(o => o.productId)) // must wait for orders
This is a NECESSARY waterfall — each step depends on the previous one. The problem is writing UNNECESSARY waterfalls:
Total time for sequential independent queries = SUM of latencies
Total time for parallel independent queries = MAX of latencies
Promise.all() doesn't fail on first rejection — use Promise.allSettled() if you need partial results
Production Insight
An e-commerce product page had 8 data fetches: product, seller, reviews, related products, shipping options, inventory, pricing tiers, and SEO metadata. Originally all 8 were sequential — 2.4 seconds total (8 × 300ms). Refactoring to Promise.all() cut it to 450ms (max latency). The product fetched at 300ms, but the page waited for all 8 sequential queries before rendering anything. After parallelization, the page rendered in 450ms — interactive 2 seconds earlier.
Rule: look at every Server Component that has more than one await. If any of those fetches don't depend on each other's results, put them in Promise.all() immediately. This is the single highest-ROI performance optimization for Server Components.
Key Takeaway
Sequential independent awaits create waterfalls — total time = sum of latencies.
Promise.all() for independent fetches — total time = max of latencies.
Only await sequentially when the next fetch depends on the previous result.
React cache() — Request-Scoped Deduplication for Database Queries
React's cache() function wraps an async function and deduplicates calls within the same render pass. If two Server Components call the same cached function during the same request, only one invocation executes — the second caller gets the cached promise.
This is DIFFERENT from Next.js Data Cache (unstable_cache). React cache() is request-scoped: it caches within a single HTTP request. After the response is sent, the cache is garbage collected. It prevents duplicate database queries within one page load but does NOT cache across requests.
When to use: any database query that might be called by multiple Server Components on the same page. Common examples: getCurrentUser(), getSiteSettings(), getNavigation(). These are called from layouts, headers, sidebars — all within one request. Without cache(), a page with a sidebar and header that both call getCurrentUser() hits the database twice for the same user.
react-cache-deduplication.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// ============================================// Without cache() — duplicate database queries// ============================================// lib/data.tsimport { prisma } from'./prisma'exportasyncfunctiongetCurrentUser(userId: string) {
console.log('DBQUERY: getCurrentUser') // Logged TWICE on the same pagereturnawait prisma.user.findUnique({ where: { id: userId } })
}
// layout.tsx — calls getCurrentUserimport { getCurrentUser } from'@/lib/data'exportdefaultasyncfunctionLayout({ children }) {
const user = await getCurrentUser('123') // DB call #1return (
<>
<Header user={user} />
<Sidebar user={user} /> {/* Sidebar calls getCurrentUser again */}
<main>{children}</main>
</>
)
}
// sidebar.tsx — ALSO calls getCurrentUser (unknowingly)import { getCurrentUser } from'@/lib/data'exportasyncfunctionSidebar({ children }) {
const user = await getCurrentUser('123') // DB call #2 — DUPLICATE!return <aside>{/* ... */}</aside>
}
// ============================================// With cache() — deduplicated within same request// ============================================// lib/data.tsimport { cache } from'react'import { prisma } from'./prisma'// Only ONE database query per render pass, no matter how many callersexportconst getCurrentUser = cache(async (userId: string) => {
console.log('DBQUERY: getCurrentUser') // Logged ONCEreturnawait prisma.user.findUnique({ where: { id: userId } })
})
// Both layout.tsx and sidebar.tsx call getCurrentUser('123')// Only the first call executes — the second awaits the same promise// Result: 1 database query instead of 2// ============================================// cache() with parameters — properly keyed// ============================================exportconst getProduct = cache(async (productId: string) => {
returnawait prisma.product.findUnique({ where: { id: productId } })
})
// getProduct('abc') and getProduct('xyz') are different keys — both execute// getProduct('abc') called twice = executes once// cache key is determined by arguments
Wrap Every Shared Data Access Function with cache()
Any data access function that might be imported by multiple Server Components should be wrapped with React cache(). It's a zero-cost safety net — if only one caller invokes it, cache() adds negligible overhead. If two callers invoke it, you save a database query. Wrap it by default.
Production Insight
A team's layout.tsx imported getCurrentUser() for the header, and their sidebar.tsx also imported getCurrentUser() independently. Both were called during the same page render — two database queries for the same user data. Adding cache() to getCurrentUser() reduced the database queries from 47 to 46 on their dashboard page. A small fix, but applied across 100+ data access functions, it reduced total page queries by 30%.
Rule: wrap every data access function that's used in more than one place with React cache(). For functions used in only one place but called with different arguments, cache() still deduplicates identical argument calls.
Key Takeaway
React cache() deduplicates within the same HTTP request — prevents duplicate DB calls across components.
Multiple components importing the same data function execute it once with cache().
cache() is request-scoped — it does NOT cache across different user requests.
Cached vs Uncached Database QueriesPerformance impact of fetch caching strategiesCached FetchUncached FetchDatabase Queries Per LoadMinimal (cached hits)47 (uncached)Waterfall RiskLow (deduplication)High (sequential requests)Data FreshnessStale until revalidationAlways freshCache ScopeRequest-scoped or persistentNoneImplementationfetch() with cache or unstable_cache()Direct DB calls or no cacheTHECODEFORGE.IO
thecodeforge.io
Nextjs Data Fetching Server Components
unstable_cache() — Persistent Cross-Request Caching for Database Queries
While React cache() deduplicates within a single request, unstable_cache() persists across requests. It's the database equivalent of fetch()'s Data Cache. Wrap your database query with unstable_cache() and specify a revalidation time or cache tags.
unstable_cache() takes three arguments: the function to cache, an array of cache key parts, and an options object with revalidate (seconds) and tags (for on-demand revalidation). The cache key is derived from the key parts + function arguments. Two calls with the same key parts and same arguments return the cached value.
The real power is tag-based invalidation. Tag your cached data with a logical name (e.g., 'products', 'users'), and when a Server Action mutates that data, call revalidateTag('products') to purge all cached queries tagged with 'products'. This enables granular cache invalidation without guessing which cache keys to clear.
unstable-cache-pattern.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// ============================================// unstable_cache() — persistent caching for DB queries// ============================================import { unstable_cache } from'next/cache'import { prisma } from'@/lib/prisma'// ---- Basic usage: cache with time-based revalidation ----exportconst getProducts = unstable_cache(
async (categoryId?: string) => {
returnawait prisma.product.findMany({
where: categoryId ? { categoryId } : undefined,
orderBy: { createdAt: 'desc' },
take: 100,
})
},
['products-list'], // cache key prefix
{ revalidate: 60, tags: ['products'] } // 60s TTL
)
// ---- Tag-based invalidation from Server Action ----'use server'import { revalidateTag } from'next/cache'exportasyncfunctioncreateProduct(formData: FormData) {
const product = await prisma.product.create({
data: {
title: formData.get('title') asstring,
price: Number(formData.get('price')),
},
})
// Invalidate ALL cache entries tagged with 'products'revalidateTag('products')
return { success: true, product }
}
// ---- Usage: on-demand revalidation for specific items ----exportconst getProduct = unstable_cache(
async (slug: string) => {
returnawait prisma.product.findUnique({
where: { slug },
include: { reviews: true },
})
},
['product-detail'],
{ tags: ['products'] } // tagged same as list — updating a product invalidates both
)
// For more specific invalidation, use unique tags per item:exportconst getProductDetailed = unstable_cache(
async (slug: string) => {
returnawait prisma.product.findUnique({
where: { slug },
include: { reviews: true, seller: true },
})
},
['product-detail', 'slug'],
{ tags: ['products', `product-${slug}`] }
)
// Then invalidate only this product:// revalidateTag(`product-${slug}`) — keeps other product caches intact
Always Tag Your Cache — Time-Based Revalidation Is a Fallback
Set both revalidate (seconds) and tags on every unstable_cache() call. The revalidate time acts as a safety net (data refreshes eventually even without explicit invalidation). Tags enable instant invalidation when data changes. Without tags, the only way to clear the cache is waiting for revalidation or calling revalidatePath on every related URL.
Production Insight
An e-commerce site cached product data with unstable_cache() and a 300-second revalidation. When an admin updated a product price, the old price was served for up to 5 minutes — causing customer complaints about incorrect charges. Adding tag-based invalidation to the admin's update Server Action (revalidateTag('products')) made price changes visible immediately. The 300-second revalidation remained as a fallback for other cache entries that didn't get explicit invalidation.
Rule: always add cache tags to unstable_cache(). Use revalidateTag() in every mutation Server Action. Time-based revalidation is for cache entries you forget to invalidate — not the primary invalidation mechanism.
Key Takeaway
unstable_cache() provides persistent cross-request caching for database queries.
Use tags for on-demand invalidation via revalidateTag() — time-based revalidation is the fallback.
Cache key is derived from key parts + function arguments — identical calls return cached value.
Preloading Data — Starting Queries Before They're Needed
Server Components render top-down. If a parent component awaits data, it blocks rendering of all children until the data resolves — even if the children don't need that data. Preloading solves this by starting the query early and passing the promise (not the result) to children that need it.
The pattern: define a data-fetching function wrapped with cache(), export it, and call it at the top of your component file (not inside the component) to start the query immediately. The component then awaits the pre-started promise. Since cache() deduplicates, multiple components awaiting the same function share the same in-flight promise.
This pattern is especially useful for layouts that need user data. Instead of awaiting the user in the layout (blocking all children), start the user query at the module level, and let the layout and children both await the same pre-started promise.
preloading-data.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// ============================================// Preloading pattern — start queries immediately// ============================================// --- lib/data.ts ---import { cache } from'react'import { prisma } from'./prisma'exportconst getCurrentUser = cache(async (userId: string) => {
returnawait prisma.user.findUnique({ where: { id: userId } })
})
exportconst getDashboardData = cache(async (userId: string) => {
returnawait prisma.dashboard.findUnique({ where: { userId } })
})
// --- layout.tsx — PRELOAD data at module level ---import { getCurrentUser, getDashboardData } from'@/lib/data'// Start BOTH queries immediately — before any component renders// These calls trigger the async functions but don't await yet// The promises are stored in React's cache() for later useconst preloadUser = (id: string) => { voidgetCurrentUser(id) }
const preloadDashboard = (id: string) => { voidgetDashboardData(id) }
exportdefaultasyncfunctionDashboardLayout({
children,
params,
}: {
children: React.ReactNode
params: { userId: string }
}) {
// Preload ALL data that any child component needspreloadUser(params.userId)
preloadDashboard(params.userId)
// Layout awaits only its own needed dataconst user = awaitgetCurrentUser(params.userId)
// Children can also await getCurrentUser or getDashboardData// Since they were preloaded (and cache() deduplicates),// the awaits return immediately with the in-flight promisereturn (
<div>
<Header user={user} />
{children}
</div>
)
}
// --- dashboard-page.tsx — child component ---import { getDashboardData } from'@/lib/data'exportdefaultasyncfunctionDashboardPage({
params,
}: {
params: { userId: string }
}) {
// This query was already started in the layout!// cache() returns the in-flight promise — no second DB callconst data = awaitgetDashboardData(params.userId)
return <div>{/* render dashboard */}</div>
}
// ============================================// Alternative: preload() function pattern// ============================================exportconst getProduct = cache(async (slug: string) => {
returnawait prisma.product.findUnique({ where: { slug } })
})
// Export a preload function alongside the data functionexportconst preloadProduct = (slug: string) => {
voidgetProduct(slug)
}
// In parent:// preloadProduct('my-product') — starts query// ... later ...// const product = await getProduct('my-product') — returns immediately
Start Queries at Module Level, Await in Components
Call data functions at module level or early in the parent to start queries immediately
cache() ensures multiple awaits of the same function share one in-flight promise
Preloading doesn't change the total query time — it shifts when the query starts earlier
Production Insight
A team's dashboard layout awaited getCurrentUser() before rendering children. This added 200ms of 'dead time' where the server had the query running but couldn't render anything. Preloading getCurrentUser() at the top of the layout file (before the component function) and also preloading child-specific queries (getDashboardData, getNotifications) meant that by the time each component rendered, their data queries were already 50-80% complete. Perceived page load time dropped by 150ms.
Rule: preload all data queries as early as possible — ideally at the module level of the parent component. Use cache() to ensure deduplication. The earlier a query starts, the sooner its result is available.
Key Takeaway
Call data functions early to start queries before they're awaited.
cache() deduplicates across preload and await calls — one in-flight promise.
Preloading reduces perceived latency by starting queries while parent components render.
Route Segment Config — revalidate, dynamic, and Cache Control
Route segment config options control caching behavior for Server Components at the route level. The most important: revalidate (time-based revalidation), dynamic (force-dynamic or force-static), and fetchCache (override default fetch caching).
'export const revalidate = 60' sets the revalidation time for the entire route segment to 60 seconds. Any page under this segment revalidates at most once per 60 seconds — the first request after 60s triggers a fresh render. Setting revalidate = 0 effectively makes the page fully dynamic (no caching).
'export const dynamic = 'force-dynamic'' disables ALL caching for the route — no Full Route Cache, no Data Cache. Every request is a fresh server render with fresh data. This is the nuclear option — use it only when data MUST be real-time (ticker prices, live scores, active user count).
'export const dynamic = 'force-static'' forces static generation — the page is rendered at build time and served from cache. Use this for content that never changes (landing pages, documentation, about pages). The page content is baked into the HTML at build time.
route-segment-config.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// ============================================// Route Segment Config — caching behavior per route// ============================================// --- app/dashboard/page.tsx ---// Time-based revalidation: page refreshes max every 30 seconds// First visitor triggers a fresh render// Subsequent visitors within 30s get the cached versionexportconst revalidate = 30exportdefaultasyncfunctionDashboardPage() {
const data = awaitgetDashboardData()
return <Dashboard data={data} />
}
// --- app/live-scores/page.tsx ---// Fully dynamic: every request is a fresh server render// NO caching at any levelexportconst dynamic = 'force-dynamic'exportdefaultasyncfunctionLiveScoresPage() {
const scores = awaitgetLiveScores()
return <ScoreBoard scores={scores} />
}
// --- app/about/page.tsx ---// Fully static: rendered at build time, served from cacheexportconst dynamic = 'force-static'exportdefaultasyncfunctionAboutPage() {
const team = await getTeamData() // fetched at build timereturn <About team={team} />
}
// --- app/products/[slug]/page.tsx ---// Mix: page is statically generated with ISR// Revalidates the HTML every 60 secondsexportconst revalidate = 60exportasyncfunctiongenerateStaticParams() {
const products = await prisma.product.findMany()
return products.map((p) => ({ slug: p.slug }))
}
exportdefaultasyncfunctionProductPage({
params,
}: {
params: { slug: string }
}) {
const product = awaitgetProduct(params.slug)
return <ProductDetail product={product} />
}
// ============================================// Config inheritance: parent configs apply to children// ============================================// app/layout.tsx
export const revalidate = 3600// 1 hour for the entire site// app/dashboard/layout.tsx — overrides parent for dashboard section
export const revalidate = 60// 1 minute for all dashboard routes// app/dashboard/analytics/page.tsx — inherits 60s from dashboard layout
force-dynamic Disables ALL Caching — Use It Sparingly
Setting dynamic = 'force-dynamic' on a page or layout disables the Full Route Cache, Data Cache for fetch(), and any cached database queries. Every visitor triggers a full server render with fresh database queries. For a page with 500ms of data fetching, force-dynamic means every visitor waits 500ms. For 1000 visitors/second, that's 500 CPU-seconds of rendering per second.
Production Insight
A team set dynamic = 'force-dynamic' on their entire site 'to ensure fresh data.' Their database CPU went from 20% to 85% and page load times doubled. Removing force-dynamic and using targeted revalidation (revalidate = 60 on database-heavy pages, revalidateTag on mutation) brought CPU back to 25% and page load times back to baseline.
Rule: never use force-dynamic site-wide. Use it on specific routes that genuinely need real-time data. For everything else, use revalidate time or tag-based invalidation.
force-dynamic disables ALL caching — use only for real-time data.
Route configs inherit from parent layouts — override in specific routes as needed.
Suspense Boundaries — Streaming Data as It Arrives
Even with parallel fetching, the page can't render until ALL promises resolve. Suspense boundaries enable streaming: wrap individual widgets in <Suspense>, and the page streams each widget as its data arrives, rather than waiting for everything.
The pattern: instead of fetching all data in the page component and passing it down, move data fetching into individual widget components and wrap each widget in Suspense with a fallback skeleton. The page shell renders immediately, and each widget streams in as its data query completes.
This provides the best user experience: the page layout is visible instantly (with skeleton loading states), and data appears progressively. The total server rendering time is the same, but the user sees content sooner.
suspense-streaming.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// ============================================// Without Suspense — page waits for ALL data// ============================================// Total render time: 1200ms (max of all queries)// User sees: blank page for 1200ms, then everything at onceexportdefaultasyncfunctionDashboardPage() {
const [revenue, pageviews, sessions] = awaitPromise.all([
getRevenue(), // 800msgetPageviews(), // 600msgetSessions(), // 400ms
])
return (
<div className="grid grid-cols-3 gap-4">
<RevenueChart data={revenue} />
<PageviewsChart data={pageviews} />
<SessionsChart data={sessions} />
</div>
)
}
// ============================================// With Suspense — streams widgets as data arrives// ============================================// Page shell renders: immediately// Sessions widget streams in: at 400ms// Pageviews widget streams in: at 600ms// Revenue widget streams in: at 800ms// User sees content progressively, not all at onceimport { Suspense } from'react'exportdefaultfunctionDashboardPage() {
return (
<div className="grid grid-cols-3 gap-4">
<Suspense fallback={<WidgetSkeleton />}>
<RevenueWidget />
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<PageviewsWidget />
</Suspense>
<Suspense fallback={<WidgetSkeleton />}>
<SessionsWidget />
</Suspense>
</div>
)
}
// --- Each widget component fetches its own data ---asyncfunctionRevenueWidget() {
const data = await getRevenue() // 800msreturn <RevenueChart data={data} />
}
asyncfunctionPageviewsWidget() {
const data = await getPageviews() // 600msreturn <PageviewsChart data={data} />
}
asyncfunctionSessionsWidget() {
const data = await getSessions() // 400msreturn <SessionsChart data={data} />
}
functionWidgetSkeleton() {
return <div className="h-48 animate-pulse rounded bg-gray-200" />
}
// ============================================// Hybrid: parallel + Suspense// ============================================// For widgets that share data, use Promise.all() and pass as props// Stream the slowest widget in SuspenseexportdefaultasyncfunctionDashboardPage() {
// Start shared fetches immediatelyconst revenuePromise = getRevenue()
const sessions = await getSessions() // fast — 400msreturn (
<div>
<SessionsChart data={sessions} /> {/* renders immediately */}
<Suspense fallback={<WidgetSkeleton />}>
<RevenueWidgetWrapper dataPromise={revenuePromise} />
</Suspense>
</div>
)
}
asyncfunctionRevenueWidgetWrapper({
dataPromise,
}: {
dataPromise: Promise<RevenueData>
}) {
const data = await dataPromise
return <RevenueChart data={data} />
}
Suspense = Progressive Enhancement for Data Loading
Each Suspense boundary is an independent streaming slot — widgets load in parallel and stream as ready
Fallback skeletons should match the widget size to prevent layout shift (CLS)
Suspense boundaries can nest — wrap groups of related widgets in a single boundary
Production Insight
A dashboard with 5 widgets had 3 fast queries (200-400ms) and 2 slow aggregation queries (2-3 seconds). Without Suspense, the page took 3 seconds to show anything. Adding Suspense boundaries around the slow aggregation widgets meant the 3 fast widgets appeared at 400ms while the slow ones showed skeletons until their data arrived. Users saw useful content 2.6 seconds earlier.
Rule: wrap every independently fetched widget in a Suspense boundary. The fallback skeleton ensures the user sees progress immediately. The data streams in as queries complete.
Each widget's data loads in parallel; users see content progressively.
Match skeleton sizes to widget dimensions to minimize Cumulative Layout Shift.
Necessary vs Unnecessary Waterfalls — When Sequential Fetching Is Correct
Not all waterfalls are bad. Some data dependencies are genuine: you can't fetch orders without knowing the user ID, you can't fetch product details without the product ID from the order. These are necessary waterfalls. The mistake is creating unnecessary waterfalls by writing independent fetches sequentially.
The rule: if Query B's input depends on Query A's output, a waterfall is necessary. If Query B's input is known before Query A completes (it's a constant, a route param, or another independent value), the two queries should be parallel.
A common hidden waterfall: fetching data in child components. If a parent component doesn't fetch data and each child fetches independently, the children render sequentially — child 1 fetches, then child 2, then child 3. This creates a waterfall even though the data fetches are independent. The fix: fetch all data in the parent with Promise.all() and pass it as props.
All queries share the same input (route param, cookie, constant) = parallelize with Promise.all()
Children that fetch independently create hidden waterfalls — lift fetching to parent
Production Insight
A blog page had 5 components that each fetched data: Header (user), ArticleList (articles), Sidebar (tags), Footer (site settings), and a NewsletterPrompt (subscription status). All 5 fetches were independent, all needing only the current URL or a constant. But because they were separate Server Components rendered in order, they created a 5-step waterfall — 1.5 seconds total. Lifting all 5 fetches to the parent page and running them in Promise.all() cut the time to 400ms.
Rule: always fetch data at the highest possible level. If multiple children need data, fetch it in the parent once and pass it down. This eliminates hidden waterfalls from sequential child rendering.
Key Takeaway
A waterfall is necessary only when a later query needs data from an earlier query's result.
Independent queries that share the same input should be parallelized with Promise.all().
Children fetching their own data create hidden waterfalls — consolidate fetching in the parent.
Deduplicating fetch() Calls Across the Component Tree
Next.js automatically deduplicates fetch() calls with the same URL and options within the same render pass. If your Header calls fetch('/api/user') and your Sidebar also calls fetch('/api/user'), only one network request is made. Both components await the same in-flight promise.
This auto-deduplication is powerful but subtle: it only works for fetch(), not for database queries. It only works within a single render pass (one HTTP request to your Next.js server). And it only works if the URL and options are EXACTLY identical — different headers (even order) create separate requests.
The deduplication key is the full URL string + HTTP method + headers + body. If your fetch calls differ in any way, they're treated as separate requests. Be careful with dynamic headers like Authorization or Cache-Control that might differ between calls.
fetch-deduplication.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// ============================================// Auto-deduplication: same URL + options = one request// ============================================// These two component BOTH call fetch('/api/user')// Next.js deduplicates — only ONE network request// --- Header.tsx (Server Component) ---asyncfunctionHeader() {
const user = await fetch('https://api.example.com/user', {
next: { tags: ['user'] }
}).then(r => r.json())
return <header>{user.name}</header>
}
// --- Sidebar.tsx (Server Component) ---asyncfunctionSidebar() {
const user = await fetch('https://api.example.com/user', {
next: { tags: ['user'] }
}).then(r => r.json())
return <aside>{user.role}</aside>
}
// Result: one HTTP request, both components get the same data// ============================================// NO deduplication: different URLs or options// ============================================// Different URL paths → two requestsfetch('/api/user') // request 1fetch('/api/user?nocache=true') // request 2 — different URL// Different headers → two requestsfetch('/api/user', { headers: { 'x-custom': 'a' } }) // request 1fetch('/api/user', { headers: { 'x-custom': 'b' } }) // request 2// Different cache options → two requestsfetch('/api/user', { cache: 'force-cache' }) // request 1fetch('/api/user', { cache: 'no-store' }) // request 2 — different init// ============================================// For database: use cache() for the same effect// ============================================// Without cache(): two database queriesconst getUser = async (id: string) => {
returnawait prisma.user.findUnique({ where: { id } })
}
// With cache(): one database queryconst getUserCached = cache(async (id: string) => {
returnawait prisma.user.findUnique({ where: { id } })
})
fetch() Auto-Deduplication Is a Feature, Not a Bug
Don't try to 'optimize' by extracting shared fetch calls to a parent component. Next.js auto-deduplicates identical fetch calls automatically. Let each component declare its data dependencies. The framework handles deduplication. This is true server-driven UI — components declare what they need, and the framework figures out how to fetch it efficiently.
Production Insight
A team manually consolidated all API calls into a parent component to 'deduplicate' them. They passed data down through 4 levels of props. When a new component needed additional user data, they had to thread the data through the entire prop chain. After discovering Next.js auto-deduplicates fetch() calls, they moved data fetching back to individual components. The network tab confirmed: same number of HTTP requests (deduplication worked), but the code was simpler and more maintainable.
Rule: let each Server Component declare its own fetch() dependencies. Next.js deduplicates automatically. Only lift data fetching to the parent when the data needs to be shared across truly independent components that can't fetch it themselves (e.g., Suspense boundaries).
Key Takeaway
fetch() calls with identical URL + options are auto-deduplicated within a render pass.
Different URLs, headers, or options create separate requests — standardize your fetch calls.
Let components declare their own data dependencies — the framework handles deduplication.
Data Fetching in Parallel Layouts — Avoiding Duplicate Route Loading
When you have nested layouts (app/dashboard/layout.tsx and app/dashboard/analytics/layout.tsx), Next.js renders them in parallel but data fetching across them is NOT automatically coordinated. If both layouts fetch the same data (e.g., current user), the framework deduplicates fetch() but NOT database queries.
The solution: use React cache() for shared data functions. Both layouts can import and call getCurrentUser() from the same cached function. Only one database query executes. The second layout's await resolves with the same promise.
Alternatively, preload data in the root layout. Call all shared data functions at the top of the root layout (using void to start without awaiting), and every child layout or page that awaits the same cached function gets the already-started promise.
parallel-layout-data.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// ============================================// Shared data function with cache() — used by parallel layouts// ============================================// --- lib/data.ts ---import { cache } from'react'import { prisma } from'./prisma'exportconst getCurrentUser = cache(async () => {
// Only ONE database query even if called from multiple parallel layoutsreturnawait prisma.user.findUnique({ where: { id: getUserIdFromSession() } })
})
exportconst getSiteConfig = cache(async () => {
returnawait prisma.siteConfig.findFirst()
})
// --- app/dashboard/layout.tsx ---import { getCurrentUser, getSiteConfig } from'@/lib/data'exportdefaultasyncfunctionDashboardLayout({
children,
}: {
children: React.ReactNode
}) {
// First call to getCurrentUser — starts the DB queryconst user = awaitgetCurrentUser()
const config = awaitgetSiteConfig()
return (
<div className="dashboard-layout">
<DashboardNav user={user} />
{children}
<footer>{config.footer}</footer>
</div>
)
}
// --- app/dashboard/analytics/layout.tsx (parallel child layout) ---import { getCurrentUser } from'@/lib/data'exportdefaultasyncfunctionAnalyticsLayout({
children,
}: {
children: React.ReactNode
}) {
// Second call to getCurrentUser — cache() returns the in-flight promise// No database query — same promise as DashboardLayoutconst user = awaitgetCurrentUser()
return (
<div className="analytics-layout">
<AnalyticsHeader user={user} />
{children}
</div>
)
}
// Both layouts are rendered in parallel by Next.js// getCurrentUser() called from both — but cache() ensures only ONE DB query// The second layout's await resolves with the same promise as the first's
Parallel Layouts = Parallel Data Fetching — cache() Prevents Duplicates
Nested layouts render in parallel. Without cache(), a shared data function called from two parallel layouts executes twice against the database. With cache(), only the first call executes — the second awaits the same promise. Always wrap shared layout data functions with cache().
Production Insight
A team with 3 levels of nested layouts (site layout, dashboard layout, analytics layout) all called getCurrentUser(). Without cache(), each of the 3 parallel layouts initiated a separate database query — 3 queries for the same user. Adding cache() reduced it to 1. On a dashboard page with 47 total queries, this fix alone saved 2 queries.
Rule: wrap every data function that's imported by multiple layout files with React cache(). This is critical for nested layout architectures where parallel rendering amplifies duplicate queries.
Key Takeaway
Nested layouts render in parallel — shared data functions without cache() execute per layout.
cache() ensures only one database query per data function per request.
Preload shared data in the root layout to start queries before parallel layouts await them.
The Revalidation Pattern — Keeping Cached Data Fresh
Cached data becomes stale. The revalidation pattern ensures users see fresh data without sacrificing the performance benefits of caching. Two mechanisms: time-based (revalidate in seconds) and event-based (revalidateTag on mutation).
Time-based revalidation: set revalidate = 60 on the route segment or on fetch()/unstable_cache(). The cached data is served for up to 60 seconds. The first request after 60 seconds triggers a background revalidation — the stale value is served while the fresh value is fetched in the background. This is called stale-while-revalidate.
Event-based revalidation: call revalidateTag('products') from a Server Action after creating or updating a product. All cached data tagged with 'products' is immediately marked as stale. The next request fetches fresh data.
The production pattern: use event-based revalidation as the primary mechanism (data stays fresh), with time-based revalidation as the fallback (data eventually refreshes even if the event-based trigger fails).
Add both a broad tag ('products') and a specific tag ('product-slug') to every unstable_cache() call. The broad tag lets you invalidate everything in one call (for bulk updates). The specific tag lets you invalidate a single item (for individual updates). Use the specific tag in mutation Server Actions to keep other cache entries fresh.
Production Insight
An e-commerce site used only time-based revalidation (300 seconds). When an admin updated 50 product prices in bulk, customers saw old prices for up to 5 minutes — 12 support tickets per hour. Adding event-based revalidation (revalidateTag('products') after every price update) made changes visible immediately. The 300-second fallback remained for cache entries that didn't get explicit invalidation.
Rule: use revalidateTag() as your primary cache invalidation mechanism. Time-based revalidation is the safety net for cache entries you missed. Every mutation Server Action should call revalidateTag() or revalidatePath().
Key Takeaway
Time-based revalidation = stale-while-revalidate with automatic refresh.
Event-based revalidation = revalidateTag() from Server Actions — instant cache clearing.
Production pattern: event-based primary, time-based fallback.
The Pattern — Consolidate, Parallelize, Cache, Stream
The complete data fetching pattern for Server Components follows four steps:
CONSOLIDATE: Identify all unique data requirements for the page. Group them into logical datasets. Eliminate duplicates by identifying which queries can be combined or which results can be reused across components.
PARALLELIZE: Run independent queries with Promise.all(). Use necessary waterfalls only for genuine dependency chains. Lift fetching to the page/parent level to avoid hidden waterfalls from sequential child rendering.
CACHE: Wrap database queries with React cache() for request-scoped deduplication. Add unstable_cache() for persistent cross-request caching with revalidation tags. Tag all cached queries for event-based invalidation.
STREAM: Wrap slow or independent widgets in Suspense boundaries. The page shell renders immediately. Each widget streams in as its data arrives. Users see content progressively.
This pattern transforms a 47-query, 8-second dashboard into an 8-query, 800ms page that streams content progressively.
The consolidate-parallelize-cache-stream pattern works for 90% of pages. Start with all data fetching in the parent, use Promise.all() by default, wrap shared functions with cache(), and stream slow widgets with Suspense. Only reach for advanced patterns (preloading, specific cache keys) when your profiling shows a bottleneck.
Production Insight
Applying this pattern to a real dashboard: 47 queries → 8 consolidated datasets, 8.2s → 800ms render time, database CPU from 95% to 25%, and user-perceived load time from 'slow' to 'instant' because Suspense streaming showed content progressively. The team went from 'our dashboard is slow' to 'it's faster than we expected' in one refactoring session.
Rule: the consolidate-parallelize-cache-stream pattern is not optional — it's the baseline for production Next.js applications. Every page that fetches data should implement at least the first two steps.
Key Takeaway
Consolidate all data fetching to parent components — eliminate per-component fetching.
Parallelize independent queries with Promise.all() — total time = max, not sum.
Cache database queries with cache() and unstable_cache() — prevent repeat database hits.
Stream with Suspense boundaries — users see content progressively.
● Production incidentPOST-MORTEMseverity: high
47 Uncached Database Queries Per Page Load — A Dashboard That Took 8 Seconds to Render
Symptom
Dashboard page took 6-8 seconds to render on first load. Debug logs showed 47 individual SQL queries executed sequentially. CPU on the database server spiked to 95% during peak hours. The team assumed server-side rendering would be fast because 'the server has direct database access.'
Assumption
The team assumed Server Components would automatically batch or cache database queries. They wrote data fetching as individual awaits in each component — one for user data, one for orders, one for revenue, one for pageviews, etc. They didn't realize each await blocked the next. They also assumed using an ORM (Prisma) would provide built-in caching.
Root cause
Each of the 12 dashboard widgets was a separate Server Component that fetched its own data with a sequential await. Since the widgets were rendered in order in the layout, and each await blocked rendering, the total page load time was the SUM of all 47 query latencies (average 170ms per query = ~8s total). Additionally, identical queries (e.g., fetching the current user) were repeated across widgets — no deduplication. The database was hit 47 times with no caching layer.
Fix
1) Identified all unique data requirements across the dashboard — reduced from 47 queries to 8 unique datasets. 2) Moved all data fetching to the parent page component using Promise.all() — all 8 queries run in parallel, total time = max latency (800ms) instead of sum (8s). 3) Wrapped database queries with React cache() to deduplicate identical calls. 4) Added Next.js unstable_cache() with a 30-second revalidation window for slow aggregation queries. 5) Replaced the 12 individual widget Server Components with a single dashboard Server Component that receives all pre-fetched data as props and distributes it to presentational child components. Page render time dropped from 8.2s to 1.1s.
Key lesson
Sequential awaits in Server Components create waterfalls — each query waits for the previous one to finish before starting
Database queries (Prisma, Drizzle, pg, etc.) are NOT cached by Next.js — HTTP fetch() caches, direct DB access does not
Always consolidate data fetching to the parent component and use Promise.all() for parallel execution
Identical database queries across components are NOT auto-deduplicated — use React cache() or unstable_cache() manually
A dashboard with 12 widgets should make 5-8 parallel queries, not 47 sequential ones
Production debug guideDiagnose waterfall, caching, and N+1 issues in production5 entries
Symptom · 01
Page loads slowly but individual queries are fast
→
Fix
Check for sequential awaits (waterfall). Look for multiple await statements on separate lines. The total load time equals the SUM of all query latencies, not the MAX. Refactor to Promise.all() to run queries in parallel.
Symptom · 02
Database CPU spikes correlated with page traffic
→
Fix
Check if database queries are being repeated for the same data across components. Identical queries in different Server Components are NOT automatically deduplicated. Use React cache() or wrap with unstable_cache().
Symptom · 03
fetch() calls work but database queries are slow
→
Fix
fetch() calls to HTTP APIs are cached by Next.js Data Cache automatically. Database queries through Prisma/Drizzle/pg are NOT cached. You must wrap them with unstable_cache() or use React cache() for deduplication.
Symptom · 04
Data is stale even though revalidation is set
→
Fix
Check if the route segment config is overriding your revalidation. If layout.tsx has 'export const revalidate = 3600', it applies to all child routes. Also check if 'dynamic = force-dynamic' is set anywhere — it disables caching entirely.
Symptom · 05
Server Component data fetching works locally but not in production
→
Fix
Check if the database connection is available in the production environment. Server Components run on the server — they need direct database access. For serverless deployments (Vercel), ensure connection pooling is configured and cold-start queries are optimized.
★ Data Fetching Quick Debug ReferenceFast commands and patterns for diagnosing Server Component data fetching issues
Slow page load — waterfall suspected−
Immediate action
Check for sequential await statements in Server Components
database queries (Prisma, Drizzle, pg) do NOT. Always wrap DB queries with cache() or unstable_cache()
2
Waterfalls kill performance
independent queries should run in parallel via Promise.all(). Total time = max latency, not sum
3
React cache() deduplicates within a single request
use it for shared data functions called by multiple Server Components
4
unstable_cache() persists across requests with tag-based invalidation
the database equivalent of fetch() caching
5
Suspense boundaries enable streaming
page shell renders immediately, each widget streams in as its data arrives
6
Preload data by calling functions early (at module level)
cache() ensures multiple awaits share one in-flight promise
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Explain the caching behavior difference between fetch() and database que...
Q02SENIOR
What are data waterfalls in Server Components and how do you prevent the...
Q03SENIOR
Describe the complete data fetching pattern for a production Next.js das...
Q04SENIOR
When should you use Suspense boundaries for data fetching in Server Comp...
Q05SENIOR
How do you handle cache invalidation for database queries in a Next.js a...
Q01 of 05SENIOR
Explain the caching behavior difference between fetch() and database queries in Next.js Server Components.
ANSWER
fetch() calls in Server Components are automatically cached by Next.js's Data Cache layer. The framework intercepts HTTP requests, caches responses, and deduplicates identical calls within the same render pass. This works with zero configuration. Database queries through Prisma, Drizzle, pg, or any other database driver bypass this layer entirely — they talk directly to the database without caching. Wrap database queries with React cache() for request-scoped deduplication (multiple callers within one HTTP request share one query) or unstable_cache() for persistent caching across requests with configurable revalidation. The practical impact: a page with 5 Server Components that each call the same database function executes 5 queries without cache() and 1 query with cache() — a 5x reduction in database calls.
Q02 of 05SENIOR
What are data waterfalls in Server Components and how do you prevent them?
ANSWER
A data waterfall happens when multiple async data fetches are written sequentially with individual await statements. Each subsequent query waits for the previous one to complete before starting. Three 300ms queries written sequentially take 900ms total. With Promise.all(), they take 300ms (the max of the three). Prevent waterfalls by: (1) separating dependent and independent queries — dependencies (Query B needs Query A's result) must be sequential, but independent queries should be parallel; (2) moving all data fetching to parent components rather than letting each child fetch independently; (3) using Suspense boundaries to stream widgets independently so slow queries don't block the entire page. The most common hidden waterfall is child components that each fetch their own data — rendered sequentially by the parent, they create a N-step waterfall even when all fetches are independent.
Q03 of 05SENIOR
Describe the complete data fetching pattern for a production Next.js dashboard with 10+ data widgets.
ANSWER
The pattern has four steps: (1) CONSOLIDATE — audit all data requirements across the dashboard. Identify unique datasets and eliminate duplicates. A dashboard with 10 widgets might only need 5-6 unique datasets. (2) PARALLELIZE — run all independent queries with Promise.all() in the parent page component. Total time = max query latency, not sum. Handle necessary waterfalls (where Query B needs Query A's result) sequentially but keep them to a minimum. (3) CACHE — wrap shared data access functions with React cache() for request-scoped deduplication (prevents duplicate queries when multiple components request the same data). Add unstable_cache() with tags for persistent cross-request caching. Tag every cache entry for event-based invalidation via revalidateTag(). (4) STREAM — wrap each independent widget in a Suspense boundary. The page shell renders immediately with skeleton states. Each widget streams in as its data query completes. Users see content progressively — the fast queries appear first, slow queries stream in later. Apply event-based revalidation from Server Actions to keep cached data fresh after mutations.
Q04 of 05SENIOR
When should you use Suspense boundaries for data fetching in Server Components?
ANSWER
Use Suspense boundaries for any Server Component that fetches its own data and can be displayed independently. Each Suspense boundary creates an independent streaming slot — the component's fallback renders immediately, and the actual content streams in when the data query completes. This is especially valuable for dashboard widgets, sidebar components, and any page section where queries have different latencies. Without Suspense, the page is blank until ALL data queries complete. With Suspense, the page shell renders immediately, fast widgets appear quickly, and slow widgets stream in as they're ready. The trade-off: each Suspense boundary adds some overhead, so don't wrap every <p> tag — use them at the widget or section level. A good rule: if a component's data fetch takes longer than 200ms, it's a candidate for Suspense.
Q05 of 05SENIOR
How do you handle cache invalidation for database queries in a Next.js application with Server Components and Server Actions?
ANSWER
Cache invalidation for database queries uses a tag-based system. First, wrap database queries with unstable_cache() and assign tags: unstable_cache(fn, ['key'], { tags: ['products', 'product-slug'] }). The tags are arbitrary labels that group related cache entries. In Server Actions that mutate data, call revalidateTag('products') to invalidate all cache entries tagged with 'products'. This immediately marks the cached data as stale — the next request fetches fresh data. For granular invalidation, use specific tags per item (e.g., 'product-slug-123') and call revalidateTag on the specific item when only that item changes. Combine with time-based revalidation (revalidate: 300) as a fallback — if the event-based trigger fails, the cache still eventually refreshes. The production pattern: tag every cache entry with both a broad tag and a specific tag, and call revalidateTag() on the specific tag after mutations.
01
Explain the caching behavior difference between fetch() and database queries in Next.js Server Components.
SENIOR
02
What are data waterfalls in Server Components and how do you prevent them?
SENIOR
03
Describe the complete data fetching pattern for a production Next.js dashboard with 10+ data widgets.
SENIOR
04
When should you use Suspense boundaries for data fetching in Server Components?
SENIOR
05
How do you handle cache invalidation for database queries in a Next.js application with Server Components and Server Actions?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
Why does fetch() cache automatically but database queries don't?
fetch() goes through Next.js's Data Cache layer — every HTTP request to an external API is intercepted, cached, and deduplicated automatically. Database queries (Prisma, Drizzle, pg, etc.) bypass this layer entirely. They talk directly to the database without any intermediate caching. To cache database queries, you must explicitly wrap them with unstable_cache() for persistent caching or React cache() for request-scoped deduplication.
Was this helpful?
02
What's the difference between React cache() and Next.js unstable_cache()?
React cache() caches data within a single HTTP request. If two Server Components call the same cached function during the same page render, only one executes — the second awaits the same promise. After the response is sent, the cache is garbage collected. unstable_cache() persists across requests — it's the database equivalent of fetch()'s Data Cache. It caches the result across different users and requests, with configurable revalidation time and tag-based invalidation.
Was this helpful?
03
How do I prevent N+1 database queries in Server Components?
The N+1 problem occurs when a parent fetches a list of items, and each item component fetches additional data individually (1 query for the list + N queries for each item). Fix: use Promise.all() to fetch all child data in the parent, or use SQL JOINs to include related data in the initial query. Alternatively, use a dataloader pattern (similar to GraphQL) via React cache() — wrap the batch-fetching function with cache() so multiple callers reuse the same batch query.
Was this helpful?
04
Should I use 'force-dynamic' for pages that need fresh data on every request?
Only as a last resort. 'force-dynamic' disables ALL caching — Full Route Cache, Data Cache, and fetch() caching. Every visitor triggers a full server render and fresh database queries. Instead, use targeted revalidation: set revalidate = 0 on specific route segments that need fresh data, or use revalidateTag() in Server Actions to invalidate cache on demand. 'force-dynamic' is appropriate for real-time data like live scores, stock tickers, or active user counts where stale data is worse than slow response.
Was this helpful?
05
How do I debug data fetching waterfalls in Server Components?
Enable server-side logging with timing: add console.time() and console.timeEnd() around each data fetch. Check the server logs for sequential timestamps — if Query B starts after Query A ends, you have a waterfall. Alternatively, use the Next.js DevTools or OpenTelemetry integration to visualize fetch timing. The most common cause: sequential awaits in the page component or child components fetching independently.
Was this helpful?
06
Can I use React Query / TanStack Query with Server Components?
React Query is a client-side data fetching library — it runs in the browser. You cannot use it in Server Components. For Server Components, use the native async/await pattern with React cache() and unstable_cache(). If you need client-side data fetching (for real-time updates, pagination, or mutations), use React Query in Client Components while leveraging Server Components for initial data. The hybrid pattern: Server Component provides initial data as props to a Client Component that uses React Query for subsequent updates.