Dev Logs
/Next.js/ Chapter 14: Caching & use cache
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
    • Plain English Explanation
    • Turning it on
    • use cache — three placements
    • Function level
    • Component level
    • File level
    • Caching a whole route
    • Cache keys — what makes an entry unique
    • cacheLife — the lifetime
    • The three timings
    • The built-in profiles
    • Custom profiles
    • Inline profiles
    • Conditional lifetimes
    • Always set it explicitly
    • cacheTag — labels for invalidation
    • The constraint you'll hit first
    • The fix: read outside, pass in
    • Draft Mode is the exception
    • Serialization rules
    • Pass-through: children and actions
    • The three cache directives
    • use cache and serverless
    • use cache: private
    • Note: no cache survives a deploy
    • The previous model (without Cache Components)
    • fetch options
    • Route segment config
    • unstable_cache
    • Common Pitfalls
    • . Forgetting cacheComponents: true
    • . Reading cookies inside use cache
    • . Omitting cacheLife
    • . Caching per-user data with plain use cache
    • . Expecting in-memory caching to work on serverless
    • . Expecting cache to survive a deploy
    • . Passing a class instance
    • . Build hangs for 50 seconds
    • . Caching something too short-lived to matter
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Fix the error
    • Problem 2: Pick the profile
    • Problem 3: Cache key analysis
    • Problem 4: Compose it
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

💾 Chapter 14: Caching & use cache

The caching model Next.js 16 was built around: one directive, explicit lifetimes, and no more guessing what's cached.

📖 Plain English Explanation

Caching is storing the result of expensive work so you don't repeat it. Simple idea, and Next.js has historically made it complicated.

Next.js 13 and 14 cached aggressively and implicitly. fetch was cached by default. Route segments were cached by default. Developers spent an enormous amount of time discovering their data was stale and hunting for the flag that turned caching off. The complaints were loud enough that Next.js 15 reversed the defaults, and Next.js 16 replaced the whole model.

The new model is built on one principle:

Nothing is cached unless you say so, and when you say so, you say for how long.

The tool is a directive:

tsx
async function getProducts() {
  'use cache'
  cacheLife('hours')
  return db.product.findMany()
}

Three lines: cache this, for roughly an hour. No hidden defaults, no framework guessing.

This chapter covers use cache and its supporting APIs. Chapter 15 covers invalidating what you've cached. Chapter 16 covers the bigger picture — how caching feeds Partial Prerendering.

⚙️ Turning it on

use cache, cacheLife, and cacheTag are all part of Cache Components, which is opt-in:

ts
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
js
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  cacheComponents: true,
}

module.exports = nextConfig

⚠️ Changed in Next.js 16

cacheComponents replaces three removed experimental flags:

js
// ❌ all removed in Next.js 16
experimental: { ppr: true }
experimental: { dynamicIO: true }
experimental: { useCache: true }

// ✅
cacheComponents: true

This is not a rename. Enabling cacheComponents changes how rendering works and will surface build errors for uncached data that isn't inside a <Suspense> boundary. Adopt it deliberately, not as a find-and-replace. Chapter 16 walks through the migration.

Without this flag you're on the previous model — fetch options and unstable_cache, covered at the end of this chapter.

🏷️ use cache — three placements

Every cached function or component must be async.

Function level

ts
// lib/data.ts
import { cacheLife } from 'next/cache'

export async function getProducts() {
  'use cache'
  cacheLife('hours')

  return db.product.findMany({ where: { active: true } })
}
js
// lib/data.js
import { cacheLife } from 'next/cache'

export async function getProducts() {
  'use cache'
  cacheLife('hours')

  return db.product.findMany({ where: { active: true } })
}

Component level

tsx
// app/components/ProductGrid.tsx
import { cacheLife, cacheTag } from 'next/cache'

export async function ProductGrid({ category }: { category: string }) {
  'use cache'
  cacheLife('hours')
  cacheTag(`products-${category}`)

  const products = await db.product.findMany({ where: { category } })

  return (
    <div className="grid grid-cols-4 gap-4">
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  )
}
jsx
// app/components/ProductGrid.js
import { cacheLife, cacheTag } from 'next/cache'

export async function ProductGrid({ category }) {
  'use cache'
  cacheLife('hours')
  cacheTag(`products-${category}`)

  const products = await db.product.findMany({ where: { category } })

  return (
    <div className="grid grid-cols-4 gap-4">
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  )
}

Caching a component caches its rendered output, not just its data. Everything it renders is stored as an RSC payload and replayed.

File level

ts
// lib/settings.ts
'use cache'                  // ← applies to every export in this file

import { cacheLife } from 'next/cache'

export async function getSiteConfig() {
  cacheLife('max')
  return db.config.findFirst()
}

export async function getNavigation() {
  cacheLife('max')
  return db.navItem.findMany()
}

At file level, every export must be async.

Caching a whole route

Put use cache at the top of both the layout and the page — they're separate cache entries:

tsx
// app/blog/layout.tsx
'use cache'
import { cacheLife } from 'next/cache'

export default async function Layout({ children }: { children: React.ReactNode }) {
  cacheLife('days')
  return <div className="prose">{children}</div>
}
tsx
// app/blog/page.tsx
'use cache'
import { cacheLife, cacheTag } from 'next/cache'

export default async function Page() {
  cacheLife('days')
  cacheTag('posts')

  const posts = await getPosts()
  return <PostList posts={posts} />
}

🔑 Cache keys — what makes an entry unique

This is the part that determines whether your cache actually works. The key is built from:

  1. Build ID — changes every deploy, so a new deploy starts with an empty cache
  2. Function ID — a hash of the function's location and signature
  3. Serializable arguments — the function's parameters or the component's props
  4. Closed-over variables — anything captured from an outer scope

That fourth one surprises people:

tsx
async function Component({ userId }: { userId: string }) {
  const getData = async (filter: string) => {
    'use cache'
    // Cache key includes BOTH userId (closure) and filter (argument)
    return fetch(`/api/users/${userId}/data?filter=${filter}`)
  }

  return getData('active')
}

userId isn't a parameter, but it's captured from the enclosing scope, so it becomes part of the key. Every user gets their own cache entry — which is correct here, but be aware it's happening.

Practical consequence: a cached function with a wide argument space produces a wide cache. getUser(id) across a million users is a million entries. That's usually fine, but a function keyed on a full search query string may never hit.

⏱️ cacheLife — the lifetime

Every cache directive should be paired with a cacheLife call. It takes a profile name:

ts
import { cacheLife } from 'next/cache'

export async function getPost(slug: string) {
  'use cache'
  cacheLife('days')
  return db.post.findUnique({ where: { slug } })
}

The three timings

PropertyMeaning
staleHow long the browser serves cached content without asking the server
revalidateHow long before the server regenerates in the background (stale-while-revalidate)
expireMaximum age — after this with no traffic, the next request waits for a fresh render

The built-in profiles

ProfileUse forstalerevalidateexpire
defaultStandard content5 min15 minnever
secondsReal-time (prices, scores)30 sec1 sec1 min
minutesFeeds, news5 min1 min1 hour
hoursInventory, weather5 min1 hour1 day
daysBlog posts, articles5 min1 day1 week
weeksPodcasts, newsletters5 min1 week30 days
maxLegal pages, archives5 min30 days1 year

Pick by how often the content actually changes:

ts
cacheLife('seconds')   // live stock ticker
cacheLife('minutes')   // social feed
cacheLife('hours')     // product inventory
cacheLife('days')      // published blog post
cacheLife('max')       // terms of service

Custom profiles

ts
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    editorial: {
      stale: 600,        // 10 minutes
      revalidate: 3600,  // 1 hour
      expire: 86400,     // 1 day
    },
  },
}

export default nextConfig
ts
export async function getArticle(id: string) {
  'use cache'
  cacheLife('editorial')
  return db.article.findUnique({ where: { id } })
}

Omitted properties inherit from default. TypeScript autocomplete picks up your custom names.

Inline profiles

For one-offs, or when the lifetime comes from the data itself:

ts
export async function getOffer(id: string) {
  'use cache'
  const offer = await db.offer.findUnique({ where: { id } })

  cacheLife({
    revalidate: offer?.ttlSeconds ?? 3600,
  })

  return offer
}

Conditional lifetimes

You can call cacheLife in different branches, as long as exactly one runs per invocation:

ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getPost(slug: string) {
  'use cache'
  const post = await fetchPost(slug)
  cacheTag(`post-${slug}`)

  if (!post) {
    cacheLife('minutes')       // might get published soon — check back often
    return null
  }

  cacheLife('days')            // published content is stable
  return post
}

Always set it explicitly

Omitting cacheLife applies the default profile — but more importantly, it makes the outer cache's lifetime depend on nested caches:

tsx
export default async function Dashboard() {
  'use cache'
  // No cacheLife → default (15 min revalidate)
  // If Widget has a 5-min lifetime → Dashboard drops to 5 min
  // If Widget has a 1-hour lifetime → Dashboard stays at 15 min
  return <div><Widget /></div>
}

That's invisible action at a distance, potentially through a third-party dependency. With an explicit cacheLife, the outer scope always wins and you can reason about a function by reading it.

Next.js enforces this in one case: nesting a short-lived cache (revalidate 0, or expire under 5 minutes) inside a cache with no explicit cacheLife is a build error, because the short lifetime would silently propagate outward.

🏷️ cacheTag — labels for invalidation

cacheLife handles time. cacheTag handles events.

ts
// lib/data.ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getPost(slug: string) {
  'use cache'
  cacheLife('max')                 // long lifetime...
  cacheTag(`post-${slug}`)         // ...because we'll invalidate explicitly
  cacheTag('posts')                // tag it twice — specific and broad

  return db.post.findUnique({ where: { slug } })
}
js
// lib/data.js
import { cacheLife, cacheTag } from 'next/cache'

export async function getPost(slug) {
  'use cache'
  cacheLife('max')
  cacheTag(`post-${slug}`)
  cacheTag('posts')

  return db.post.findUnique({ where: { slug } })
}

Then invalidate from a Server Action:

ts
// app/actions.ts
'use server'
import { updateTag } from 'next/cache'

export async function editPost(slug: string, data: PostInput) {
  await db.post.update({ where: { slug }, data })
  updateTag(`post-${slug}`)        // this post, immediately
}

⚠️ Changed in Next.js 16

cacheLife and cacheTag are now stable. Drop the unstable_ prefix and the import aliases:

ts
// ❌ Next.js 15
import {
  unstable_cacheLife as cacheLife,
  unstable_cacheTag as cacheTag,
} from 'next/cache'

// ✅ Next.js 16
import { cacheLife, cacheTag } from 'next/cache'

The combination — long cacheLife plus a cacheTag you invalidate on write — is the ideal setup for content that changes rarely but must be correct when it does. Chapter 15 covers the invalidation side in full.

🚫 The constraint you'll hit first

Cached functions cannot read request-time data.

tsx
// ❌ Error: next-request-in-use-cache
async function getUserData() {
  'use cache'
  const session = (await cookies()).get('session')   // not allowed
  return db.user.findUnique({ where: { id: session.userId } })
}

Off-limits inside a use cache scope:

  • cookies()
  • headers()
  • searchParams
  • params (unless prerendered via generateStaticParams)

The restriction follows the call stack — a helper the cached function calls that reads cookies fails the same way.

This makes sense: a cache is shared, and request data is per-user. Caching a value derived from someone's session and serving it to someone else is a data leak.

The fix: read outside, pass in

tsx
// app/profile/page.tsx
import { cookies } from 'next/headers'
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'

export default function Page() {
  return (
    <Suspense fallback={<div>Loading…</div>}>
      <ProfileContent />
    </Suspense>
  )
}

// Not cached — reads the request
async function ProfileContent() {
  const sessionId = (await cookies()).get('session')?.value
  return <CachedProfile sessionId={sessionId} />
}

// Cached — receives the value as an argument
async function CachedProfile({ sessionId }: { sessionId?: string }) {
  'use cache'
  cacheLife('minutes')
  const data = await fetchUserData(sessionId)
  return <div>{data.name}</div>
}
jsx
// app/profile/page.js
import { cookies } from 'next/headers'
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'

export default function Page() {
  return (
    <Suspense fallback={<div>Loading…</div>}>
      <ProfileContent />
    </Suspense>
  )
}

async function ProfileContent() {
  const sessionId = (await cookies()).get('session')?.value
  return <CachedProfile sessionId={sessionId} />
}

async function CachedProfile({ sessionId }) {
  'use cache'
  cacheLife('minutes')
  const data = await fetchUserData(sessionId)
  return <div>{data.name}</div>
}

sessionId becomes part of the cache key, so each session gets its own entry. Explicit, safe, and readable.

Draft Mode is the exception

draftMode() can be read inside use cache, and when draft mode is on, cached functions re-execute every request and don't write to the cache:

tsx
import { draftMode } from 'next/headers'

async function Content() {
  'use cache'
  const { isEnabled } = await draftMode()
  const url = isEnabled ? DRAFT_API : PROD_API
  return <article>{await fetchFrom(url)}</article>
}

📦 Serialization rules

Arguments and return values must be serializable — and the rules differ between the two.

Arguments (Server Component serialization, stricter):

✅ string, number, boolean, null, undefined
✅ plain objects, arrays
✅ Date, Map, Set, TypedArray, ArrayBuffer
✅ React elements — pass-through only
❌ class instances, functions, Symbol, WeakMap, URL

Return values (Client Component serialization, looser): the same, plus JSX elements. That's why a cached component can return markup.

Pass-through: children and actions

You can accept non-serializable values as long as you don't inspect them:

tsx
async function CachedShell({
  header,
  children,
}: {
  header: React.ReactNode
  children: React.ReactNode
}) {
  'use cache'
  cacheLife('hours')

  const cachedData = await getExpensiveData()

  return (
    <div>
      {header}
      <PrerenderedContent data={cachedData} />
      {children}            {/* passed straight through, not part of the key */}
    </div>
  )
}
tsx
// app/page.tsx
export default async function Page() {
  const liveData = await getLiveData()      // NOT cached

  return (
    <CachedShell header={<h1>Dashboard</h1>}>
      <LiveWidget data={liveData} />        {/* dynamic, inside a cached shell */}
    </CachedShell>
  )
}

The expensive shell is cached; the live widget isn't. This composition is what makes Cache Components genuinely useful rather than all-or-nothing.

Server Actions pass through the same way — just don't call them inside the cached function:

tsx
async function CachedForm({ action }: { action: () => Promise<void> }) {
  'use cache'
  // Don't invoke action here — just hand it to the client
  return <ClientButton action={action} />
}

🌍 The three cache directives

DirectiveWhere it's storedCan read cookies/headersUse for
use cacheIn-memory on the server (per instance)❌Shared data, the default choice
use cache: remoteA durable shared store (Redis, KV)❌High-hit-rate data across instances
use cache: privateThe user's browser✅Per-user data you can't refactor

use cache and serverless

Worth understanding, because it changes what you should expect:

EnvironmentRuntime behavior
ServerlessEach request may hit a fresh instance. In-memory entries often don't persist between requests. Build-time caching still works.
Self-hostedA long-lived process means entries persist across requests as expected.

So on Vercel or Lambda, use cache primarily helps by putting results into the prerendered static shell at build time. For durable runtime caching across instances you need use cache: remote, which costs a network round-trip and usually platform fees. Only worth it at a high hit rate.

use cache: private

For per-user data where refactoring to pass arguments isn't practical:

tsx
async function UserGreeting() {
  'use cache: private'
  cacheLife('minutes')

  const theme = (await cookies()).get('theme')?.value   // allowed here
  return <p>Theme: {theme}</p>
}

The result is cached in the user's browser only — never in a shared server store. This is what makes reading cookies safe.

Prefer the read-outside-pass-in pattern where you can. Reach for private when compliance rules or an unrefactorable codebase force your hand.

Note: no cache survives a deploy

The build ID is part of every cache key, so a new deployment starts cold — including remote entries. For data that must persist across deploys, use unstable_cache or the fetch cache.

🗄️ The previous model (without Cache Components)

If cacheComponents is off, you're on the older APIs. Still supported, still common in existing codebases.

fetch options

ts
// No cache — the DEFAULT in Next.js 15/16
await fetch(url, { cache: 'no-store' })

// Cache indefinitely
await fetch(url, { cache: 'force-cache' })

// Cache with time-based revalidation
await fetch(url, { next: { revalidate: 3600 } })

// Cache with tags for on-demand invalidation
await fetch(url, { next: { tags: ['posts'] } })

⚠️ Changed in Next.js 15

fetch used to be cached by default. Since Next.js 15, it isn't. If a tutorial tells you to add cache: 'no-store' to get fresh data, that's the default now.

Route segment config

tsx
// app/products/page.tsx
export const dynamic = 'force-dynamic'    // never prerender
export const revalidate = 3600            // ISR — regenerate hourly
export const fetchCache = 'force-no-store'

unstable_cache

The pre-use cache way to cache a non-fetch function:

ts
import { unstable_cache } from 'next/cache'

export const getProducts = unstable_cache(
  async (category: string) => db.product.findMany({ where: { category } }),
  ['products'],                              // key parts
  { revalidate: 3600, tags: ['products'] }
)

It still works and, unlike use cache, persists across deploys. But use cache is clearer, composable, and integrated with prerendering. Prefer it for new code.

⚠️ Common Pitfalls

1. Forgetting cacheComponents: true

Error: "use cache" is not enabled

The directive does nothing without the flag.

2. Reading cookies inside use cache

Error: next-request-in-use-cache

The nasty version: on a dynamically rendered route this can pass next build and only fail under next start. Test production builds.

3. Omitting cacheLife

Not an error, but it makes the lifetime implicit and lets nested caches silently shorten it. Set it every time.

4. Caching per-user data with plain use cache

If a user ID isn't part of the cache key, one user's data gets served to another. Either pass the ID as an argument, or use use cache: private.

5. Expecting in-memory caching to work on serverless

Instances are ephemeral. Plain use cache mainly helps at build time there. Use remote if you need runtime persistence across instances.

6. Expecting cache to survive a deploy

It doesn't. The build ID is in every key.

7. Passing a class instance

tsx
// ❌ Cannot serialize class instance
async function Card({ user }: { user: UserModel }) {
  'use cache'
}

// ✅
async function Card({ id, name }: { id: string; name: string }) {
  'use cache'
}

8. Build hangs for 50 seconds

Error: Filling a cache during prerender timed out, likely because
request-specific arguments such as params, searchParams, cookies()
or uncached data were used inside "use cache".

You've passed a promise that resolves to request data into a cached scope — as a prop, via a closure, or through a shared Map. Await the runtime value outside and pass the resolved primitive in.

9. Caching something too short-lived to matter

cacheLife('seconds') has a 1-minute expire, which excludes it from prerenders. If the data changes every request, don't cache it — wrap it in <Suspense> and stream it.

🎯 When & Why to Use

'use cache' when:
  ✅ The result is the same for many users
  ✅ It's expensive (slow query, external API, heavy computation)
  ✅ Slightly stale is acceptable
  ✅ You want it in the prerendered static shell

'use cache: private' when:
  ✅ Per-user data that must read cookies/headers directly
  ✅ You can't refactor to pass values as arguments

'use cache: remote' when:
  ✅ Serverless, and you need entries shared across instances
  ✅ The hit rate justifies a network round-trip and platform cost

Don't cache when:
  ❌ Data must be fresh every request → <Suspense> + stream it
  ❌ It's cheap to compute
  ❌ The cache key would be so wide it never hits

Choosing a lifetime:

Changes every second?     → don't cache; stream it
Every minute?             → cacheLife('minutes')
A few times a day?        → cacheLife('hours')
Daily?                    → cacheLife('days')
Only when someone edits?  → cacheLife('max') + cacheTag + updateTag on write

🏋️ Mini Practice Problems

Problem 1: Fix the error

tsx
import { cookies } from 'next/headers'

export async function getDashboard() {
  'use cache'
  const userId = (await cookies()).get('uid')?.value
  return db.dashboard.findUnique({ where: { userId } })
}

Name the error and give two different fixes.

Problem 2: Pick the profile

  • A. Live cryptocurrency price
  • B. A published blog post that can be edited by its author
  • C. The site's navigation menu
  • D. A product's stock count
  • E. A "trending this week" list

For each: which cacheLife profile, and does it also need a cacheTag?

Problem 3: Cache key analysis

How many cache entries does this produce for 1,000 users each viewing 5 categories?

tsx
async function Component({ userId }: { userId: string }) {
  const load = async (category: string) => {
    'use cache'
    return fetch(`/api/${userId}/items?c=${category}`)
  }
  return load('shoes')
}

Is this a good use of use cache? Why or why not?

Problem 4: Compose it

A product page needs:

  • Product details — change a few times a day, must update instantly when an admin edits
  • Reviews — expensive query, an hour stale is fine
  • "In your cart" indicator — per-user, must be current
  • Recommendations — same for everyone, updated nightly

Write the page. Say which parts are cached with what profile and tags, and which stream.

💼 Interview Notes

Common Questions

Q: What changed about caching in Next.js 16? Implicit caching was replaced by explicit opt-in. fetch is no longer cached by default (that changed in 15). The use cache directive, cacheLife, and cacheTag became the model, gated behind cacheComponents: true, which also replaced experimental.ppr, dynamicIO, and useCache.

Q: What determines a cache entry's key? Build ID, a hash of the function's identity, its serializable arguments, and any variables it closes over from an outer scope. The closure part is the one people miss.

Q: Why can't a cached function read cookies()? A cache entry is shared. Deriving it from one user's request and serving it to another would leak data. Read request values outside the cached scope and pass them in as arguments, which makes them part of the key.

Q: What's the difference between stale, revalidate, and expire? stale is how long the browser serves its copy without checking the server. revalidate is when the server regenerates in the background while still serving the old copy. expire is the hard ceiling — past it, the next request waits for a fresh render.

Q: Why should you always call cacheLife explicitly? Without it, the default profile applies and nested caches with shorter lifetimes can silently shorten the outer one — possibly from a third-party dependency. An explicit call always wins, so a function's behavior is readable from the function itself.

Q: When would you use use cache: remote over use cache? On serverless, where in-memory entries don't survive between requests. remote uses a durable shared store, at the cost of a network round-trip and platform fees — worth it only at a high hit rate.

Q: Does the cache survive a deployment? No. The build ID is part of every cache key, including remote entries. unstable_cache and the fetch cache do persist across deploys.

🏢 Asked at Companies

  • Vercel: "Design the caching for a product page: catalog, live stock, personalized recommendations, and reviews."
  • Shopify: "A customer reports seeing another customer's name on the dashboard. What caching mistake causes that?"
  • Stripe: "Explain stale-while-revalidate and where it shows up in cacheLife."
  • Netflix: "When is caching the wrong answer, and what do you do instead?"

📊 Visual Memory Aid

              THE CACHE KEY

  ┌─────────────────────────────────┐
  │ build ID        (per deploy)    │
  │ function ID     (code location) │
  │ arguments       (props/params)  │
  │ closed-over vars ← easy to miss │
  └─────────────────────────────────┘
       │
       └──►  one entry per unique combination


              THE THREE TIMINGS

   0 ─────────────────────────────────────────────►  time
   │      stale       │   revalidate    │   expire
   │  browser serves  │  server serves  │  next request
   │  without asking  │  old + rebuilds │  waits for fresh
   │                  │  in background  │


              PROFILES AT A GLANCE

   seconds  ──►  live data       (1s revalidate)
   minutes  ──►  feeds, news     (1m)
   hours    ──►  inventory       (1h)
   days     ──►  blog posts      (1d)
   weeks    ──►  newsletters     (1w)
   max      ──►  legal, archive  (30d)  ← pair with cacheTag


              THE DIRECTIVES

  'use cache'          server memory   no cookies   shared
  'use cache: remote'  durable store   no cookies   shared
  'use cache: private' browser only    cookies OK   per-user


              READ OUTSIDE, PASS IN

  ❌ async function f() {
       'use cache'
       const id = (await cookies()).get('uid')   ← error
     }

  ✅ async function Outer() {              (not cached)
       const id = (await cookies()).get('uid')
       return <Inner userId={id} />
     }
     async function Inner({ userId }) {
       'use cache'                          ← userId is in the key
     }

🎯 Key Takeaways

  1. Nothing is cached unless you say so. use cache + cacheLife is the whole model, gated behind cacheComponents: true.
  2. Arguments and closures form the cache key. A value captured from an outer scope silently multiplies your cache entries.
  3. Always call cacheLife explicitly. Otherwise nested caches can shorten the outer lifetime from somewhere you'd never think to look.
  4. Cached scopes cannot read cookies(), headers(), or searchParams. Read them outside and pass the value in — that's the pattern, not a workaround.
  5. On serverless, plain use cache mainly helps at build time. In-memory entries don't survive between requests; use use cache: remote when you need durable shared caching.

Next Chapter: Revalidation & ISR →

Practice: Take a page with three data sources of different volatility. Cache each with an appropriate cacheLife, tag the one that changes on write, and use NEXT_PRIVATE_DEBUG_CACHE=1 npm run start to watch the hits and misses in the log.


PreviousChapter 13: Route HandlersNextChapter 15: Revalidation & ISR

Open source, free forever. Built by iammhador.

Contribute on GitHub