Next.js Caching Explained: The 4 Cache Layers and When Each Breaks
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:
- Request Memoization — Deduplicates requests in a single render
- Data Cache — Persists fetched data across requests
- Full Route Cache — Caches the rendered result of a route
- 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)vsfetchUser(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 setrevalidate: 0is set (static rendering)- The
revalidateperiod 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 setrevalidate = 0is 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
-
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') -
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:
- Request Memoization — Automatic deduplication within a single render
- Data Cache — Persists fetched data across requests
- Full Route Cache — Caches rendered routes
- Router Cache — Client-side pre-fetching
When debugging cache issues:
- Check which cache layer is involved
- Verify the cache isn't being bypassed
- Ensure cache invalidation is working
- Use debug tools to inspect cache status
Best practices:
- Cache static data aggressively
- Don't cache user-specific data
- Use tags for granular invalidation
- 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...