Have you tried turning on developer tools and refreshing the website?
Looking at the Network tab, some requests return 200, others return 304. Even if I force refresh (Ctrl+Shift+F5), I see x-vercel-cache: HIT pop up and think, "What is this again?" Have you ever wanted to?
When developing with Next.js, these questions sometimes arise. “I obviously modified the data, but why isn’t it reflected on the screen?”
Is caching really a necessary evil? Or is it because I don't understand it properly?
Why isn't my data changing?
Let's assume you've created a simple product listing page with Next.js 15.
In a development environment (npm run dev), changes to API data are reflected immediately on each refresh.
But in production (npm run build && npm run start)... the changes are not reflected even if you refresh.
What's the difference?
There is an important point here. The default behavior of fetch changed in Next.js 15.
Next.js 14: fetch is cached by default (cache: 'force-cache')
Next.js 15: fetch is not cached by default (cache: 'no-store')
“Then why doesn’t the data change if caching doesn’t work in v15?”
Starting with these questions, we'll slowly dive into the intricacies of Next.js caching. Just to mention, fetch caching is just one of four caching mechanisms that Next.js has.
Network response codes 304 and 200
First, let's experiment with how the browser caches.
You've probably noticed these differences:
General Refresh: Some requests have status 304 Not Modified
Force Refresh: All requests have 200 OK status
What is 304?
304 Not Modified means the server is saying “It’s the same version you have, use that!”
code
Browser: "I have this file, version 2025-08-24"
Server: "Checked it, nothing changed. I'll return 304."
Browser: "Then I'll use it from the cache."
At this time, no actual data is transmitted. This is why the Size in the Network tab is as small as 0.1KB.
Web Caching Basics: Why Do You Need It?
What would happen if there was no caching on the web?
code
User: Naver logo please (3MB)
Server: Naver logo delivery (3MB transfer)
User: (0.1 seconds later) Please give me the Naver logo again.
Server: Naver logo delivered (also 3MB transferred)
How inefficient would it be to transmit like this every time?
Understanding the convenience store analogy
Caching is similar to convenience stores
Headquarters (Server): Original storage of all products
Convenience store (CDN): Store frequently used products in the neighborhood
Fridge (Browser Cache): Store frequently eaten items at home
The pros and cons of each level are listed below.
Refrigerator: The fastest, but only I can use it
Convenience store: shared with local people, pretty fast
Headquarters: Always has the latest products, but is slow due to the distance
Cache verification: How do you know it's "unchanged"?
URL
Status
Size
/logo.png
200
3.2MB
/style.css
200
45KB
/script.js
200
128KB
304 Not Modified: 브라우저 캐시를 사용합니다. 실제 데이터는 전송되지 않아 Size가 작습니다.
As you can see in the simulator above, a 304 response involves significantly
less data transfer. But how does the server know that the file hasn't changed?
ETag and Last-Modified
Browsers and servers check file versions in two ways:
Last-Modified / If-Modified-Since
code
First request:
Server → Browser: “Last-Modified: 2025-08-24 10:00:00”
Re-request:
Browser → Server: “If-Modified-Since: 2025-08-24 10:00:00”
Server: (Check file modification time) "304 Not Modified"
ETag / If-None-Match
code
First request:
Server → Browser: "ETag: abc123" (hash value of file contents)
Re-request:
Browser → Server: “If-None-Match: abc123”
Server: (Check current file hash) "304 Not Modified"
Why do we need two methods?
When the date alone is not enough: What if the file was modified and then returned to its original state?
When hash alone is not enough: Cost of reading the entire file each time and calculating the hash
CDN Cache: Why does HIT appear even after a forced refresh?
Now let's go back to the first question. What is the reason why x-vercel-cache: HIT appears even when I force refresh?”
Check the x-vercel-cache value in Response Headers
What did you discover?
HIT: fetched from cache
MISS: not in cache, retrieved from origin server
STALE: It is old, but provided for now, being updated in the background.
Forced refresh only ignores browser cache. But there is another cache layer between the browser and the server. It is CDN (Content Delivery Network).
code
[Browser] ← Ignore force refresh only here → [CDN cache] ← → [Next.js server]
↑
x-vercel-cache: HIT
Why is CDN needed?
What if 100 Korean users request the same page from a US server at the same time?
Without CDN
100 people → Across the Pacific → US server (processed 100 times)
If you have a CDN
1 person → Across the Pacific → US server → Save to Seoul CDN
Remaining 99 people → Seoul CDN responds immediately
Four caching layers in Next.js
Now, let’s take a closer look at Next.js’ caching system. Next.js provides a total of 4 caching mechanisms.
1. Request Memoization
Deduplicates identical requests in the same rendering cycle.
tsx
// What if these components are on the same page?asyncfunctionHeader() {
const user = awaitfetch('/api/user'); // first call// ...
}
asyncfunctionSidebar() {
const user = awaitfetch('/api/user'); // duplication! (0ms, using cache)// ...
}
asyncfunctionMainContent() {
const user = awaitfetch('/api/user'); // duplication! (0ms, using cache)// ...
}
The characteristics of Request Memoization are as follows.
Lifetime: Single rendering cycle (only for one page request)
Scope: Only during server-side rendering
Automatic: Operates automatically without separate settings
Data Cache
The results of the fetch request are stored on the server.
tsx
// Next.js 15 default: no cachingconst data = awaitfetch('https://api.example.com/data');
// Explicitly enable cachingconst data = awaitfetch('https://api.example.com/data', {
cache: 'force-cache', // or next: { revalidate: 3600 }
});
The features of Data Cache are as follows.
Lifetime: depending on settings (unlimited or revalidate time)
Scope: Shared by all users
Change in v15: Default value changed to ‘no-store’
Full Route Cache
Cache the entire HTML generated at build time.
tsx
// This page is built staticallyexportdefaultasyncfunctionStaticPage() {
const data = awaitfetch('...', { cache: 'force-cache' });
return<div>{data}</div>;
}
// This page is dynamic (rendered on every request)exportdefaultasyncfunctionDynamicPage() {
const data = awaitfetch('...', { cache: 'no-store' });
return<div>{data}</div>;
}
The features of Full Route Cache are as follows.
Static pages: HTML generation and caching at build time
Dynamic pages: no caching
fetch option affects the entire page
Router Cache
Cache for client-side navigation.
tsx
// Pages moved to the link are cached in the browser memory.
<Link href="/about">About</Link>
// Why does it load immediately when you go back?
The features of Router Cache are as follows.
Lifetime: For a session (browser memory)
Scope: Individual user
Use: Quick navigation
Practice: When and which cache to use?
Scenario 1: E-commerce site
Let's say you're creating a product listing page. Think about the characteristics of each data.
Product Information: Does not change often
Inventory Quantity: Changes in real time
Reviews: Added occasionally
Recommended Products: Varies by user
상품 정보
상품명, 가격, 설명 등
재고 수량
실시간 재고 현황
Scenario 2: Real-time auction site
The situation is more complicated this time. Both real-time and performance must be considered.
실시간 경매 시뮬레이터
캐싱 전략에 따라 사용자가 보는 가격이 어떻게 달라지는지 확인해보세요
실제 입찰가
₩100,000
사용자가 보는 가격
₩100,000
최근 입찰 내역
Caching tradeoff: speed vs accuracy
Is caching always good? That's not true.
SWR (Stale-While-Revalidate) pattern
revalidate in Next.js works in an interesting way.
tsx
// revalidate: 60 = update every 60 secondsconst data = awaitfetch('/api/data', {
next: { revalidate: 60 },
});
How will it behave after 60 seconds?
code
13:00:00 - User 1 connects → Cache creation
13:00:30 - User 2 connects → Use cache (fresh)
13:01:00 - User 3 connects → uses cache (60 seconds have passed, stale state)
→ Start importing new data in the background at the same time
13:01:01 - User 4 connects → uses new cache
User 3 receives outdated data but receives an immediate response. This is the core of SWR.
// Invalidate cache with server actionasyncfunctionupdateProduct(productId: string, data: any) {
await db.products.update({ id: productId, ...data });
// Invalidate specific tagsrevalidateTag(`product-${productId}`);
// or invalidate a specific pathrevalidatePath(`/products/${productId}`);
}
Common mistakes
Mistake 1: Caching everything
tsx
// ❌ Bad example: caching user-specific dataconst userData = awaitfetch('/api/me', {
next: { revalidate: 3600 }, // Same data for other users?
});
// ✅ Good example: do not cache user-specific dataconst userData = awaitfetch('/api/me', {
cache: 'no-store',
});
Caching is not a panacea. You need an appropriate strategy for your situation.
Principles to remember
Understand the characteristics of the data
How often does it change?
How important is accuracy?
What is the cost of bad data?
Put user experience first
Fast loading vs accurate data
In most cases both are needed
Measure and Improve
Cache hit rate monitoring
Collect user feedback
Continuous optimization
What’s changed in Next.js 15
tsx
// v14: Default caching (explicitly turned off by developer)// v15: Default no-cache (explicitly turned on by developer)// Now you need to clearly express your intentionsconst cachedData = awaitfetch('/api/data', {
cache: 'force-cache', // “I want caching”
});
This change is a decision that prioritizes “predictable behavior.” The philosophy is that it is safer to explicitly enable it when needed, rather than causing chaos due to caching.