Implementing Cache Sharing Between Serverless Instances with Next.js 15+ `use cache: remote` and Upstash Redis — Includes Completed Custom Cache Handler Code
When operating Next.js apps in a serverless environment, you face an inconvenient truth. Since a new function instance is created for every request and each occupies an independent memory space, the built-in in-memory cache is effectively meaningless. If 10 instances are running simultaneously, the same data is queried from the database 10 times, resulting in slowed response times, database spikes, and increased unnecessary costs. The problem becomes more severe as traffic increases.
This article is intended for developers running Next.js App Router in a production serverless environment. Assuming basic experience with App Router and serverless deployment, we will examine the process step-by-step, from the interface structure of custom cache handlers to the core challenges of ReadableStream serialization and a complete implementation ready for immediate production use. By connecting use cache: remote with Upstash Redis, all instances in a serverless environment can share the same cache pool, enabling the stable maintenance of a high cache hit rate even after a cold start.
Key Concepts
The Cache Dilemma in Serverless Environments
Memory for serverless functions is deallocated when a request is finished. As horizontal scaling occurs, the number of instances increases, but the in-memory caches of each instance are independent of one another. The question "Does use cache make sense in serverless?" is a very real issue that is being actively discussed in the community.
Next.js solves this problem by separating it into three cache layers.
| Directive | Storage Location | Attributes |
|---|---|---|
"use cache" |
Instance In-Memory (LRU) | Fastest but cannot be shared between instances |
"use cache: remote" |
External remote storage | Shareable between instances, added network latency |
"use cache: private" |
User-isolated cache | For personalized data (cache key includes user identifier) |
"use cache: remote" This is a directive introduced in Next.js 15 that instructs cache results to be saved to external storage. Handlers registered as cacheHandlers.remote are responsible for the actual saving logic.
Custom Cache Handler Interface
Handlers registered with the cacheHandlers option must implement two methods.
interface CacheHandler {
get(cacheKey: string, softTags: string[]): Promise<CacheEntry | undefined>
set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>
}
interface CacheEntry {
value: ReadableStream<Uint8Array> // 직렬화가 필요한 스트림
tags: string[]
stale: number
timestamp: number
expire: number // Unix 밀리초 타임스탬프
revalidate: number
}The reason CacheEntry.value is ReadableStream is that Next.js generates rendering results as a stream. Thanks to this design, the response stream and cache storage logic can be separated asynchronously, but it introduces implementation complexity requiring the stream to be serialized to store in Redis.
softTags is a list of dynamic tags passed as the second argument to the get method. It is generated in the current request context, and if this tag is invalidated by revalidateTag, the cache should not be returned. Omitting this validation logic can lead to a serious problem where invalidated caches continue to be returned.
Distinguishing between cacheHandlers(plural) and cacheHandler(singular)
The roles of the two easily confused options are completely different.
| Options | Usage | Remarks |
|---|---|---|
cacheHandler (singular) |
For existing ISR(Incremental Static Regeneration)/page caching | Next.js 13~14 era API |
cacheHandlers (plural) |
"use cache" Directive only |
New to Next.js 15+ API |
Practical Application
next.config.js settings
First, enable experimental features in next.config.js and register a custom handler path.
// next.config.js
const nextConfig = {
experimental: {
dynamicIO: true,
},
cacheHandlers: {
// require.resolve()는 Node.js 모듈 해석 시 실행 환경 기준의 절대 경로를
// 보장하기 위해 사용합니다. 상대 경로 문자열을 직접 써도 되지만,
// 모노레포나 빌드 환경에 따라 경로 오류가 발생할 수 있습니다
default: require.resolve('./cache-handlers/in-memory-handler.js'),
remote: require.resolve('./cache-handlers/upstash-handler.js'),
},
}
module.exports = nextConfig| Settings Item | Description |
|---|---|
dynamicIO: true |
"use cache" Experimental flag to enable directives |
cacheHandlers.default |
"use cache" Default handler to execute when used |
cacheHandlers.remote |
"use cache: remote" Remote handler to operate when used |
Upstash Redis Remote Handler Implementation
ReadableStream This is a complete handler implementation including serialization.
// cache-handlers/upstash-handler.js
const { Redis } = require('@upstash/redis')
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
// 같은 Redis 인스턴스를 스테이징/프로덕션 또는 여러 프로젝트에서 공유한다면
// 환경 변수로 프리픽스를 분리하여 키 충돌을 방지하는 것을 권장합니다
const CACHE_PREFIX = process.env.CACHE_PREFIX ?? 'nextjs:cache:'
const DEFAULT_TTL = 60 * 60 * 24 // 24시간
module.exports = class UpstashCacheHandler {
// 캐시 조회: Redis에서 가져와 ReadableStream으로 복원
async get(cacheKey, softTags) {
const stored = await redis.get(CACHE_PREFIX + cacheKey)
if (!stored) return undefined
const entry = typeof stored === 'string' ? JSON.parse(stored) : stored
// expire가 0이면 만료 없음, 양수이면 현재 시각과 비교
const now = Date.now()
if (entry.expire !== 0 && now > entry.expire) return undefined
// TODO: softTags 무효화 검증 미구현
// softTags에 포함된 태그가 revalidateTag로 무효화된 경우
// 이 캐시를 반환하면 안 됩니다. 이 로직이 없으면
// 무효화된 데이터가 계속 반환될 수 있습니다.
// 구현 방법(무효화된 태그를 Redis에 저장하고 교차 검증)은 다음 글에서 다룹니다.
// Base64 인코딩된 청크를 Uint8Array로 복원 후 ReadableStream 생성
const chunks = entry.valueChunks.map(
(chunk) => new Uint8Array(Buffer.from(chunk, 'base64'))
)
const value = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk)
controller.close()
},
})
return {
value,
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
}
}
// 캐시 저장: ReadableStream을 직렬화하여 Redis에 저장
async set(cacheKey, pendingEntry) {
try {
// pendingEntry는 Promise이므로 반드시 await
const entry = await pendingEntry
// Next.js는 응답 스트림이 소비된 후 비동기(fire-and-forget)로
// 핸들러를 호출합니다. entry.value를 직접 읽어도 안전합니다
const chunks = []
const reader = entry.value.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(Buffer.from(value).toString('base64'))
}
const serialized = {
valueChunks: chunks,
tags: entry.tags,
stale: entry.stale,
timestamp: entry.timestamp,
expire: entry.expire,
revalidate: entry.revalidate,
}
// expire는 Unix ms 타임스탬프이므로 Redis EX(초 단위)로 변환
const now = Date.now()
const ttl = entry.expire
? Math.ceil((entry.expire - now) / 1000)
: DEFAULT_TTL
// expire가 이미 지난 항목은 저장하지 않음
if (ttl <= 0) return
await redis.set(
CACHE_PREFIX + cacheKey,
JSON.stringify(serialized),
{ ex: ttl }
)
} catch (err) {
// Redis 저장 실패는 응답에 영향을 주지 않도록 조용히 처리
console.error('[UpstashCacheHandler] set 실패:', err)
}
}
}| Implementation Points | Description |
|---|---|
await pendingEntry |
Since the second argument of set is Promise<CacheEntry>, await is mandatory |
.tee() unnecessary |
It is safe to read entry.value directly since Next.js calls the handler after consuming the response stream |
| Base64 Serialization | Uint8Array → Buffer.from(value).toString('base64') → JSON Storage |
| TTL Calculation | Convert to seconds from expire(ms) to (expire - Date.now()) / 1000; skip saving if negative |
| Error Handling | Wrapped in try/catch to prevent Redis save failures from propagating to Next.js |
Declarative Use in Components
Once the handler configuration is complete, you can declaratively utilize remote caching in Server Components and Server Actions.
// app/products/page.tsx
import { cacheLife, cacheTag } from 'next/cache'
// 이 함수의 결과는 Upstash Redis에 저장됩니다
async function getProducts() {
'use cache: remote'
cacheLife('hours') // stale: 0, revalidate: 3600, expire: 86400
cacheTag('products') // revalidateTag('products')로 무효화 가능
const res = await fetch('https://api.example.com/products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return <ProductList items={products} />
}// app/actions/products.ts
'use server'
import { revalidateTag } from 'next/cache'
// 상품 데이터 변경 시 모든 인스턴스의 원격 캐시를 일괄 무효화
export async function refreshProducts() {
revalidateTag('products')
}cacheLife can use predefined life profiles such as 'hours', 'days', 'weeks', or it is also possible to register a custom profile in next.config.js.
Pros and Cons Analysis
Advantages
| Item | Content |
|---|---|
| Cache Sharing Across Instances | Dozens to hundreds of instances share the same cache hit rate |
| Declarative Caching | Increased predictability by explicitly specifying caching strategies at the component/function level |
| HTTP-based Upstash | No TCP connection pool management required, so it works anywhere, edge or serverless |
| Tag-based granular invalidation | You can selectively remove only relevant caches with revalidateTag |
| Maintain Hit Rate After Cold Start | Unlike in-memory cache, the cache remains alive even after instance recreation |
Disadvantages and Precautions
| Item | Content | Response Plan |
|---|---|---|
| Network Latency | An additional Upstash API call is added for every cache lookup (1–5ms) | Selectively applied only to high-hit data |
| Cost | Upstash uses request-based billing, so costs increase during traffic spikes | Set TTL appropriately and limit unnecessary caching |
| Serialization Complexity | ReadableStream Serialization and deserialization implementations are required |
Reuse completed handler code or utilize @fortedigital/nextjs-cache-handler |
| softTags invalidation not implemented | The implementation in this post does not include softTags validation | Apply after the softTags implementation in the next post is complete |
| Still experimental | Some features of dynamicIO are still being stabilized in Next.js 16 |
Check the Next.js release notes before deploying to production |
The Most Common Mistakes in Practice
- When processing
pendingEntrywithout await —setThe second argument of the method isPromise<CacheEntry>. Accessing.valuewithout await results inundefined. softTagsWhen skipping validation — If you ignore softTags in thegetmethod, the cache of tags invalidated byrevalidateTagwill continue to be returned. It is recommended to clearly indicate this with a TODO comment until the invalidation logic is complete.- If you mistake the
expirefield for seconds —expireis a Unix millisecond timestamp. It must be converted toMath.ceil((entry.expire - Date.now()) / 1000)before passing it to Redis'sEXoption.
In Conclusion
"use cache: remote" + Custom cache handlers are a production pattern that elegantly resolves the issue of state sharing between instances—an inherent limitation of serverless architecture—at the code level. Depending on the environment and data characteristics, you can expect response time savings of tens to hundreds of milliseconds and reduced database load compared to direct database queries.
3 Steps to Start Right Now:
- Create a Redis DB in the Upstash Console and issue environment variables. You can start with the Free Tier, and add the two values
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENto.env.local. - After
pnpm add @upstash/redis, save the above handler code ascache-handlers/upstash-handler.jsand register it innext.config.js. Also add theexperimental.dynamicIO: truesettings. - Start by declaring
'use cache: remote'and assigningcacheTagto a function that is frequently accessed but rarely changes. You can quickly learn how it works by directly verifying in the Upstash Console's Data Browser that data is stored with thenextjs:cache:prefix.
Next Post: softTags Completing the Invalidation Logic — revalidateTag Pattern for Handling Dynamic Tag Expiration in a Remote Handler Upon Call
Reference Materials
- Directives: use cache: remote | Next.js Official Documentation
- next.config.js: cacheHandlers | Next.js Official Documentation
- Directives: use cache | Next.js Official Documentation
- Functions: cacheLife | Next.js Official Documentation
- Functions: cacheTag | Next.js Official Documentation
- Guides: Self-Hosting | Next.js Official Documentation
- Next.js with Redis | Upstash Official Documentation
- Speed up your Next.js application with Redis | Upstash 블로그
- fortedigital/nextjs-cache-handler | GitHub
- Next.js cache-handler-redis Official Example | GitHub
- Is "use cache" gonna cache anything in a serverless environment? | GitHub Discussion #87842
- "use cache"s interaction with custom cache handlers | GitHub Discussion #76635
- Scaling Next.js with Redis cache handler | DEV Community
- Watt v3.18: Next.js 16 use cache with Redis/Valkey | Platformatic 블로그
- Upgrading: Version 16 | Next.js Official Documentation