Home JavaScript fetch() in Server Components — 47 Uncached Database Queries Per Page Loaded
Beginner 7 min · July 12, 2026
Data Fetching in Next.js 16: Server Components, Streaming, and Suspense

fetch() in Server Components — 47 Uncached Database Queries Per Page Loaded

Server Component fetch() auto-caches.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 20 min
  • Next.js app directory familiarity
  • Server Components basics
  • Node.js 18+ installed
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
ChromeFirefoxSafariEdge

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 React cache() 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 hits

async function getTodosViaFetch() {
  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 queries

import { prisma } from '@/lib/prisma'

async function getTodosViaPrisma() {
  // This hits the database EVERY TIME — no caching
  return await 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 query

const getTodosCached = cache(async () => {
  return await 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 query

const getTodosPersistent = unstable_cache(
  async () => {
    return await prisma.todo.findMany()
  },
  ['todos-list'], // cache key
  { revalidate: 60, tags: ['todos'] } // 60s TTL + cache tag
)
Try it live
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.
nextjs-data-fetching-server-components THECODEFORGE.IO Data Fetching Layers in Server Components Stacked architecture from request to persistent cache Route Segment Config revalidate | dynamic | cache control Suspense Boundaries Streaming data | Partial rendering Preloading Data Start queries early | Avoid waterfalls Request-Scoped Cache React cache() | Deduplication per request Persistent Cache unstable_cache() | Cross-request caching Fetch Auto-Cache HTTP cache | Built-in deduplication THECODEFORGE.IO
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:

const revenue = await getRevenue() // independent const pageviews = await getPageviews() // independent const sessions = await getSessions() // independent

These three are parallel but written sequentially. Each one adds 300ms to the page load time for no reason.

waterfall-vs-parallel.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
// ============================================
// WATERFALL — sequential independent fetches
// ============================================

// Total time: 900ms (3 × 300ms) — BAD

export default async function DashboardPage() {
  const revenue = await getRevenue() // starts at 0ms, ends at 300ms
  const pageviews = await getPageviews() // starts at 300ms, ends at 600ms
  const sessions = await getSessions() // starts at 600ms, ends at 900ms

  return (
    <div>
      <RevenueWidget data={revenue} />
      <PageviewsWidget data={pageviews} />
      <SessionsWidget data={sessions} />
    </div>
  )
}

// ============================================
// PARALLEL — Promise.all() for independent fetches
// ============================================

// Total time: 300ms (max of 3) — GOOD

export default async function DashboardPage() {
  const [revenue, pageviews, sessions] = await Promise.all([
    getRevenue(),     // starts at 0ms, ends at 300ms
    getPageviews(),   // starts at 0ms, ends at 250ms
    getSessions(),    // starts at 0ms, ends at 280ms
  ])

  return (
    <div>
      <RevenueWidget data={revenue} />
      <PageviewsWidget data={pageviews} />
      <SessionsWidget data={sessions} />
    </div>
  )
}

// ============================================
// MIXED — necessary waterfall + parallel independent
// ============================================

// Total time: user(300ms) + max(orders(200ms), recommendations(400ms)) = 700ms
// Without parallelization: 300 + 200 + 400 = 900ms

export default async function ProfilePage({ userId }: { userId: string }) {
  const user = await getUser(userId) // necessary waterfall — orders needs userId

  const [orders, recommendations] = await Promise.all([
    getOrders(user.id),              // independent after user
    getRecommendations(user.id),     // independent after user
  ])

  return (
    <div>
      <UserProfile user={user} />
      <OrderList orders={orders} />
      <Recommendations items={recommendations} />
    </div>
  )
}
Try it live
Promise.all() Is Free Speed — Use It Aggressively
  • 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.ts
import { prisma } from './prisma'

export async function getCurrentUser(userId: string) {
  console.log('DB QUERY: getCurrentUser') // Logged TWICE on the same page
  return await prisma.user.findUnique({ where: { id: userId } })
}

// layout.tsx — calls getCurrentUser
import { getCurrentUser } from '@/lib/data'

export default async function Layout({ children }) {
  const user = await getCurrentUser('123') // DB call #1
  return (
    <>
      <Header user={user} />
      <Sidebar user={user} /> {/* Sidebar calls getCurrentUser again */}
      <main>{children}</main>
    </>
  )
}

// sidebar.tsx — ALSO calls getCurrentUser (unknowingly)
import { getCurrentUser } from '@/lib/data'

export async function Sidebar({ children }) {
  const user = await getCurrentUser('123') // DB call #2 — DUPLICATE!
  return <aside>{/* ... */}</aside>
}

// ============================================
// With cache() — deduplicated within same request
// ============================================

// lib/data.ts
import { cache } from 'react'
import { prisma } from './prisma'

// Only ONE database query per render pass, no matter how many callers
export const getCurrentUser = cache(async (userId: string) => {
  console.log('DB QUERY: getCurrentUser') // Logged ONCE
  return await 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
// ============================================

export const getProduct = cache(async (productId: string) => {
  return await 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
Try it live
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 Queries Performance impact of fetch caching strategies Cached Fetch Uncached Fetch Database Queries Per Load Minimal (cached hits) 47 (uncached) Waterfall Risk Low (deduplication) High (sequential requests) Data Freshness Stale until revalidation Always fresh Cache Scope Request-scoped or persistent None Implementation fetch() with cache or unstable_cache() Direct DB calls or no cache THECODEFORGE.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 ----

export const getProducts = unstable_cache(
  async (categoryId?: string) => {
    return await 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'

export async function createProduct(formData: FormData) {
  const product = await prisma.product.create({
    data: {
      title: formData.get('title') as string,
      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 ----

export const getProduct = unstable_cache(
  async (slug: string) => {
    return await 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:

export const getProductDetailed = unstable_cache(
  async (slug: string) => {
    return await 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
Try it live
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'

export const getCurrentUser = cache(async (userId: string) => {
  return await prisma.user.findUnique({ where: { id: userId } })
})

export const getDashboardData = cache(async (userId: string) => {
  return await 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 use
const preloadUser = (id: string) => { void getCurrentUser(id) }
const preloadDashboard = (id: string) => { void getDashboardData(id) }

export default async function DashboardLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: { userId: string }
}) {
  // Preload ALL data that any child component needs
  preloadUser(params.userId)
  preloadDashboard(params.userId)

  // Layout awaits only its own needed data
  const user = await getCurrentUser(params.userId)

  // Children can also await getCurrentUser or getDashboardData
  // Since they were preloaded (and cache() deduplicates),
  // the awaits return immediately with the in-flight promise
  return (
    <div>
      <Header user={user} />
      {children}
    </div>
  )
}

// --- dashboard-page.tsx — child component ---

import { getDashboardData } from '@/lib/data'

export default async function DashboardPage({
  params,
}: {
  params: { userId: string }
}) {
  // This query was already started in the layout!
  // cache() returns the in-flight promise — no second DB call
  const data = await getDashboardData(params.userId)

  return <div>{/* render dashboard */}</div>
}

// ============================================
// Alternative: preload() function pattern
// ============================================

export const getProduct = cache(async (slug: string) => {
  return await prisma.product.findUnique({ where: { slug } })
})

// Export a preload function alongside the data function
export const preloadProduct = (slug: string) => {
  void getProduct(slug)
}

// In parent:
// preloadProduct('my-product') — starts query
// ... later ...
// const product = await getProduct('my-product') — returns immediately
Try it live
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 version
export const revalidate = 30

export default async function DashboardPage() {
  const data = await getDashboardData()
  return <Dashboard data={data} />
}

// --- app/live-scores/page.tsx ---

// Fully dynamic: every request is a fresh server render
// NO caching at any level
export const dynamic = 'force-dynamic'

export default async function LiveScoresPage() {
  const scores = await getLiveScores()
  return <ScoreBoard scores={scores} />
}

// --- app/about/page.tsx ---

// Fully static: rendered at build time, served from cache
export const dynamic = 'force-static'

export default async function AboutPage() {
  const team = await getTeamData() // fetched at build time
  return <About team={team} />
}

// --- app/products/[slug]/page.tsx ---

// Mix: page is statically generated with ISR
// Revalidates the HTML every 60 seconds
export const revalidate = 60

export async function generateStaticParams() {
  const products = await prisma.product.findMany()
  return products.map((p) => ({ slug: p.slug }))
}

export default async function ProductPage({
  params,
}: {
  params: { slug: string }
}) {
  const product = await getProduct(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
Try it live
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.
Key Takeaway
revalidate sets time-based page revalidation — 0 = dynamic, empty = static.
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 once

export default async function DashboardPage() {
  const [revenue, pageviews, sessions] = await Promise.all([
    getRevenue(),     // 800ms
    getPageviews(),   // 600ms
    getSessions(),    // 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 once

import { Suspense } from 'react'

export default function DashboardPage() {
  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 ---

async function RevenueWidget() {
  const data = await getRevenue() // 800ms
  return <RevenueChart data={data} />
}

async function PageviewsWidget() {
  const data = await getPageviews() // 600ms
  return <PageviewsChart data={data} />
}

async function SessionsWidget() {
  const data = await getSessions() // 400ms
  return <SessionsChart data={data} />
}

function WidgetSkeleton() {
  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 Suspense

export default async function DashboardPage() {
  // Start shared fetches immediately
  const revenuePromise = getRevenue()
  const sessions = await getSessions() // fast — 400ms

  return (
    <div>
      <SessionsChart data={sessions} /> {/* renders immediately */}
      <Suspense fallback={<WidgetSkeleton />}>
        <RevenueWidgetWrapper dataPromise={revenuePromise} />
      </Suspense>
    </div>
  )
}

async function RevenueWidgetWrapper({
  dataPromise,
}: {
  dataPromise: Promise<RevenueData>
}) {
  const data = await dataPromise
  return <RevenueChart data={data} />
}
Try it live
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.
Key Takeaway
Suspense boundaries stream widgets independently — page shell renders immediately.
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.

necessary-vs-unnecessary.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
// ============================================
// NECESSARY waterfall — dependency chain
// ============================================

// Query B depends on Query A — cannot parallelize
// Total time: 900ms (300 + 300 + 300)

export default async function OrderDetailPage(orderId: string) {
  const order = await getOrder(orderId)          // Q1: ~300ms
  const product = await getProduct(order.productId) // Q2: depends on Q1 — ~300ms
  const seller = await getSeller(product.sellerId)  // Q3: depends on Q2 — ~300ms

  return <OrderView order={order} product={product} seller={seller} />
}

// ============================================
// UNNECESSARY waterfall — independent queries
// ============================================

// All three queries only need userId — which is a route param, known immediately
// Total time (sequential): 900ms — BAD
// Total time (parallel): 300ms — GOOD

export default async function ProfilePage(userId: string) {
  const profile = await getProfile(userId)    // Q1: ~300ms
  const posts = await getPosts(userId)         // Q2: ~300ms — independent!
  const followers = await getFollowers(userId) // Q3: ~300ms — independent!

  return <ProfileView profile={profile} posts={posts} followers={followers} />
}

// CORRECT: parallel
export default async function ProfilePage(userId: string) {
  const [profile, posts, followers] = await Promise.all([
    getProfile(userId),
    getPosts(userId),
    getFollowers(userId),
  ])

  return <ProfileView profile={profile} posts={posts} followers={followers} />
}

// ============================================
// HIDDEN waterfall — child component fetching
// ============================================

// BAD: each child fetches independently, parent renders children sequentially

export default function BlogPage() {
  return (
    <div>
      <Header />       {/* renders first, fetches user data: 200ms */}
      <ArticleList />  {/* renders second, fetches articles: 300ms */}
      <Sidebar />      {/* renders third, fetches tags: 150ms */}
      {/* Total time: 200 + 300 + 150 = 650ms */}
    </div>
  )
}

// GOOD: parent fetches all data, passes as props

export default async function BlogPage() {
  const [user, articles, tags] = await Promise.all([
    getCurrentUser(),
    getArticles(),
    getTags(),
  ])
  // Total time: max(200, 300, 150) = 300ms

  return (
    <div>
      <Header user={user} />
      <ArticleList articles={articles} />
      <Sidebar tags={tags} />
    </div>
  )
}
Try it live
Dependency Chain = Waterfall Is Necessary. Shared Input = Parallelizable.
  • Query B's input depends on Query A's output = necessary waterfall — cannot parallelize
  • 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) ---

async function Header() {
  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) ---

async function Sidebar() {
  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 requests
fetch('/api/user') // request 1
fetch('/api/user?nocache=true') // request 2 — different URL

// Different headers → two requests
fetch('/api/user', { headers: { 'x-custom': 'a' } }) // request 1
fetch('/api/user', { headers: { 'x-custom': 'b' } }) // request 2

// Different cache options → two requests
fetch('/api/user', { cache: 'force-cache' }) // request 1
fetch('/api/user', { cache: 'no-store' }) // request 2 — different init

// ============================================
// For database: use cache() for the same effect
// ============================================

// Without cache(): two database queries
const getUser = async (id: string) => {
  return await prisma.user.findUnique({ where: { id } })
}

// With cache(): one database query
const getUserCached = cache(async (id: string) => {
  return await prisma.user.findUnique({ where: { id } })
})
Try it live
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'

export const getCurrentUser = cache(async () => {
  // Only ONE database query even if called from multiple parallel layouts
  return await prisma.user.findUnique({ where: { id: getUserIdFromSession() } })
})

export const getSiteConfig = cache(async () => {
  return await prisma.siteConfig.findFirst()
})

// --- app/dashboard/layout.tsx ---

import { getCurrentUser, getSiteConfig } from '@/lib/data'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  // First call to getCurrentUser — starts the DB query
  const user = await getCurrentUser()
  const config = await getSiteConfig()

  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'

export default async function AnalyticsLayout({
  children,
}: {
  children: React.ReactNode
}) {
  // Second call to getCurrentUser — cache() returns the in-flight promise
  // No database query — same promise as DashboardLayout
  const user = await getCurrentUser()

  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
Try it live
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).

revalidation-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
69
70
71
72
73
74
75
76
77
// ============================================
// Data fetching with cache tags — for event-based invalidation
// ============================================

import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'

// Fetch with tag for event-based invalidation
// Time-based fallback: 300s
export const getProducts = unstable_cache(
  async () => {
    return await prisma.product.findMany({
      orderBy: { createdAt: 'desc' },
    })
  },
  ['products-list'],
  { revalidate: 300, tags: ['products'] }
)

// ============================================
// Server Action — invalidates cache on mutation
// ============================================

'use server'

import { revalidateTag } from 'next/cache'
import { prisma } from '@/lib/prisma'

export async function updateProductPrice(
  productId: string,
  newPrice: number
) {
  // Update database
  await prisma.product.update({
    where: { id: productId },
    data: { price: newPrice },
  })

  // Invalidate ALL cache entries tagged with 'products'
  // This clears: getProducts list, getProduct detail, search results — everything
  revalidateTag('products')

  return { success: true }
}

// ============================================
// Specific invalidation — per-item tags
// ============================================

export const getProduct = unstable_cache(
  async (slug: string) => {
    return await prisma.product.findUnique({
      where: { slug },
      include: { inventory: true, reviews: true },
    })
  },
  ['product-detail', 'slug'],
  { tags: ['products', `product-${slug}`] }
)

'use server'

export async function updateProductInventory(
  slug: string,
  quantity: number
) {
  await prisma.inventory.update({
    where: { productSlug: slug },
    data: { quantity },
  })

  // Only invalidate cache for THIS product
  revalidateTag(`product-${slug}`)

  // All other product caches remain valid
  return { success: true }
}
Try it live
Tag Everything, Invalidate Selectively
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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

complete-pattern.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
// ============================================
// Complete Data Fetching Pattern
// ============================================

// --- lib/data.ts ---
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'

// Step 3: CACHE — wrap with cache() for request dedup + unstable_cache() for persistence
export const getDashboardData = cache(async (userId: string) => {
  const [profile, stats, recentOrders, notifications] = await Promise.all([
    // Step 2: PARALLELIZE — all independent queries run concurrently
    getProfile(userId),
    getStats(userId),
    getRecentOrders(userId),
    getNotifications(userId),
  ])

  return { profile, stats, recentOrders, notifications }
})

const getProfile = unstable_cache(
  async (userId: string) => prisma.user.findUnique({ where: { id: userId } }),
  ['profile'],
  { revalidate: 60, tags: ['user'] }
)

const getStats = unstable_cache(
  async (userId: string) => prisma.dashboard.findUnique({ where: { userId } }),
  ['stats'],
  { revalidate: 60, tags: ['dashboard'] }
)

const getRecentOrders = unstable_cache(
  async (userId: string) =>
    prisma.order.findMany({ where: { userId }, orderBy: { date: 'desc' }, take: 5 }),
  ['recent-orders'],
  { revalidate: 30, tags: ['orders'] }
)

const getNotifications = unstable_cache(
  async (userId: string) =>
    prisma.notification.findMany({ where: { userId, read: false } }),
  ['notifications'],
  { revalidate: 10, tags: ['notifications'] }
)

// --- app/dashboard/page.tsx ---

import { Suspense } from 'react'
import { getDashboardData } from '@/lib/data'

// Step 1: CONSOLIDATE — parent fetches all data
export default async function DashboardPage() {
  const userId = await getCurrentUserId()

  return (
    <div className="grid grid-cols-2 gap-6">
      {/* Step 4: STREAM — wrap in Suspense for progressive loading */}
      <Suspense fallback={<CardSkeleton />}>
        <ProfileSection userId={userId} />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <StatsSection userId={userId} />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <RecentOrdersSection userId={userId} />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <NotificationsSection userId={userId} />
      </Suspense>
    </div>
  )
}

// Individual widget — receives userId, fetches its portion
async function ProfileSection({ userId }: { userId: string }) {
  const { profile } = await getDashboardData(userId)
  return <ProfileCard user={profile} />
}

async function StatsSection({ userId }: { userId: string }) {
  const { stats } = await getDashboardData(userId)
  return <StatsGrid data={stats} />
}
Try it live
Start with the Pattern, Optimize Later
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
Commands
grep -rn 'await' app/ --include='*.tsx' --include='*.ts' | grep -v 'test\|spec\|node_modules'
grep -rn 'const.*= await' app/ --include='*.tsx' | grep -v 'Promise.all'
Fix now
Group independent queries into Promise.all(). Move all data fetching to parent component and pass down as props.
Repeated database queries for the same data+
Immediate action
Check if database queries are wrapped with cache() or unstable_cache()
Commands
grep -rn "from 'react'\|cache(" app/ --include='*.ts' --include='*.tsx' | grep -E 'cache\('
grep -rn 'unstable_cache' app/ --include='*.ts' --include='*.tsx'
Fix now
Wrap shared data access functions with React cache() to deduplicate identical calls in the same render pass.
fetch() cached but database not cached+
Immediate action
Check if database code uses unstable_cache() for persistence caching
Commands
grep -rn 'prisma\|drizzle\|db\.' app/ --include='*.ts' --include='*.tsx' | head -20
grep -rn 'unstable_cache\|cache()' app/ --include='*.ts' --include='*.tsx'
Fix now
Wrap slow or repeated database queries with unstable_cache() with appropriate revalidation time.
Data doesn't update after mutation+
Immediate action
Check if revalidateTag or revalidatePath is being called after mutations
Commands
grep -rn 'revalidateTag\|revalidatePath' app/ --include='*.ts' --include='*.tsx'
grep -rn "'use server'" app/ -A 30 | grep 'revalidate'
Fix now
Add revalidateTag('your-data-tag') after mutations. Apply the same tag during data fetching with next: { tags: [...] }.
⚙ Quick Reference
12 commands from this guide
FileCommand / CodePurpose
fetch-vs-db-caching.tsasync function getTodosViaFetch() {The fetch() Auto-Cache
waterfall-vs-parallel.tsxexport default async function DashboardPage() {Waterfalls Are the #1 Cause of Slow Server Component Pages
react-cache-deduplication.tsexport async function getCurrentUser(userId: string) {React cache()
unstable-cache-pattern.tsexport const getProducts = unstable_cache(unstable_cache()
preloading-data.tsexport const getCurrentUser = cache(async (userId: string) => {Preloading Data
route-segment-config.tsexport const revalidate = 30Route Segment Config
suspense-streaming.tsxexport default async function DashboardPage() {Suspense Boundaries
necessary-vs-unnecessary.tsexport default async function OrderDetailPage(orderId: string) {Necessary vs Unnecessary Waterfalls
fetch-deduplication.tsxasync function Header() {Deduplicating fetch() Calls Across the Component Tree
parallel-layout-data.tsxexport const getCurrentUser = cache(async () => {Data Fetching in Parallel Layouts
revalidation-pattern.tsexport const getProducts = unstable_cache(The Revalidation Pattern
complete-pattern.tsxexport const getDashboardData = cache(async (userId: string) => {The Pattern

Key takeaways

1
fetch() auto-caches via Next.js Data Cache
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.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Why does fetch() cache automatically but database queries don't?
02
What's the difference between React cache() and Next.js unstable_cache()?
03
How do I prevent N+1 database queries in Server Components?
04
Should I use 'force-dynamic' for pages that need fresh data on every request?
05
How do I debug data fetching waterfalls in Server Components?
06
Can I use React Query / TanStack Query with Server Components?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

7 min read · try the examples if you haven't

Previous
Server Components vs Client Components in Next.js 16
23 / 56 · Next.js
Next
Styling in Next.js 16: Tailwind CSS, CSS Modules, and Global Styles