Back to Blog
August 19, 20268 min read1 views

Next.js Caching Explained: The 4 Cache Layers and When Each Breaks

Next.jsTypeScriptWeb Development
JL
Written by Julius Legaspi
LinkedInGitHub
Share

Next.js Caching Explained: The 4 Cache Layers and When Each Breaks

Introduction

Caching in Next.js is powerful but confusing. With four different cache layers, automatic caching behaviors, and various revalidation strategies, it's easy to get lost.

After debugging caching issues in production applications, I've developed a clear mental model for understanding how each cache layer works and when it breaks.

The 4 Cache Layers

Next.js has four distinct cache layers, each serving a different purpose:

  1. Request Memoization — Deduplicates requests in a single render
  2. Data Cache — Persists fetched data across requests
  3. Full Route Cache — Caches the rendered result of a route
  4. Router Cache — Client-side cache for pre-fetched routes

Let me explain each layer in detail.

Layer 1: Request Memoization

What it does: Deduplicates identical requests made during a single render.

When it's useful: When multiple components fetch the same data.

How it works:

// Both components fetch the same user
async function Header() {
  const user = await fetchUser(userId) // Request 1
  return <nav>{user.name}</nav>
}

async function Profile() {
  const user = await fetchUser(userId) // Request 2 (deduplicated!)
  return <div>{user.email}</div>
}

In this example, fetchUser(userId) is only called once, even though it appears in two components. Next.js automatically deduplicates these requests.

When it breaks:

  • Different parameters: fetchUser(1) vs fetchUser(2)
  • Different fetch options: { cache: 'no-store' } vs { next: { revalidate: 3600 } }
  • Non-GET requests (POST, PUT, DELETE)

Example of it breaking:

async function UserList() {
  // These are NOT deduplicated (different IDs)
  const user1 = await fetchUser(1)
  const user2 = await fetchUser(2)
  return <div>{user1.name}, {user2.name}</div>
}

Layer 2: Data Cache

What it does: Persists fetched data across requests and deployments.

When it's useful: Data that doesn't change frequently.

How it works:

// This data is cached across requests
async function ProductPage({ params }) {
  const product = await fetch(`https://api.example.com/products/${params.id}`, {
    next: { revalidate: 3600 } // Cache for 1 hour
  }).then(r => r.json())
  
  return <ProductDetails product={product} />
}

When it breaks:

  • cache: 'no-store' is set
  • revalidate: 0 is set (static rendering)
  • The revalidate period has elapsed
  • On-demand revalidation is triggered

Example of it breaking:

// This bypasses the Data Cache entirely
async function RealTimeData() {
  const data = await fetch('https://api.example.com/realtime', {
    cache: 'no-store' // Never cache
  }).then(r => r.json())
  
  return <DataDisplay data={data} />
}

Layer 3: Full Route Cache

What it does: Caches the rendered result of a route (HTML + RSC Payload).

When it's useful: Routes that don't depend on user-specific data.

How it works:

// This entire route is cached
export default async function AboutPage() {
  const content = await getAboutContent()
  return <div>{content}</div>
}

When it breaks:

  • Dynamic functions are used: cookies(), headers(), searchParams
  • dynamic = 'force-dynamic' is set
  • revalidate = 0 is set
  • POST requests are made
  • cache: 'no-store' is used in fetch

Example of it breaking:

import { cookies } from 'next/headers'

// This route CANNOT be cached (uses cookies())
export default async function Dashboard() {
  const cookieStore = await cookies()
  const session = cookieStore.get('session')
  // ...
}

Layer 4: Router Cache

What it does: Caches pre-fetched routes on the client side.

When it's useful: Improving navigation performance between routes.

How it works:

When users navigate between routes, Next.js pre-fetches and caches the RSC Payload for those routes. This makes subsequent navigation instant.

When it breaks:

  • router.refresh() is called
  • The cache expires (default: 30 seconds for dynamic routes)
  • User performs a full page refresh
  • cache: 'no-store' is used in fetch

Example of it breaking:

'use client'
import { useRouter } from 'next/navigation'

function RefreshButton() {
  const router = useRouter()
  
  return (
    <button onClick={() => router.refresh()}>
      Refresh Data
    </button>
  )
}

Cache Invalidation Strategies

Time-Based Revalidation

Cache data for a specific duration:

// Cache for 1 hour
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }
})

// Cache for 1 day
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 86400 }
})

On-Demand Revalidation

Revalidate cache when data changes:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const { path, tag } = await request.json()
  
  if (path) {
    revalidatePath(path)
  }
  
  if (tag) {
    revalidateTag(tag)
  }
  
  return Response.json({ revalidated: true })
}

Tag-Based Revalidation

Group related cache entries with tags:

// Fetch with tags
const products = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] }
})

const product = await fetch(`https://api.example.com/products/${id}`, {
  next: { tags: ['products', `product-${id}`] }
})

// Revalidate all product-related cache
revalidateTag('products')

Common Caching Patterns

Pattern 1: Static Data with Periodic Revalidation

For data that changes occasionally:

async function ProductPage({ params }) {
  const product = await fetch(`https://api.example.com/products/${params.id}`, {
    next: { revalidate: 3600 } // Revalidate every hour
  }).then(r => r.json())
  
  return <ProductDetails product={product} />
}

Pattern 2: User-Specific Data (No Caching)

For personalized content:

async function Dashboard() {
  const session = await getSession()
  const userData = await fetch(`https://api.example.com/users/${session.userId}`, {
    cache: 'no-store' // Never cache user-specific data
  }).then(r => r.json())
  
  return <DashboardContent user={userData} />
}

Pattern 3: Real-Time Data

For constantly updating data:

async function LiveMetrics() {
  const metrics = await fetch('https://api.example.com/metrics', {
    cache: 'no-store' // Always fresh
  }).then(r => r.json())
  
  return <MetricsDisplay metrics={metrics} />
}

Pattern 4: Static Generation with On-Demand Updates

For content that changes rarely but needs immediate updates:

// Build-time generation
export const dynamic = 'force-static'

async function BlogPost({ params }) {
  const post = await getPost(params.slug)
  return <PostContent post={post} />
}

// On-demand revalidation when content is updated
// app/api/revalidate/route.ts
export async function POST(request: Request) {
  const { slug } = await request.json()
  revalidatePath(`/blog/${slug}`)
  return Response.json({ revalidated: true })
}

Debugging Cache Issues

Check Cache Status

Use Next.js headers to check cache status:

async function DataComponent() {
  const response = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 }
  })
  
  // Check cache status
  console.log('Cache status:', response.headers.get('x-vercel-cache'))
  console.log('Cache age:', response.headers.get('age'))
  
  const data = await response.json()
  return <DataDisplay data={data} />
}

Force Cache Bypass

Temporarily disable caching for debugging:

// Force fresh data
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store'
})

// Or with specific headers
const data = await fetch('https://api.example.com/data', {
  headers: {
    'Cache-Control': 'no-cache'
  }
})

Use Next.js Debug Tools

Enable debug logging:

# Enable cache debugging
NEXT_DEBUG_CACHE=1 npm run dev

Performance Optimization

Optimize Data Fetching

  1. Fetch only what you need

    // Bad: Fetches all fields
    const user = await fetch('/api/users/1')
    
    // Good: Fetches specific fields
    const user = await fetch('/api/users/1?fields=name,email')
  2. Use parallel fetching

    // Bad: Sequential
    const user = await fetchUser(userId)
    const posts = await fetchPosts(userId)
    
    // Good: Parallel
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchPosts(userId)
    ])

Optimize Cache Duration

Set appropriate cache durations based on data freshness needs:

// Static content (cache for 1 day)
const content = await fetch('/api/content', {
  next: { revalidate: 86400 }
})

// Semi-dynamic (cache for 1 hour)
const products = await fetch('/api/products', {
  next: { revalidate: 3600 }
})

// Dynamic (cache for 1 minute)
const prices = await fetch('/api/prices', {
  next: { revalidate: 60 }
})

// Real-time (no cache)
const metrics = await fetch('/api/metrics', {
  cache: 'no-store'
})

Common Mistakes to Avoid

1. Caching User-Specific Data

Mistake:

async function Profile() {
  const session = await getSession()
  // This caches the same profile for all users!
  const profile = await fetch('/api/profile', {
    next: { revalidate: 3600 }
  })
  return <ProfileCard profile={profile} />
}

Solution:

async function Profile() {
  const session = await getSession()
  const profile = await fetch(`/api/profile?userId=${session.userId}`, {
    cache: 'no-store' // Don't cache user-specific data
  })
  return <ProfileCard profile={profile} />
}

2. Forgetting About Stale Data

Mistake:

// This might show stale data for up to 1 hour
async function ProductPage({ params }) {
  const product = await fetch(`/api/products/${params.id}`, {
    next: { revalidate: 3600 }
  })
  return <ProductDetails product={product} />
}

Solution:

// Use on-demand revalidation when product is updated
async function ProductPage({ params }) {
  const product = await fetch(`/api/products/${params.id}`, {
    next: { revalidate: 3600, tags: [`product-${params.id}`] }
  })
  return <ProductDetails product={product} />
}

// When product is updated, call revalidateTag(`product-${id}`)

3. Not Handling Cache Invalidation

Mistake:

// No cache invalidation strategy
async function createProduct(data) {
  await db.product.create({ data })
  // Cache is now stale!
}

Solution:

async function createProduct(data) {
  await db.product.create({ data })
  revalidatePath('/products')
  revalidateTag('products')
}

Conclusion

Understanding Next.js caching is crucial for building performant applications. The four cache layers work together to optimize performance, but they can cause confusion when they break unexpectedly.

Key takeaways:

  1. Request Memoization — Automatic deduplication within a single render
  2. Data Cache — Persists fetched data across requests
  3. Full Route Cache — Caches rendered routes
  4. Router Cache — Client-side pre-fetching

When debugging cache issues:

  1. Check which cache layer is involved
  2. Verify the cache isn't being bypassed
  3. Ensure cache invalidation is working
  4. Use debug tools to inspect cache status

Best practices:

  1. Cache static data aggressively
  2. Don't cache user-specific data
  3. Use tags for granular invalidation
  4. Test cache behavior in development

The key is to understand that caching is not just about performance — it's about finding the right balance between freshness and performance for your specific use case.


Need help optimizing your Next.js caching strategy? Contact me to discuss your application's performance.

Comments (0)

Loading comments...