When revalidateTag is not propagated in distributed deployments — Completing softTags invalidation with Next.js custom cache handlers
Have you ever experienced a phenomenon where the old data is visible only to some users after you have modified product information? This is a common issue encountered when deploying Next.js apps to multi-instance environments (serverless, container auto-scaling, etc.). Even if Instance A calls revalidateTag('products'), Instances B and C, which are simultaneously handling other requests, are completely unaware of this fact. As a result, cache invalidation is applied only to some instances, causing users to see different versions of the data.
The solution to this problem lies within the 'use cache' directive-based caching architecture newly introduced in Next.js 15/16. 'use cache' is an explicit caching method that replaces the existing implicit fetch caching, allowing you to declare independent cache lifecycles at the component or function level. The key to distributed invalidation in this system is softTags. If you are familiar with pages/ routers or getServerSideProps, you will notice that this new cache control method is much more precise and explicit.
The core of the Remote Handler pattern is comparing the invalidation timestamp of softTags with the entry creation time in the custom cache handler's get() method, and synchronizing with a shared storage like Redis using the updateTags() / refreshTags() hooks.
Key Concepts
hardTags and softTags — Two types of tag systems
In Next.js's 'use cache' system, tags are divided into two types.
- hardTags (Explicit Tags): Tags declared directly by the developer in the code, such as
cacheTag('products') - softTags (Implicit Tags): Tags automatically generated by Next.js from route paths. They have the
_N_T_prefix.
For example, when you access the path /blog/hello, Next.js automatically generates the following softTags.
_N_T_/layout
_N_T_/blog/layout
_N_T_/blog/hello/layout
_N_T_/blog/helloThanks to this structure, calling revalidatePath('/blog/hello') invalidates not only that path but also the parent layout path. Internally, revalidatePath shares the same layer as the tag-based invalidation system.
What are softTags? They are implicit cache tags that Next.js automatically generates from route paths without requiring a developer to declare them. They have the prefix _N_T_ and form the basis of revalidatePath() behavior.
Why Cache Handlers Need to Handle SoftTags
The get() method of a custom cache handler takes softTags: string[] as a second argument, in addition to the cache key. Next.js delegates the responsibility of determining whether these tags are still valid to the handler. If the handler does not implement this comparison logic, calling revalidatePath or revalidateTag will not actually invalidate the cache.
Below is the interface that a custom cache handler must implement.
// ⚠️ updateTags, refreshTags, getExpiration은 현재(2025년 기준) Next.js 내부 API입니다.
// 공식 문서에 stable로 표기되기 전까지는 내부 훅으로 취급하고,
// Next.js 버전 업그레이드 시 changelog에서 시그니처 변경 여부를 반드시 확인하세요.
interface CacheHandler {
get(cacheKey: string, softTags: string[]): Promise<CacheEntry | undefined>;
set(cacheKey: string, value: ReadableStream, options: CacheOptions): Promise<void>;
// 분산 환경 원격 핸들러 전용 훅
getExpiration(tags: string[]): Promise<number>; // 태그 중 최신 무효화 timestamp 반환
updateTags(tags: string[], expireAt: number): Promise<void>; // revalidateTag 호출 시 실행
refreshTags(): Promise<void>; // 각 요청 전 공유 저장소와 동기화
}revalidateTag vs updateTag — Two Branches of Consistency Strategies
In Next.js 15/16, cache invalidation strategies are divided into two types depending on the purpose.
| Category | revalidateTag |
updateTag |
|---|---|---|
| Consistency Method | Eventual (stale-while-revalidate) | Immediate |
| Behavior | Mark existing cache as stale → Immediately return old data to the current user, query the data source directly for subsequent requests | Expire cache immediately → For subsequent requests, query the data source directly without caching and wait for results |
| Available Locations | Route Handlers, Server Actions, API Routes | Server Actions Only |
| Suitable Examples | Blog Posts, Product Catalogs | User's own edits reflected immediately (read-your-own-writes) |
What is Stale-While-Revalidate (SWR)? It is an HTTP caching strategy that immediately returns stale content and fetches new data in the background to serve on subsequent requests. It allows data to be updated without sacrificing user-perceived response speed.
Practical Application
Example 1: Basic structure of a custom cache handler processing softTags
To properly handle softTags invalidation even in a single instance environment, you must compare the entry creation time with the latest invalidation timestamp of softTags inside the get() method.
// cache-handler.ts
// ⚠️ 아래 경로는 Next.js 내부 경로 import입니다.
// 버전 업그레이드 시 경로가 변경될 수 있으므로, 공식 export 경로가 생기면 교체하세요.
import type { CacheHandler, CacheEntry } from 'next/dist/server/lib/cache-handlers/types';
// storage는 캐시 엔트리를 실제로 저장하는 저장소입니다.
// 메모리 Map, Redis, 파일시스템 등 어떤 구현체든 사용할 수 있습니다.
// 예시: const storage = new Map<string, CacheEntry>();
declare const storage: { get(key: string): Promise<CacheEntry | undefined> };
export class MyCacheHandler implements CacheHandler {
private tagTimestamps = new Map<string, number>();
async get(cacheKey: string, softTags: string[]): Promise<CacheEntry | undefined> {
const entry = await storage.get(cacheKey);
if (!entry) return undefined;
const softTagExpiration = await this.getExpiration(softTags);
// ⚠️ softTagExpiration과 entry.timestamp의 단위(밀리초 vs 초)가 반드시 일치해야 합니다.
// 단위가 다르면 항상 stale 처리되거나 절대 stale 처리되지 않는 조용한 버그가 발생합니다.
if (softTagExpiration > entry.timestamp) {
// softTag가 더 최근에 무효화됨 → 엔트리를 stale 처리
return undefined;
}
return entry;
}
async getExpiration(tags: string[]): Promise<number> {
return Math.max(0, ...tags.map(t => this.tagTimestamps.get(t) ?? 0));
}
async updateTags(tags: string[], expireAt: number): Promise<void> {
// revalidateTag() 호출 시 Next.js가 내부적으로 이 메서드를 실행합니다.
for (const tag of tags) {
this.tagTimestamps.set(tag, expireAt);
}
}
async refreshTags(): Promise<void> {
// 각 요청 시작 전 호출됩니다.
// 단일 인스턴스에서는 구현이 필요 없고, 분산 환경에서 공유 저장소와 동기화합니다.
}
}| Code Point | Description |
|---|---|
getExpiration(softTags) |
Returns the most recently invalidated timestamp among softTags |
softTagExpiration > entry.timestamp |
If invalidation occurs after entry creation, treat as stale |
updateTags() |
revalidateTag() Next.js runs automatically when called |
| Timestamp Unit | The units of the two values (milliseconds/second) must match for accurate comparison |
Example 2: Synchronizing Multi-Instances with Redis-Based Remote Handlers
Example 1 is a single-instance handler. In serverless or container-based deployments, invalidation does not propagate between instances because a separate tagTimestamps exists for each instance. Example 2 is a remote handler version in which refreshTags() and updateTags() from Example 1 are replaced with Redis-based ones. If you are new to Redis, it is recommended that you refer to the official Redis documentation first.
// redis-cache-handler.ts
import { createClient } from 'redis';
// Redis 클라이언트는 모듈 수준에서 초기화하고,
// 애플리케이션 시작 시 await redis.connect()를 호출해야 합니다.
// 연결 오류는 redis.on('error', handler)로 별도 처리하는 것을 권장합니다.
const redis = createClient({ url: process.env.REDIS_URL });
const TAG_PREFIX = 'tag:';
export class RedisCacheHandler implements CacheHandler {
private localTagCache = new Map<string, number>();
async updateTags(tags: string[], expireAt: number): Promise<void> {
// revalidateTag() 호출 시 Redis에 무효화 시각을 기록합니다.
// pipeline(multi)으로 묶어 네트워크 왕복을 최소화합니다.
const pipeline = redis.multi();
for (const tag of tags) {
pipeline.set(`${TAG_PREFIX}${tag}`, String(expireAt));
}
await pipeline.exec();
}
async refreshTags(): Promise<void> {
// 매 요청 전 Redis에서 전체 태그 상태를 로컬 캐시에 동기화합니다.
// ⚠️ 이 메서드가 throw하면 요청 자체가 실패하므로 반드시 try/catch 처리해야 합니다.
try {
// ⚠️ redis.keys()는 O(N) blocking 명령으로 프로덕션에서 절대 사용하면 안 됩니다.
// SCAN을 사용해 비blocking 방식으로 키를 순회해야 합니다.
const tagKeys: string[] = [];
let cursor = 0;
do {
const result = await redis.scan(cursor, {
MATCH: `${TAG_PREFIX}*`,
COUNT: 100,
});
cursor = result.cursor;
tagKeys.push(...result.keys);
} while (cursor !== 0);
for (const key of tagKeys) {
const value = await redis.get(key);
if (value) {
const tag = key.slice(TAG_PREFIX.length);
this.localTagCache.set(tag, Number(value));
}
}
} catch (err) {
console.error('refreshTags failed, using stale local state:', err);
// Redis 장애 시 마지막으로 알려진 로컬 태그 상태로 서비스를 계속 유지합니다.
}
}
async getExpiration(tags: string[]): Promise<number> {
return Math.max(0, ...tags.map(t => this.localTagCache.get(t) ?? 0));
}
}Why SCAN instead of redis.keys()? The KEYS command is an O(N) blocking command that scans all keys in Redis at once. If the number of keys exceeds tens of thousands, the entire Redis server may be unable to process other commands during that time, potentially leading to a failure. SCAN processes the same task in small chunks, making it safe even in large-scale environments.
Example 3: next.config.ts Registration and Tag Declaration/Invalidation Code
After implementing the handler, register it with next.config.ts, and declare and invalidate the tag in your application code.
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
cacheHandlers: {
default: require.resolve('./cache-handler'), // 'use cache' 대상
remote: require.resolve('./redis-cache-handler'), // 'use cache: remote' 대상
},
};
export default config;// app/actions.ts
'use server';
import { cacheTag } from 'next/cache';
import { revalidateTag, updateTag } from 'next/cache';
// 태그 선언 — 'use cache' 디렉티브가 있는 함수 내부에서만 유효합니다.
// categoryId처럼 동적 값을 태그에 포함하면 특정 카테고리만 선택적으로 무효화할 수 있습니다.
async function getProducts(categoryId: string) {
'use cache';
cacheTag('products', `products:category:${categoryId}`);
return await db.products.findAll({ where: { categoryId } });
}
// 즉시 반영이 필요한 경우 (Server Action 전용)
export async function deleteProduct(id: string) {
await db.products.delete(id);
// 다음 요청은 캐시 없이 데이터 소스를 직접 조회합니다.
updateTag('products');
}
// 백그라운드 갱신으로 충분한 경우
export async function triggerRevalidate() {
// 현재 사용자에게는 구 데이터를 즉시 반환하고, 다음 요청부터 신규 데이터를 제공합니다.
revalidateTag('products');
}Pros and Cons Analysis
Advantages
| Item | Content |
|---|---|
| Route-based automatic invalidation | Both revalidatePath() and revalidateTag() can be handled with a unified tag system |
| Granular cache control | Manage independent lifecycles at the component and function level |
| Distributed Environment Scalability | You can implement invalidation propagation between multiple instances using updateTags() / refreshTags() hooks |
| Delay-free updates | Updates in the background with the SWR strategy, responding immediately to the user |
Disadvantages and Precautions
| Item | Content | Response Plan |
|---|---|---|
refreshTags() Error Propagation |
If the method throws, the request itself fails | Wrap in try/catch and implement graceful degradation to local tag state in case of Redis failure |
| Timestamp Unit Discrepancy | Comparison Error Occurs If softTagExpiration and entry.timestamp Have Different Units (Milliseconds/Sec) |
Explicitly unify the units of the two values and record it in a comment |
| Clock Skew | In distributed environments, system clocks differ for each instance, potentially leading to timestamp comparison errors | Use a single timestamp source, such as the Redis TIME command |
| Redis Query Load | refreshTags() queries Redis for every request |
Control Redis load with local memory cache + TTL-based polling |
updateTag Usage location restricted |
Cannot be used in Route Handlers as it is for Server Actions only | Use revalidateTag in Route Handlers |
| Internal API Stability | updateTags(), refreshTags(), etc. are still internal APIs and may change upon version upgrade |
Check the official changelog for signature changes before upgrading |
What is Graceful Degradation? It is a design pattern where the rest of the service continues to operate as much as possible even if some functions (e.g., Redis connections) fail.
What is Clock Skew? It is a time error that occurs when system clocks differ slightly from server to server in a distributed system. In timestamp-based comparison logic, time differences between instances can cause incorrect decisions regarding cache invalidation.
The Most Common Mistakes in Practice
-
If exceptions are not caught in
refreshTags()— In the event of a Redis failure, if the throw propagates as is, the entire request will fail. try/catch and fallback logic are mandatory, not optional. -
If you use
redis.keys()as is in production code — TheKEYScommand is an O(N) command that blocks the Redis server. Since the entire Redis system can stop when the number of tags exceeds several thousand, you must replace it with aSCAN-based version. -
If you experience a runtime error when attempting to use
updateTag()in a Route Handler or API Routes —updateTagis an API dedicated to Server Actions. You must userevalidateTageven if immediate invalidation is required in a Route Handler.
In Conclusion
If revalidateTag is not propagated to all instances in a multi-instance environment, the cause is usually that the softTags invalidation logic is missing or it is not synchronized with a shared repository. By comparing the softTags invalidation timestamp with the entry creation time in the get() method of a custom cache handler and synchronizing with a shared repository like Redis via the updateTags() / refreshTags() hooks, you can achieve consistent cache invalidation across the entire distributed deployment environment. Applying this pattern eliminates the symptom where "old data is visible to some users even after modifications," and enables instances to be freely scaled even as traffic increases.
3 Steps to Start Right Now:
-
You can create a cache handler file. You can start by creating
cache-handler.ts, writing thesoftTagscomparison logic inside theget()method, and then registering it withcacheHandlers.defaultinnext.config.ts. -
It is recommended that you first determine which strategy is right for you between
revalidateTagandupdateTag. If users need to see their modification results immediately,updateTagis suitable; if it is sufficient for the results to be propagated to all users,revalidateTagis appropriate. -
If you are in a distributed deployment environment, you can extend this to a Redis-based remote handler. We recommend that you add a try/catch block inside
refreshTags()and intentionally take Redis down to verify for yourself whether the service remains in a local tag state.
Next Post: We will cover how to tune the three values of stale / revalidate / expire in cacheLife() by scenario, and a warm-up strategy to minimize blocking updates for the first request after a period of no traffic.
Reference Materials
Must Read
- revalidateTag | Next.js Official Documentation
- CacheHandlers Settings | Next.js Official Documentation
- How Revalidation Works | Next.js Official Documentation
- Scaling Next.js with Redis cache handler | DEV Community
Additional Note
- cacheTag | Next.js Official Documentation
- updateTag | Next.js Official Documentation
- use cache directive | Next.js official documentation
- use cache: remote directive | Next.js official documentation
- updateTag vs revalidateTag | GitHub Discussion
- Cache Revalidation Options | GitHub Discussion
- revalidateTag & updateTag In NextJs | DEV Community
- Next.js revalidateTag vs updateTag: Cache Strategy Guide | Build with Matija
- @neshca/cache-handler Official Documentation
- fortedigital/nextjs-cache-handler | GitHub
- Next.js 16: use cache with Watt v3.18 | Platformatic Blog