Switch Language
Toggle Theme

Complete Guide to Next.js Caching: Master When to Use revalidate

1 AM. I’m staring at that damn “stale data” on the screen, refreshing for the 20th time. I manually updated the title in the database 10 minutes ago, but the page won’t budge. Opening the code, revalidate: 60 is right there, crystal clear. Furiously deleted it, changed to revalidate: 10. Restarted the server. Refreshed. Still old.

You know that feeling?

Honestly, Next.js’s caching mechanism might be the most frustrating part of the entire framework. Four layers of caching, three revalidate methods, and a breaking change from version 14 to 15. You think setting revalidate: 60 is enough? Nope - could be Router Cache messing around, could be Full Route Cache not invalidating, or you might be testing in dev mode.

This article won’t lecture you about “caching is an important means to improve performance” - that’s technically correct but totally useless. I’ll tell you straight up:

  • What are the four layers of cache in Next.js and what does each do
  • What’s the difference between revalidatePath, revalidateTag, and updateTag - which one should you use
  • How to troubleshoot layer by layer when data won’t update

If you’ve ever faced data not updating, revalidate not working, or confusion about which API to use, the next 12 minutes might save you several all-nighters.

Why is Next.js Caching So Complex?

Why so many layers?

To be honest, when I first saw Next.js has four layers of caching, my expression was probably the same as yours right now. Request Memoization? Full Route Cache? What are these? Can’t it be simpler?

But think about it calmly - each layer of caching solves performance problems in different scenarios:

  • In your component tree, 10 components all need user info - you can’t make 10 requests, right?
  • Your blog post list doesn’t update that often - you can’t re-render on every visit, right?
  • User hits the back button - you can’t make them wait for page reload, right?

Each layer has its responsibility. The problem is, they interact with each other. That’s why when you update data, you don’t know which cache to clear.

Next.js 14 vs 15: A Caching Revolution

End of 2024, Next.js 15 dropped a bomb: no more default caching for fetch requests.

Before (14):

fetch(url) // Default cached, equals cache: 'force-cache'

Now (15):

fetch(url) // Default not cached, equals cache: 'no-store'

This change caused many people to see performance tank after upgrading, because previously auto-cached data now isn’t cached at all. Vercel forums were flooded with complaints, but their reasoning was: “Explicit is better than implicit, caching should be a developer’s conscious choice, not default behavior.”

Sounds reasonable. But for existing projects, this is a breaking change.

Four-Layer Cache Overview

Simply put, data goes through these four layers from server to user browser:

  1. Request Memoization (Request memoization)
    Scope: Single request render cycle
    Manager: React

  2. Data Cache (Data cache)
    Scope: Server-side, persists across requests
    Manager: Next.js

  3. Full Route Cache (Full route cache)
    Scope: Server-side, static routes
    Manager: Next.js

  4. Router Cache (Router cache)
    Scope: Client browser memory
    Manager: Next.js

The data flow looks roughly like this:

User Visit → Router Cache (Client) → Full Route Cache (Server)

                            Data Cache → Request Memoization → Data Source

Your revalidate mainly affects Data Cache and Full Route Cache. Router Cache needs router.refresh() or hard refresh to clear.

This is why sometimes you’ve clearly revalidated, but the client still shows old data on refresh - because Router Cache is still there.

Next chapter, I’ll break down these four caches layer by layer, telling you what each does, when it activates, when it expires.

Four-Layer Cache Mechanism Explained

Request Memoization

What is it?

This is actually a React 18 feature, not something Next.js specifically created. Simply put, during one render cycle, if multiple components make the same GET request, React automatically merges them into one.

For example:

// app/page.tsx
async function UserProfile() {
  const user = await fetch('https://api.example.com/user/123')
  return <div>{user.name}</div>
}

async function UserAvatar() {
  const user = await fetch('https://api.example.com/user/123')  // Same request
  return <img src={user.avatar} />
}

export default function Page() {
  return (
    <>
      <UserProfile />
      <UserAvatar />
    </>
  )
}

Both components request the same URL, but actually only one request is made. React remembers the first result and uses the cache for the second.

Very Short Lifetime

This cache only lasts during a single render process. When rendering ends, the cache clears. Next time a user refreshes the page, it’s a fresh request.

Notes

  • Only works for Server Components
  • Only works for GET requests, POST/PUT won’t be memoized
  • Might not see the effect in dev environment, because every code change triggers re-render

When would you use it?

You probably don’t need to care about it at all. This is an optimization React automatically does for you, can’t manually control. I mention it just so you know: if you write the same fetch in multiple components, don’t worry about multiple requests.

Data Cache

This is the key

Data Cache is the core of Next.js caching mechanism. It stores fetch request results on the server’s file system, persisting across requests, users, and deployments.

Next.js 14 vs 15 - Worlds Apart

Next.js 14:

fetch('https://api.example.com/posts')
// Equivalent to
fetch('https://api.example.com/posts', { cache: 'force-cache' })
// Result: Data cached permanently unless manually revalidated

Next.js 15:

fetch('https://api.example.com/posts')
// Equivalent to
fetch('https://api.example.com/posts', { cache: 'no-store' })
// Result: Re-fetch every time, no caching

If you want to cache data (Next.js 15)

Method 1: Per-request setting

fetch('https://api.example.com/posts', {
  cache: 'force-cache',
  next: { revalidate: 3600 }  // Revalidate after 1 hour
})

Method 2: Entire route setting

// app/blog/page.tsx
export const revalidate = 3600

export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts')
  // ...
}

When does it expire?

Data Cache expires at three moments:

  1. When the set revalidate time is up (like 3600 seconds)
  2. Manually calling revalidatePath() or revalidateTag()
  3. Redeploying the app

What if multiple fetches have different revalidate times?

If a page has multiple fetches with different revalidate times set, Next.js will use the shortest one as the revalidation time for the entire page.

async function Page() {
  const posts = await fetch('...', { next: { revalidate: 60 } })    // 60 seconds
  const user = await fetch('...', { next: { revalidate: 3600 } })  // 1 hour

  // Actually, entire page revalidates every 60 seconds
}

Full Route Cache

HTML-level caching

If Data Cache caches data, then Full Route Cache caches the entire page’s HTML and RSC Payload (serialized data from React Server Component).

When is it cached?

Only statically rendered routes are cached. What is static rendering? Pages whose content can be determined at build time.

Conversely, if your page uses these things, it becomes dynamically rendered and won’t be cached:

  • cookies()
  • headers()
  • searchParams
  • Unstable functions (like Math.random(), Date.now())

How to tell if a page is static or dynamic?

Run npm run build, terminal will show:

Route (app)                              Size     First Load JS
┌ ○ /                                    5 kB           87 kB
├ ● /blog                                1 kB           88 kB
└ ƒ /api/user                            0 kB           87 kB

○  (Static)  Auto-rendered as static HTML (using no dynamic data)
●  (SSG)     Auto-generated as static HTML + JSON (using getStaticProps)
ƒ  (Dynamic) Server-rendered on demand

See or = static, will be cached. See ƒ = dynamic, won’t be cached.

Force static or dynamic

// Force static
export const dynamic = 'force-static'

// Force dynamic
export const dynamic = 'force-dynamic'

When does it expire?

Full Route Cache expires at:

  1. When Data Cache expires, Full Route Cache also expires (because data changed, page needs re-render)
  2. Calling revalidatePath('/blog')
  3. Redeploying the app

Router Cache

Client-side tricks

Router Cache is stored in user browser memory. After a user visits a page, Next.js caches the page content on the client, next time the user clicks “back” or navigates to this page, it uses the cache directly without requesting the server.

There’s also this cool trick: prefetching

If you use <Link href="/about">, when this link appears in the user’s viewport, Next.js will automatically prefetch the /about page content and store it in Router Cache. When the user actually clicks, instant navigation.

Lifetime (Next.js 14)

  • Static routes: cached 5 minutes
  • Dynamic routes: cached 30 seconds

Changes in Next.js 15

Next.js 15 by default doesn’t enable Router Cache (or has very short cache time). If you want to enable it, you need to configure in next.config.js:

// next.config.js
module.exports = {
  experimental: {
    staleTimes: {
      dynamic: 30,      // Dynamic routes cached 30 seconds
      static: 180,      // Static routes cached 180 seconds
    },
  },
}

When does it expire?

  • Cache time is up
  • User hard refreshes (Ctrl+Shift+R)
  • Calling router.refresh()

Why does your revalidate seem not to work?

Many times, you called revalidatePath on the server, data actually updated, but users refreshing the page still see old data. Nine out of ten times it’s because Router Cache hasn’t expired yet.

Solutions:

  1. User hard refresh (but you can’t require users to do this)
  2. After updating data, call router.refresh() (if it’s a client component)
  3. Shorten Router Cache time

Complete Guide to revalidate Methods

Alright, covered the theory of four layers of caching. Now for the most practical part: how to invalidate cache.

Next.js provides several revalidate methods, each for different scenarios. Understanding their differences can save you tons of debugging time.

Time-based revalidate

Most common approach

Time-based revalidate means “automatically refetch data every X seconds”. This is the core mechanism of ISR (Incremental Static Regeneration).

Two ways to write it

Method 1: Set in fetch request

const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 }  // 3600 seconds = 1 hour
})

Method 2: Set at route file top level

// app/blog/page.tsx
export const revalidate = 3600

export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts')
  return <PostList posts={posts} />
}

How it works (ISR)

Suppose you set revalidate: 3600:

  1. First user visits page → generates static HTML, caches for 1 hour
  2. For the next hour, all users see this cached HTML (super fast)
  3. After 1 hour, next user visits → still returns old HTML (won’t wait)
  4. But meanwhile, Next.js regenerates new HTML in the background
  5. After new HTML is generated, subsequent users see new content

This mechanism is called stale-while-revalidate (return stale content while revalidating). Advantage is users never wait, disadvantage is there’s always one user seeing expired data.

Use cases

  • Blog post list (updates a few times per hour)
  • News homepage (every 30 minutes)
  • Product catalog (once a day)

Common Problem 1: Doesn’t work in dev environment

Many people complain revalidate doesn’t work after setting. First thing to check: are you testing in dev or production environment?

In dev environment (npm run dev), Next.js disables most caching, re-renders on every request. You must test in production:

npm run build
npm start

Common Problem 2: Multiple fetches with inconsistent times

Mentioned before, if a page has multiple fetches with different revalidate times, Next.js takes the minimum. But there’s a detail: Data Cache still respects each fetch’s own time.

async function Page() {
  // This request revalidates every 60 seconds
  const posts = await fetch('...', { next: { revalidate: 60 } })

  // This request revalidates every 3600 seconds
  const user = await fetch('...', { next: { revalidate: 3600 } })
}

Page will re-render every 60 seconds, but the user request’s data cache stays for 1 hour. Meaning, for the first hour, page re-renders every 60 seconds but user data doesn’t change; after 1 hour, user data updates.

Sounds a bit convoluted, but it’s actually a reasonable design.

On-demand revalidate: revalidatePath

User-triggered updates

Time-based revalidate is a timer, revalidatePath is a button - when a specific event happens (like user publishes a new post), you manually trigger cache invalidation.

Usage

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function publishPost(formData) {
  // Post publishing logic
  await db.posts.create({ ... })

  // Invalidate blog list page cache
  revalidatePath('/blog')
}

Then call in client component:

// app/components/PublishButton.tsx
'use client'

import { publishPost } from '@/app/actions'

export function PublishButton() {
  return (
    <form action={publishPost}>
      <button type="submit">Publish Post</button>
    </form>
  )
}

Different path types

revalidatePath can specify path type:

// Only revalidate /blog this one page
revalidatePath('/blog', 'page')

// Revalidate all pages under /blog (including /blog/post-1, /blog/post-2)
revalidatePath('/blog', 'layout')

Important: Not immediate regeneration

Many think after calling revalidatePath, the page regenerates immediately. Not true.

revalidatePath only marks cache as invalid. Actual regeneration happens next time a user visits this page.

This means:

  1. You call revalidatePath('/blog')
  2. Cache is cleared
  3. Next user visits /blog → Only now HTML regenerates (user waits)
  4. Subsequent users see new content

Use cases

  • User publishes new content, refresh list
  • Admin modifies config, refresh related pages
  • User submits form, refresh current page

On-demand revalidate: revalidateTag

More flexible invalidation control

revalidatePath invalidates by path, revalidateTag invalidates by tag. You can tag data, then batch invalidate all caches using that tag.

Usage

Step 1: Tag fetch requests

const posts = await fetch('https://api.example.com/posts', {
  next: {
    revalidate: 3600,
    tags: ['posts']  // Tag with 'posts'
  }
})

const authors = await fetch('https://api.example.com/authors', {
  next: {
    revalidate: 3600,
    tags: ['posts', 'authors']  // Can have multiple tags
  }
})

Step 2: Invalidate specific tag cache

'use server'

import { revalidateTag } from 'next/cache'

export async function publishPost() {
  await db.posts.create({ ... })

  // All data tagged with 'posts' will be invalidated
  revalidateTag('posts')
}

profile=“max” stale-while-revalidate strategy

Next.js 15 recommends using profile="max":

revalidateTag('posts', { profile: 'max' })

When using profile="max":

  1. Marked as expired, but doesn’t immediately delete cache
  2. Next visit → returns old data (fast!)
  3. Meanwhile fetches new data in background
  4. After new data is ready, subsequent requests return new data

This is better than default behavior, because users don’t have to wait.

Difference between revalidatePath and revalidateTag

DimensionrevalidatePathrevalidateTag
Invalidation granularityBy pathBy data tag
Cross-pageCan only specify specific pathCan cross multiple pages
PrecisionCoarseFine
Usage complexitySimpleNeed to plan tags in advance

When to use Tag?

When your data is used by multiple pages, Tag is more suitable.

For example, a blog system:

  • Post list page /blog
  • Post detail page /blog/[slug]
  • Author page /author/[id]
  • Homepage “Latest Posts” section

All four pages use “post data”. If you use revalidatePath, you’d need:

revalidatePath('/blog')
revalidatePath('/blog/[slug]')
revalidatePath('/author/[id]')
revalidatePath('/')

But if you tag post data with posts, you only need:

revalidateTag('posts')

All pages using this tag will be invalidated. Much easier.

New: updateTag (Next.js 15)

Immediate invalidation, not delayed

updateTag is a new API in Next.js 15, difference from revalidateTag is: immediately deletes cache, not marks as expired.

'use server'

import { updateTag } from 'next/cache'

export async function updateUserProfile(userId, newData) {
  await db.users.update({ where: { id: userId }, data: newData })

  // Immediately invalidate user data cache
  updateTag(`user-${userId}`)
}

Difference from revalidateTag

DimensionrevalidateTagupdateTag
Invalidation methodMark as expired, update in background on next visitImmediately delete cache
Next visitReturn old data, fetch new in backgroundBlocking wait, fetch new data
Usage restrictionCan use anywhereOnly in Server Actions
Use casesGeneral scenarios, pursue speed”Read your own writes” scenarios

What is “read your own writes”?

Suppose user modifies nickname on profile page, after clicking save, page should immediately show new nickname, not still show old (then wait for background update).

This scenario uses updateTag:

export async function updateProfile(formData) {
  const userId = getCurrentUserId()

  await db.users.update({
    where: { id: userId },
    data: { nickname: formData.get('nickname') }
  })

  // Immediately invalidate, ensure next read sees latest data
  updateTag(`user-${userId}`)

  revalidatePath('/profile')
}

New: use cache directive (Next.js 15)

Explicitly declare caching

Next.js 15 introduces new 'use cache' directive, can explicitly mark which functions should be cached.

Usage

'use cache'

export async function getPopularPosts() {
  const posts = await db.posts.findMany({
    orderBy: { views: 'desc' },
    take: 10
  })
  return posts
}

Use with cacheTag

import { unstable_cacheTag as cacheTag } from 'next/cache'

'use cache'

export async function getPostsByAuthor(authorId) {
  cacheTag('posts', `author-${authorId}`)

  return await db.posts.findMany({
    where: { authorId }
  })
}

Then can invalidate with revalidateTag:

revalidateTag(`author-${authorId}`)

Why is this needed?

In Next.js 15, since fetch defaults to no caching, if you don’t want to re-query database every time, you need to explicitly declare caching. use cache makes your intent clearer.

Common Issues Troubleshooting Guide

Theory covered. Now for the most practical part: how to troubleshoot when cache goes wrong.

I’ll list the four most common problems, each with troubleshooting steps and solutions.

Problem 1: revalidate set but doesn’t work

Symptoms

You set export const revalidate = 60, but 10 minutes later the page still shows old data.

Troubleshooting steps

1. Confirm testing in production environment

This is the most common misconception. Dev environment (npm run dev) disables most caching.

Must test like this:

npm run build
npm start

2. Check if route is dynamically rendered

Run npm run build, look at terminal output:

Route (app)                Size
├ ○ /blog                  1 kB    ← Static, will cache
└ ƒ /profile               2 kB    ← Dynamic, won't cache

If your route is ƒ (dynamic), then revalidate won’t work at all, because dynamic routes don’t cache.

Code that might cause dynamic rendering:

// These will make route dynamically rendered
import { cookies } from 'next/headers'
import { headers } from 'next/headers'

export default function Page({ searchParams }) {  // Uses searchParams
  const cookieStore = cookies()  // Uses cookies
  // ...
}

Solutions:

  • If don’t need dynamic rendering, remove this code
  • If needed, don’t expect revalidate to work, use on-demand revalidate

3. Check Next.js version

Next.js 14 and 15 have different default behaviors. If you just upgraded to 15, many things that were cached before now aren’t.

Solutions (Next.js 15):

// Explicitly enable caching
fetch(url, {
  cache: 'force-cache',
  next: { revalidate: 60 }
})

// Or use use cache directive
'use cache'
export async function getData() {
  // ...
}

4. Check if interfered by Router Cache

Even if server data updated, client’s Router Cache might still have old data cached.

Solutions:

  • Hard refresh (Ctrl+Shift+R)
  • Or configure shorter staleTimes in Next.js 15

Problem 2: Data updated but page still shows old data

Symptoms

You manually changed data in database, or called revalidatePath, but refreshing page still shows old data.

Troubleshooting approach: Check four cache layers

Layer 1: Router Cache (Client)

Easiest to overlook is client cache.

Quick test:

  • Press Ctrl+Shift+R for hard refresh
  • If data updates, it’s Router Cache problem

Solutions:

'use client'

import { useRouter } from 'next/navigation'

export function RefreshButton() {
  const router = useRouter()

  return (
    <button onClick={() => router.refresh()}>
      Refresh
    </button>
  )
}

Or configure shorter cache time in next.config.js:

module.exports = {
  experimental: {
    staleTimes: {
      dynamic: 0,    // Disable dynamic route cache
      static: 30,    // Static route cache 30 seconds
    },
  },
}

Layer 2: Full Route Cache (Server)

Check if route is static. If static route, entire HTML is cached.

Quick test:

# Open new incognito window to visit page
# If still old data, it's server cache

Solutions:

'use server'

import { revalidatePath } from 'next/cache'

export async function updateData() {
  await db.update({ ... })

  // Clear route cache
  revalidatePath('/your-page')
}

Layer 3: Data Cache (Server)

Check fetch request configuration.

Quick test:

Add timestamp log in fetch:

const data = await fetch(url)
console.log('Fetched at:', new Date().toISOString())

Refresh page, if timestamp doesn’t change, using cache.

Solutions:

Method 1: Tag data, then invalidate

// When fetching data
const data = await fetch(url, {
  next: { tags: ['my-data'] }
})

// When updating data
revalidateTag('my-data')

Method 2: Temporarily disable cache for testing

const data = await fetch(url, {
  cache: 'no-store'  // Don't cache
})

Layer 4: Request Memoization (Server)

This layer usually isn’t a problem, because it only lasts for a single request. If first three layers are fine, it’s not a cache problem, might be the data source itself.

Problem 3: revalidatePath vs revalidateTag - which to use?

Decision tree

Need to invalidate cache
    |
    ├─ Only affects one page
    |      → Use revalidatePath('/specific-page')
    |
    ├─ Affects all pages under a path
    |      → Use revalidatePath('/blog', 'layout')
    |
    ├─ Data used by multiple pages at different paths
    |      → Use revalidateTag('your-tag')
    |
    └─ Need immediate invalidation (user sees update right after edit)
           → Use updateTag('your-tag') (Next.js 15)

Real-world case: Blog system

Suppose your blog system has these pages:

  • Homepage: Shows latest 3 posts
  • Blog list page /blog: All posts
  • Post detail page /blog/[slug]: Single post
  • Author page /author/[id]: Author’s posts

Tagging strategy:

// Tag when fetching data
async function getPosts() {
  return fetch('https://api.example.com/posts', {
    next: {
      revalidate: 3600,
      tags: ['posts']  // Tag all post-related data
    }
  })
}

async function getPostBySlug(slug) {
  return fetch(`https://api.example.com/posts/${slug}`, {
    next: {
      revalidate: 3600,
      tags: ['posts', `post-${slug}`]  // Both general and specific tags
    }
  })
}

async function getPostsByAuthor(authorId) {
  return fetch(`https://api.example.com/posts?author=${authorId}`, {
    next: {
      revalidate: 3600,
      tags: ['posts', `author-${authorId}-posts`]
    }
  })
}

When publishing new post:

export async function publishPost(formData) {
  await db.posts.create({ ... })

  // Only need to invalidate 'posts' tag
  // All pages using this tag will update
  revalidateTag('posts')
}

When modifying specific post:

export async function updatePost(slug, newData) {
  await db.posts.update({ where: { slug }, data: newData })

  // Only invalidate this post's related cache
  revalidateTag(`post-${slug}`)

  // Or, if you want to update list page too
  revalidateTag('posts')
}

Problem 4: Cache stops working after upgrading from Next.js 14 to 15

Symptoms

After upgrading to Next.js 15, data that was nicely cached before now refetches every time, performance tanks.

Reasons

Next.js 15’s three major caching default behavior changes:

  1. fetch default changed from force-cache to no-store
  2. GET route handlers default no caching
  3. Router Cache default disabled

Migration solutions

Solution 1: Explicitly enable caching (recommended)

// Before (Next.js 14)
const data = await fetch(url)

// Now (Next.js 15) - need explicit declaration
const data = await fetch(url, {
  cache: 'force-cache',
  next: { revalidate: 3600 }
})

Solution 2: Use use cache directive

'use cache'

export async function getPostList() {
  const posts = await db.posts.findMany()
  return posts
}

Solution 3: Enable Router Cache

// next.config.js
module.exports = {
  experimental: {
    staleTimes: {
      dynamic: 30,
      static: 180,
    },
  },
}

Compare before and after migration:

// Next.js 14 - Implicit caching
export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts')
  // Auto cached
}

// Next.js 15 - Explicit caching
'use cache'  // Add this line

export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts', {
    cache: 'force-cache',  // Or add this
    next: { revalidate: 3600 }
  })
}

My suggestion:

Don’t expect one-click migration. Carefully review every data request, consciously decide what needs caching and what doesn’t. It’s tedious, but healthier long-term - you’ll know exactly what data in your system is cached and what isn’t.

Best Practices and Selection Strategy

After all that, let’s summarize: when to use which caching strategy.

Caching Strategy Selection Flowchart

How often does your data update?
    |
    ├─ Almost never changes (like about page, help docs)
    |      → Static generation, don't set revalidate
    |      → Redeploy manually when changed
    |
    ├─ Regular updates (like hourly, daily)
    |      → ISR + time-based revalidate
    |      → export const revalidate = 3600
    |
    ├─ Irregular updates (like user-published content)
    |      → On-demand revalidate
    |      → revalidatePath or revalidateTag
    |
    └─ Real-time updates (like chat, real-time data)
           → Dynamic rendering + cache: 'no-store'
           → Don't cache

Tag Naming Strategy

If you choose to use revalidateTag, recommended naming convention:

Granularity design

  • Coarse-grained (suitable for batch invalidation)

    • posts - All posts
    • products - All products
    • users - All users
  • Medium-grained (by category/status)

    • posts:published - Published posts
    • posts:draft - Drafts
    • products:category:electronics - Electronics products
  • Fine-grained (specific resources)

    • post:id:123 - Post with ID 123
    • user:profile:456 - User profile with ID 456

Naming convention recommendation

Use namespacing, format as entity:type:id:

// When fetching data
const post = await fetch(`/api/posts/${id}`, {
  next: {
    tags: [
      'posts',                    // Coarse-grained
      'posts:published',          // Medium-grained
      `post:id:${id}`             // Fine-grained
    ]
  }
})

// Can choose different granularities when invalidating
revalidateTag('posts')              // Invalidate all posts
revalidateTag('posts:published')    // Only invalidate published
revalidateTag(`post:id:${id}`)      // Only invalidate specific post

Performance Optimization Tips

1. Don’t over-cache

Caching isn’t the more the better. Too much caching brings these problems:

  • Data inconsistency
  • Difficult debugging
  • Storage space waste

Rule of thumb:

  • User personalized data (like cart, personal settings) → Don’t cache
  • Public data (like product lists, post lists) → Cache
  • Real-time data (like inventory, online users) → Don’t cache or very short cache

2. Reasonable revalidate time

Don’t set too short a time. If you set revalidate: 1, meaning page might regenerate every second, this doesn’t serve caching purpose at all.

Recommended settings:

  • News: 30-60 minutes
  • Blog posts: 1-2 hours
  • Product catalog: 2-4 hours
  • Static pages: 24 hours or longer

3. Use stale-while-revalidate

Next.js 15’s profile="max" is this strategy:

revalidateTag('posts', { profile: 'max' })

Users always see cached content (fast!), system quietly updates cache in background. This is optimal user experience.

4. Monitor cache hit rate

Add to .env.local:

NEXT_PRIVATE_DEBUG_CACHE=1

Then run production server, console shows cache hit status:

○ GET /blog 200 in 45ms (cache: HIT)
○ GET /about 200 in 12ms (cache: SKIP)

Regularly check to see if your caching strategy is effective.

Dev vs Production Environment Differences

Key reminder:

Dev environment (npm run dev) and production environment (npm start) have completely different caching behaviors.

FeatureDev EnvironmentProduction Environment
Data CacheMostly disabledFully enabled
Full Route CacheDisabledStatic routes enabled
Request MemoizationEnabledEnabled
Router CacheEnabled but very shortFull duration

Correct way to test caching:

# 1. Build production version
npm run build

# 2. Check terminal output, confirm route type
#    ○ = Static, will cache
#    ƒ = Dynamic, won't cache

# 3. Start production server
npm start

# 4. Test caching behavior
# Open browser, visit page, then modify data source
# Refresh page, check if still old data

# 5. Test revalidate
# Wait for revalidate time to expire, visit again, see if updated

Don’t debug caching problems in dev environment. This is the most common trap, I’ve seen too many people mess around in dev environment for hours, then complain revalidate doesn’t work.

Conclusion

After all that, the core is just a few points:

1. Understand four-layer cache responsibilities

Don’t confuse them. Router Cache is client-side, the other three are server-side. revalidate mainly affects Data Cache and Full Route Cache.

2. Choose appropriate revalidate method

  • Timed updatesexport const revalidate = 3600
  • User-triggered, single pagerevalidatePath('/page')
  • User-triggered, multiple pagesrevalidateTag('tag')
  • Immediate invalidationupdateTag('tag') (Next.js 15)

3. Test in production environment

Always remember: dev environment caching behavior is inaccurate. To test caching, must npm run build && npm start.

4. Troubleshoot layer by layer

When data won’t update, check in this order:

  1. Router Cache (client) → Try hard refresh
  2. Full Route Cache (server) → revalidatePath
  3. Data Cache (server) → revalidateTag
  4. Data source itself

5. Explicit over implicit (Next.js 15 philosophy)

Next.js 15 changed caching from default enabled to default disabled. This means you need to actively think about what data needs caching. It’s tedious, but healthier long-term - your code is clearer and easier to maintain.


Finally, if you’ve read this far, you now have a complete understanding of Next.js’s caching mechanism. Next time you encounter data not updating, you should know where to start.

Caching is complex, but once you understand it, you’ll find it’s one of Next.js’s most powerful features. Use caching wisely and your app will be lightning fast; cache randomly and you’re digging your own grave.

May your app’s performance be off the charts and bugs be zero!

FAQ

What are the four layers of Next.js caching?
1. Request Memoization:
• Deduplicates same fetch in one request
• Only lasts for the request duration

2. Data Cache:
• Caches fetch responses
• Persists across requests

3. Full Route Cache:
• Caches entire rendered page
• For static generation

4. Router Cache:
• Client-side route cache
• Temporary cache for navigation
What's the difference between revalidatePath, revalidateTag, and updateTag?
revalidatePath:
• Invalidates cache by path
• Use when you know the exact path

revalidateTag:
• Invalidates cache by tag
• Use when multiple paths share same tag

updateTag:
• Updates tag without revalidation
• Use to mark data as stale without immediate revalidation
Why doesn't revalidate work?
Possible causes:
• Testing in dev mode (caching disabled)
• Router Cache interfering
• Wrong cache layer being targeted
• revalidate time hasn't passed yet

Solutions:
• Test in production mode
• Use revalidatePath/revalidateTag
• Clear Router Cache
• Check which cache layer is causing issue
How do I troubleshoot data not updating?
Step-by-step troubleshooting:
1) Check if testing in dev mode (caching disabled)
2) Check which cache layer is causing issue
3) Use revalidatePath to invalidate specific path
4) Use revalidateTag if using tags
5) Clear Router Cache if client-side issue
6) Check Full Route Cache if static generation

Use Next.js build output to see which pages are cached.
What changed in Next.js 15 caching?
Next.js 15 defaults to no caching for fetch.

Changes:
• Need to explicitly enable caching with force-cache or revalidate
• Makes code clearer and easier to maintain
• Requires active thinking about what needs caching

This is a breaking change from Next.js 14.
How do I use revalidateTag?
Steps:
1) Add tag to fetch: fetch(url, { next: { tags: ['posts'] } })
2) Revalidate by tag: revalidateTag('posts')

This invalidates all fetches with that tag, useful when multiple paths use same data source.
When should I use each revalidate method?
revalidatePath:
• When you know exact path to invalidate
• Simple and direct

revalidateTag:
• When multiple paths share same data
• More flexible

updateTag:
• When you want to mark as stale without immediate revalidation
• For background updates

16 min read · Published on: Dec 19, 2025 · Modified on: Jan 22, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts