`Next.js 15 revalidateTag vs updateTag`: The Complete Guide to Cache Invalidation Strategies
With the introduction of the 'use cache' directive in Next.js 15, the server caching architecture has fundamentally changed. 'use cache' stores the result in the server cache simply by declaring it at the top of an async function or component, replacing the previous fetch()-option-based pattern. Along with this change, cache invalidation functions have been overhauled — and now you might find yourself confused about whether to choose revalidateTag() or updateTag().
This is exactly why the question "I called a cache invalidation function, so why do I still see old data after refreshing?" keeps coming up in the community. The two functions have similar names and appear to share the same goal of "invalidating" the cache, but their internal behavior and the contexts in which they can be used are entirely different.
revalidateTag() operates with stale-while-revalidate (SWR) semantics, refreshing the cache in the background, while updateTag() performs immediate synchronous invalidation at the moment the Server Action completes. Without understanding this difference, choosing the wrong one leads to problems like "I saved, but the changes aren't showing up" or, conversely, "cache performance is terrible." This article examines how both functions work, criteria for choosing between them in real-world scenarios, and common mistakes.
Target audience: This article assumes you have experience using the Next.js App Router. If you're not yet familiar with the basic concepts of
'use cache',cacheTag(),cacheLife(), Server Actions, and Route Handlers, it's recommended to first read the Next.js official documentation's Caching guide.
Core Concepts
What is stale-while-revalidate?
SWR (stale-while-revalidate) is a strategy that has been used in the HTTP caching world for a long time. The core idea is: "Even when the cache expires, respond immediately with the old data and fetch new data in the background to replace it."
SWR (stale-while-revalidate): A cache strategy that, when the cache expires, returns stale data first without blocking the user, then returns fresh data starting from the next request once background revalidation is complete.
When you call revalidateTag(tag, 'max'), the cache entry tagged with that tag is not immediately deleted — it is instead marked as "stale."
revalidateTag('post', 'max') called
↓
Cache entry → marked as "stale" (not immediately deleted)
↓
First request → responds immediately with stale data (no user blocking)
↓
Background fetch of new data begins
↓
Cache replaced upon completion → all subsequent visitor requests return fresh dataAn important point is that Next.js's server cache is globally shared. In the diagram above, the "first request" may not be a revisit from the same user. Even if a completely different visitor arrives immediately after revalidateTag is called, they will receive stale data until the background regeneration is complete.
You should also consider cases where background regeneration fails. If regeneration fails due to a data source error or network issue, the stale state persists until the next request comes in. There is no built-in retry mechanism, so it is recommended to detect regeneration failures through error monitoring.
updateTag() — Immediate Synchronous Invalidation
updateTag() is a Server Action-exclusive function that immediately expires the cache at the moment the Server Action completes. It guarantees that fresh data will be fetched on the next render.
'use server'
import { updateTag } from 'next/cache'
export async function updateUserProfile(formData: FormData) {
await db.user.update({ /* ... */ })
// Expires immediately at Server Action completion → guarantees fresh data on next render
updateTag('user-profile')
}Read-your-own-writes: A consistency model that guarantees a user can immediately read the result of a write operation they performed.
updateTag()provides this property.
This is a different concept from "optimistic update." An optimistic update is a pattern where the UI is updated first without waiting for a server response and then corrected later, whereas updateTag() is immediate synchronous invalidation that synchronously expires the cache after the Server Action has fully completed.
The Fundamental Difference Between the Two Approaches
revalidateTag(tag, 'max') |
updateTag(tag) |
|
|---|---|---|
| Behavior | Stale marking → background regeneration | Immediate synchronous expiration |
| First request response | Stale data (fast) | Waits for new data |
| Usable context | Server Action, Route Handler | Server Action only |
| Consistency level | Eventual consistency | Read-your-own-writes |
| Suitable for | Public content, external webhooks | User-initiated changes |
Understanding the Second Argument of revalidateTag()
The second argument determines the invalidation method. The single-argument form is deprecated, so it is recommended to explicitly provide the second argument.
// ✅ Recommended
revalidateTag('post', 'max') // SWR semantics: stale marking then background regeneration
revalidateTag('post', { expire: 0 }) // Immediate expiration (for Route Handlers)
// ❌ Deprecated (single argument)
revalidateTag('post')Here, 'max' is the same concept as the profile name in cacheLife('max'). Just as cacheLife('max') is configured as stale: Infinity, revalidate: Infinity, expire: 5 years, revalidateTag(tag, 'max') invalidates the cache entry using SWR semantics based on the max profile.
Note: For the exact behavioral specification of
revalidateTag()'s second argument, it is recommended to check both the Next.js official documentation and GitHub Discussions together. Official documentation links and Discussion links are mixed in the references, and the deprecated status and{ expire: 0 }form may differ depending on the Next.js version you are using.
Decision Tree
Is the write operation a Server Action?
├── YES → Is this a user-initiated change?
│ ├── YES → updateTag() (immediate update, read-your-own-writes)
│ └── NO → revalidateTag('tag', 'max') (SWR, background regeneration)
└── NO (Route Handler / Webhook)
├── Needs immediate expiration? → revalidateTag('tag', { expire: 0 })
└── SWR acceptable? → revalidateTag('tag', 'max')Real-World Applications
Example 1: External CMS Webhook Integration (SWR Invalidation)
This is a pattern for invalidating the cache via webhook when content is updated in an external CMS such as Contentful or Sanity. The SWR approach is suitable here because it has little impact on service operations even if the first visitor briefly sees stale data.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const { tag, secret } = await req.json()
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag(tag, 'max') // SWR: stale marking then background regeneration
return Response.json({ revalidated: true })
}// app/blog/[slug]/page.tsx
async function getBlogPost(slug: string) {
'use cache'
cacheTag(`post-${slug}`, 'blog')
cacheLife('max') // stale: ∞, revalidate: ∞, expire: 5 years — keep as long as possible until webhook is called
return await cms.getPost(slug)
}| Code point | Description |
|---|---|
cacheTag('post-${slug}', 'blog') |
Assigns both a per-slug tag and a global blog tag, enabling both individual and bulk invalidation |
cacheLife('max') |
Keeps the cache as long as possible until a webhook is called |
revalidateTag(tag, 'max') |
Returns fresh data to visitors after background regeneration is complete |
Example 2: User Profile Update (Immediate Invalidation Required)
When a user directly modifies their information, they must not see old data when they refresh the page immediately after saving. updateTag() is the right choice for this scenario.
// app/profile/actions.ts
'use server'
import { updateTag } from 'next/cache'
export async function updateProfile(formData: FormData) {
// getSessionUserId() is a function from your project's authentication library
// (e.g., getServerSession from next-auth) that retrieves the currently logged-in user's ID
const userId = await getSessionUserId()
await db.user.update({
where: { id: userId },
data: { name: formData.get('name') as string },
})
updateTag(`user-${userId}`) // Immediate synchronous expiration → guarantees fresh data on next render
}| Code point | Description |
|---|---|
updateTag('user-${userId}') |
Precisely targets only that user's cache for immediate expiration |
| Called only within a Server Action | Calling updateTag outside a Server Action context causes a Runtime error |
Example 3: Real-Time Inventory System Webhook (Immediate Expiration in Route Handler)
For cases where data accuracy is critical, such as payment or inventory systems, but a Server Action cannot be used, you can handle immediate expiration with { expire: 0 }. Authentication verification is essential for webhook endpoints.
// app/api/inventory/webhook/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
// Webhook security: verify request origin — without this, anyone can invalidate the cache
const signature = req.headers.get('x-webhook-signature')
if (signature !== process.env.INVENTORY_WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { productId } = await req.json()
// updateTag cannot be used in Route Handlers
// Handle immediate expiration with expire: 0
revalidateTag(`product-${productId}`, { expire: 0 })
return Response.json({ ok: true })
}Note:
{ expire: 0 }triggers immediate expiration in Route Handlers, but there is a known issue where the UI does not update automatically. Users may need to manually refresh the page; related discussion can be found at vercel/next.js Discussion #84805.
Example 4: Bulk Invalidation with Hierarchical Tags
When multiple pages share a common tag, such as in a product catalog, you can assign multiple tags and manage them hierarchically. Invalidating a single shared tag invalidates all cache entries carrying that tag at once.
// Tag hierarchy declaration — used in each category page
async function getProducts(categoryId: string) {
'use cache'
// catalog: entire catalog, products: all products, category-{id}: specific category
cacheTag(`category-${categoryId}`, 'products', 'catalog')
cacheLife('hours')
return await db.product.findMany({ where: { categoryId } })
}// app/api/catalog/webhook/route.ts — Catalog update webhook
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const { scope, categoryId, secret } = await req.json()
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
if (scope === 'all') {
revalidateTag('catalog', 'max') // SWR invalidation of entire catalog
} else if (categoryId) {
revalidateTag(`category-${categoryId}`, 'max') // Invalidate only the specific category
}
return Response.json({ revalidated: true })
}| Invalidation target | How to call | Scope of impact |
|---|---|---|
| Entire catalog | revalidateTag('catalog', 'max') |
All cache entries tagged with catalog |
| Specific category | revalidateTag('category-${id}', 'max') |
Only that category's cache entries |
Pros and Cons Analysis
Advantages
| Approach | Item | Description |
|---|---|---|
revalidateTag('tag', 'max') |
Response performance | Immediate response with stale data → no user blocking |
revalidateTag('tag', 'max') |
Server load distribution | Sequential background regeneration even during bulk invalidation |
revalidateTag('tag', 'max') |
Applicability | Suitable for most public content cases (blogs, products, documentation) |
updateTag() |
Data consistency | Guarantees read-your-own-writes — new data immediately visible after saving |
updateTag() |
Natural integration | Integrates naturally with Server Action flow |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| SWR — first request stale | Visitors who access immediately after invalidation receive old data | Use updateTag or { expire: 0 } when immediate consistency is required |
| SWR — background failure | Stale state persists if regeneration fails; no retry mechanism | Recommend adding error monitoring |
updateTag — context restriction |
Causes Runtime error when called outside a Server Action | Use revalidateTag(tag, { expire: 0 }) in Route Handlers instead |
updateTag — cache miss spike |
Potential performance degradation if many users trigger actions simultaneously | Limit use to user-initiated changes only |
{ expire: 0 } — UI not updated |
UI does not automatically update after Route Handler call | Guide users to manually refresh or explore alternative approaches |
Eventual consistency: A property that does not guarantee immediate consistency, but ensures all requests will eventually receive the same latest data after some time has passed. The
revalidateTag()SWR approach falls into this category.
The Most Common Mistakes in Practice
- Using
revalidateTag()SWR for user-initiated changes — This causes a UX problem where old data appears even after refreshing following a save. For changes directly made by users, such as form submissions, profile edits, and comment posting,updateTag()is the correct choice. - Continuing to use the single-argument
revalidateTag('tag')form — This is deprecated in Next.js 15. It is recommended to explicitly specify the second argument in the formrevalidateTag('tag', 'max')orrevalidateTag('tag', { expire: 0 }). - Attempting to call
updateTag()in a Route Handler — Since it is a Server Action-exclusive function, a Runtime error will occur. In Route Handlers,revalidateTag(tag, { expire: 0 })can be used instead.
Closing Thoughts
The difference between revalidateTag() and updateTag() is not simply an API choice — it is the answer to a design question: "When must consistency for this data be guaranteed?" The basic principle is: gain performance with SWR for public content, and guarantee consistency with immediate synchronous invalidation for user-initiated changes.
Here are 3 steps you can take right now.
- You can check whether your current project uses
revalidateTag()with a single argument. Rungrep -r "revalidateTag(" ./app --include="*.ts" --include="*.tsx"in the terminal to check whether any deprecated forms remain. - List the write operations (Server Actions) directly performed by users and review whether
updateTag()is applied to each. Server Actions for form submissions, profile edits, and comment posting are the primary targets. - If you have Route Handler-based webhooks, it is recommended to explicitly specify the second argument — either
revalidateTag(tag, 'max')orrevalidateTag(tag, { expire: 0 })— based on your service requirements.
Next article: We plan to cover how to customize
cacheLife('max')used in the examples of this article to suit your service's characteristics, and strategies for layering Vercel edge cache and server cache hierarchically.
References
- Functions: revalidateTag | Next.js Official Documentation
- Functions: updateTag | Next.js Official Documentation
- Guides: How Revalidation Works | Next.js
- Getting Started: Revalidating | Next.js
- Directives: use cache | Next.js
- Functions: cacheTag | Next.js
- Functions: cacheLife | Next.js
- updateTag vs revalidateTag · vercel/next.js Discussion #84805
- Cache Revalidation Options · vercel/next.js Discussion #78513
- Deep Dive: Caching and Revalidating · vercel/next.js Discussion #54075
- Next.js revalidateTag vs updateTag: Cache Strategy Guide | Build with Matija
- revalidateTag & updateTag In NextJs - DEV Community
- Guides: ISR | Next.js
- Caching - OpenNext