
Next.js Caching: A Frontend-Driven Caching Strategy
Next.js Caching: A Frontend-Driven Caching Strategy
Frontend Engineers Should Talk About Caching Too
Walk into any load testing or caching strategy meeting, and it’s usually the backend engineers who bring up API caches and CDN layers first. As a frontend developer, I’ve always found this a bit frustrating.
There are plenty of caching techniques on the client and frontend server side that can distribute traffic and improve user experience. I really wanted to share with my team just how powerful frontend caching can be as a weapon.
The Next.js we work with is not just a rendering tool. It has a multi-layered cache system built in — from browser memory all the way to the frontend server’s disk — and properly leveraging this architecture is at the core of frontend-driven system design.
In this post, I’ll break down the caching mechanisms in Next.js 15 and 16, then share a practical caching architecture example. It’s based on real requirements I’ve encountered, combined with some hypothetical scenarios to form a cohesive example.

Why You Need to Understand Caching Even Though the Framework “Handles It”
If you look at the Caching section in the Next.js official docs, you’ll find this statement:
“This page provides a good foundation for understanding how Next.js works under the hood but is not essential knowledge to be productive with Next.js. Most of Next.js’ caching heuristics are determined by your API usage and have defaults for the best performance with zero or minimal configuration.” — Next.js Official Documentation - Caching
The key takeaway is: “We’ve built in optimal caching so you can focus on business logic.”
But in real-world production, things are different. If all you care about is raw performance gains, then sure, let the framework defaults handle it. The problem arises in business environments where you need to show users fresh data at the right time.
You need to understand which caches are in play, and you need to know how to declare and invalidate them to meet your business requirements.
In this post, I’ll split the cache layers into two categories: “caches you can leave to the framework” and “caches you must control through code.”
| Cache Layer | Control Needed | Storage Location | Invalidation Method |
|---|---|---|---|
| Request Memoization | Leave to framework | Server memory (during render) | Auto-expires |
| Data Cache | Manual control | Server disk / Redis | revalidateTag, revalidatePath |
| Full Route Cache | Manual control | Server disk | Cascading refresh on Data Cache invalidation |
| Router Cache | Manual control | Browser memory | router.refresh(), staleTimes config |
Leave It to the Framework: Request Memoization
Within a single rendering cycle, identical GET requests with the same URL and options are automatically deduplicated. No matter where in the component tree you call the same fetch, only one actual network request fires — the rest reuse the cached response from memory. Once rendering completes, the cache is gone, so no manual invalidation is needed.
This isn’t a Next.js-specific feature — it’s a capability React provides by extending the fetch API.
Comparison with the Pages Router Era
In the Pages Router days, if you wanted to fetch server data, you had to call it in getServerSideProps or getStaticProps and pass it down as props or distribute it via Context. Even when child components needed the same data, you had to fetch it at the root and props-drill or wire up a state management library.
As shown in the diagram below, even if different parts of the component tree call Request A, B, and C independently, the Memoized Requests ultimately execute A, B, and C only once each.

With App Router’s Memoization, each component can fetch the data it needs directly. If it’s the same request, it only fires once anyway.
In Code
// api.ts
export async function getUser(id: string) {
// React intercepts identical URL requests and executes only once.
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
// Layout.tsx (top-level)
export default async function Layout() {
const user = await getUser('123'); // Actual API call (1 time)
return <Profile />;
}
// Profile.tsx (child component)
export default async function Profile() {
const user = await getUser('123'); // Instant return from memory
return <div>{user.name}</div>;
}
Since each child component can independently declare the same fetch without penalty, component cohesion improves significantly.
Control It Yourself — Layer 1: Data Cache
This layer stores fetched data on the server (disk/Redis) and reuses it across user requests. It’s the first line of defense for reducing frontend server load.
The Philosophical Shift in v15/16
In v14, fetch calls were automatically cached (force-cache) by default. Starting with v15, the default changed to no-store (Dynamic by Default). Without an explicit cache option, every call fetches fresh data.
Declaring Cache Behavior
Use the next object in fetch options to declare caching duration and tags.
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: {
revalidate: 3600, // Time-based: refresh in the background every hour
tags: ['post-list'] // On-demand: tag for manual cache invalidation
}
});
return res.json();
}
Invalidating the Cache
When a post is created or updated, call invalidation from Server Actions or API Routes.
'use server'
import { revalidateTag, revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
await db.posts.insert(...);
// Trigger Data Cache invalidation
revalidateTag('post-list'); // Purge all caches tagged with 'post-list'
// revalidatePath('/posts'); // Alternatively, purge by route path
}
Considerations for Multi-Server Environments
On Vercel, the framework handles global cache invalidation. But when running multiple frontend servers on your own infrastructure (AWS ECS, EKS, etc.), you need to be careful.
By default, Next.js Data Cache is stored on each server’s local disk. If you call revalidateTag on Server A, the local caches on Server B and C remain stale. Depending on load balancing, different users may see different outdated data.
The solution is to implement a Custom Cache Handler that uses a centralized store like Redis. This ensures all instances share the same cache and can be invalidated uniformly.

As shown above, without a shared cache, Instances A, B, and C each independently request from the backend and store locally. Even if A invalidates its cache, B and C still retain stale data.
The ideal fix is to place Redis between instances and use a Pub/Sub pattern: publish revalidate events → each instance subscribes and invalidates its cache simultaneously. However, this introduces operational complexity — additional Redis infrastructure, Pub/Sub subscription management, and fallback handling during outages. In our current environment, we’ve opted to rely on TTL-based defense rather than absorbing this complexity.
Control It Yourself — Layer 2: Full Route Cache
If Data Cache caches “data fragments,” Full Route Cache stores the “entire page render output (HTML + RSC Payload)” on the server disk. Think of it as an evolution of SSG from the Pages Router era.
How Is This Different from SSG?
The RSC Payload: It caches not just HTML but also the React Server Component Payload. When navigating via links on the client, only this payload is fetched — enabling smooth partial rendering without a full page load.
Integration with PPR: You don’t need to statically freeze an entire page. Cache only the unchanging layout shell, and slot in dynamic areas (like a user’s shopping cart) at request time.
Connection to Data Cache: When Data Cache is invalidated via revalidateTag, the Full Route Cache referencing that data is immediately rebuilt on the server.
When Full Route Cache Breaks (Auto Opt-out)
Next.js tries to statically render as many pages as possible at build time and store them in the Full Route Cache. But a single line of developer code can silently disable it.
The Dynamic API Trap: The moment any component in the page tree calls cookies(), headers(), or reads the searchParams prop, Next.js decides “this page varies per request” and abandons Full Route Cache.
Missing Params in Dynamic Routes: For dynamic routes (app/posts/[id]/page.tsx) without generateStaticParams, the default is dynamic rendering at request time.
Why Code Review Matters: A blazing-fast statically-built page can lose its cache the moment a colleague adds a “check current user cookie” call. If there’s an agreement that “this landing page must always hit the static cache,” you need to track whether Dynamic APIs creep in during reviews.
Control It Yourself — Layer 3: Router Cache (Client Memory)
What Gets Cached
Router Cache stores RSC Payload in browser memory. When a user clicks a <Link> and navigates to another page, the React Server Component Payload (the rendering result for that page) received from the server is stored in browser memory. On revisiting the same page or pressing back, the stored Payload is used to render instantly without re-requesting from the server.
In simple terms, it’s “a cache where the browser remembers and reuses the rendering results of previously visited pages.”
Why This Causes Problems
In production, most bug reports saying “I pressed save but I’m still seeing the old screen!” are caused by Router Cache. Even if you invalidate Data Cache on the server with revalidateTag, if the old RSC Payload remains in browser memory, the user sees stale content. The server already has fresh data, but the browser is showing a cached previous render.
staleTimes: How Long Does It Cache?
How long Router Cache keeps the stored RSC Payload depends on the page type:
- Static Routes (static pages): Cached for 5 minutes (300 seconds). Revisiting within 5 minutes shows instantly without a server request
- Dynamic Routes (dynamic pages): Was 30 seconds in v14, but changed to 0 seconds in v15/16. Always fetches the latest Payload from the server
Static/Dynamic here refers to how Next.js classified the page at build time. Pages pre-built with generateStaticParams are Static; pages using cookies() or searchParams are Dynamic.
Relationship with Prefetch
The <Link> component automatically prefetches the RSC Payload of target pages when they enter the viewport. This prefetched data also goes into Router Cache. If you explicitly set <Link prefetch={true}>, even Dynamic pages are force-prefetched and cached — so be cautious on pages where data freshness matters.
Invalidating the Cache
Use this in client components when you need to manually refresh the view.
'use client'
import { useRouter } from 'next/navigation';
export default function RefreshButton() {
const router = useRouter();
const handleRefresh = () => {
// Discard the browser's Router Cache (RSC Payload) and re-request fresh data from server
// React state is preserved while server data is refreshed
router.refresh();
};
return <button onClick={handleRefresh}>Fetch Latest Data</button>
}
Note: When revalidatePath is called in a Server Action, Next.js sends a signal to the client browser to clear its Router Cache as well.
Real-World Example: Caching Architecture for a Multilingual Event Page
Here’s where it gets practical. Let’s apply the cache layers discussed above to an actual event page. This example is based on real requirements combined with some hypothetical scenarios.
Requirements
- Traffic: Thousands of requests per second when the event launches
- BO (Back Office) update frequency: The operations team modifies event banners, product lists, and winner info dozens of times per day
- Multilingual support: At least 3 languages (Korean, Japanese, English). URL structure:
/ja/events/[id],/ko/events/[id] - Personalization areas: User-specific participation status, coupon state, entry count
- Infrastructure constraints: Multiple pods on AWS ECS, no centralized cache store (Redis, etc.) in place

The Design Decision — TL;DR
Given these conditions, here’s what we decided:
- Full Route Cache → Abandoned. Too many locale × event combinations, and BO changes are too frequent for build-time static generation to be practical
- On-demand Revalidation → Unreliable. Without Redis in a multi-pod environment, only one pod gets invalidated while the rest remain stale
- Data Cache with Time-based TTL → Primary strategy. 30-second TTL ensures uniform refreshes across all pods, reducing backend calls by 50–60%
- Personalization areas → Isolated with Suspense. Render cacheable static areas first, then stream in user-specific data
Let me walk through how we arrived at this conclusion.
Splitting the Page into Static and Dynamic Zones

The static zones on the left (banners, event descriptions, product lists) can be server-cached and only need refreshing when BO makes changes. The dynamic zones on the right (user participation status, coupon state, real-time stock/entries) vary per user, so they can’t be cached and require API calls every time.
How you reflect this separation at the code level is the core of the design.
Why We Abandoned Full Route Cache
The event detail page seems like a great candidate for Full Route Cache at first glance — just pre-build event IDs with generateStaticParams.
In a multilingual environment, things are different:
- You’d need to build every
locale × eventIdcombination → build time explodes as events accumulate - Event info changes constantly from BO → already stale immediately after build
- Personalization areas require
cookies()and other Dynamic API calls → automatically switches to Dynamic Rendering
// app/[locale]/events/[id]/page.tsx
export default async function EventPage({ params }: Props) {
const { locale, id } = await params;
// The moment you reference locale-based Accept-Language headers or cookies
// → Switches to Dynamic Rendering
const event = await getEvent(id, locale);
// ...
}
Full Route Cache simply doesn’t work for this page.
Why We Can’t Trust On-demand Revalidation
In an ideal scenario: BO edit → webhook → revalidateTag → instant update. In our environment, this breaks down:
- Webhook hits only one pod through the load balancer → other pods’ caches remain untouched
- Sending individual webhooks to every pod is possible, but with autoscaling, pod count fluctuates and target management becomes complex
- No fundamental fix until Custom Cache Handler + Redis is implemented
We still apply tags proactively. When Redis is eventually introduced, On-demand Revalidation will work without any code changes.
The Remaining Option: Time-based TTL Design
By process of elimination, we’re left with Data Cache’s Time-based Revalidation. We set TTLs based on data change frequency and acceptable business delay.
| Item | revalidate | Rationale |
|---|---|---|
| Event basic info (title, banner, product list) | 30s | High BO change frequency, max 30s delay SLA |
| Comments/reviews | 300s | Low change frequency, 5-minute delay acceptable |
| Stock/entry count | no-store | Real-time accuracy required |
| User personalization (participation, coupons) | no-store | Varies per user, not cacheable |
In code:
// lib/api/event.ts
export async function getEvent(id: string, locale: string) {
const res = await fetch(`${API_BASE}/events/${id}?lang=${locale}`, {
next: {
revalidate: 30, // 30-second TTL
tags: [`event-${id}`, `event-${id}-${locale}`] // For future Redis integration
}
});
return res.json();
}
export async function getEventComments(id: string, locale: string) {
const res = await fetch(`${API_BASE}/events/${id}/comments?lang=${locale}`, {
next: {
revalidate: 300, // 5-minute TTL
tags: [`event-comments-${id}`]
}
});
return res.json();
}
export async function getEventStock(id: string) {
// Stock is never cached
const res = await fetch(`${API_BASE}/events/${id}/stock`, {
cache: 'no-store'
});
return res.json();
}
What a 30-second TTL means:
- At 3,000 requests/second, each pod calls the backend once per 30 seconds. 89,999 requests are cache hits
- Maximum delay for BO changes to reflect: 30 seconds (uniform across all pods)
- Trade-off: Shorter TTL = more frequent backend calls
Isolating Personalization with Suspense
Static areas respond instantly from Data Cache while dynamic areas stream in later.
// app/[locale]/events/[id]/page.tsx
import { Suspense } from 'react';
export default async function EventPage({ params }: Props) {
const { locale, id } = await params;
const event = await getEvent(id, locale); // Data Cache hit (30s TTL)
return (
<main>
{/* Static zone - renders instantly from cache */}
<EventBanner event={event} />
<EventDescription description={event.description} />
{/* Dynamic zone - streams in later */}
<Suspense fallback={<StockSkeleton />}>
<EventStock eventId={id} />
</Suspense>
<Suspense fallback={<ParticipationSkeleton />}>
<ParticipationStatus eventId={id} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<EventComments eventId={id} locale={locale} />
</Suspense>
</main>
);
}
From the user’s perspective, banners and descriptions appear first, while stock and participation status fill in shortly after. Perceived loading feels faster, and cacheable areas definitively hit the cache.
How Is This Different from Backend Caching?
You might wonder: “Wouldn’t caching with Redis on the backend achieve the same result?” In terms of data freshness, sure — it’s similar. But frontend Data Cache offers distinct advantages.
1. Network round-trips disappear — Even if the backend gets a 1ms Redis cache hit, the frontend → backend request (DNS, TCP, serialization, transfer) still costs several to tens of milliseconds each time. Frontend local cache makes that 0ms.
2. Backend connection load is reduced — Handling thousands of TCP connections per second is expensive by itself. When the frontend absorbs that traffic, the backend can focus resources on truly dynamic requests (stock checks, entry processing).
3. Rendering costs are also saved — Backend cache only stores raw data. Frontend Data Cache reuses both the fetch response and the RSC rendering result simultaneously.
4. Per-context TTL separation is possible — The same API can have a 30-second TTL on the event main page and a 5-minute TTL on the listing page, depending on where it’s called.
5. It acts as a buffer during backend outages — If the backend goes down but cached data is still within TTL, users still get a valid response.
Frontend caching isn’t a replacement for backend caching — it’s an additional defense layer stacked in front.
Actual Server Load Impact
Without caching
- 3,000 requests/second × 3 APIs = 9,000 backend calls per second
- Frontend server also performs RSC rendering every time → high CPU load
With Data Cache (3 pods)
- Event basic info: 30s TTL → 1 call per pod per 30s. With 3 pods, that’s 3 calls total per 30s
- Comments: 300s TTL → 1 call per pod per 5 minutes
- Stock/count: Still called every time
- Result: ~50–60% reduction in backend API calls
Since each pod maintains its own local cache, there’s duplication proportional to pod count. But going from 3,000 calls/second to 3 calls per 30 seconds is a massive win — and when Redis is eventually added, even that duplication collapses to 1.
Summary
If you’re working in a Next.js environment, there are multiple cache layers you can directly control — from the browser (Router Cache) to the frontend server (Data Cache). Even in environments like a multilingual event page where Full Route Cache isn’t viable and On-demand Revalidation is limited without Redis, the combination of Time-based Data Cache + Suspense streaming alone can absorb over 50% of backend load at the frontend layer.
Of course, the limitations are clear. In the current setup, BO changes can take up to the full TTL to reflect, and caches are duplicated across pods. These are problems to solve with Custom Cache Handler + Redis in the future.
The key point is that even without Redis, frontend caching alone can already defend against significant traffic. When you understand the caching system and design it properly, you can achieve both performance and great user experience simultaneously.
댓글
새 글 알림 받기
실무에서 바로 써먹을 수 있는 개발 팁과 경험담을 받아보세요
개인정보는 뉴스레터 발송 목적으로만 사용되며, 언제든 구독을 해지할 수 있습니다.