Files
vercel__next.js/examples/cache-handler-redis/cache-handler.js
Eddy (Frontend Engineer) 579ff601b4 example(cache-handler-redis): fix connection leak, hangs on a down Redis, and cross-page tag invalidation (#98716)
Three runtime problems in `examples/cache-handler-redis`.

### 1. One new Redis connection per request, never closed

Next.js constructs the singular `cacheHandler` class once per request
(`new CurCacheHandler(...)` in `IncrementalCache`, which
`route-module.ts` creates per request). The example's constructor calls
`createClient()` + `connect()`, so every request opens a connection that
is never closed.

```
$ redis-cli client list | wc -l      # before
102
$ for i in $(seq 1 10); do curl -s -o /dev/null localhost:3000/cet; done
$ redis-cli client list | wc -l
132                                  # +3 per request
```

With Redis' default `maxclients 10000`, a single instance runs out after
a few thousand requests.

### 2. Requests hang while Redis is unavailable

The README says the handlers "degrade gracefully when Redis is
unavailable, so the app still builds and runs, just without a shared
cache". At runtime they don't: with Redis stopped, every request on
every instance blocks until Redis comes back.

```
$ docker stop cache-handler-redis
$ curl -s -o /dev/null -m 60 -w "%{http_code} %{time_total}s\n" localhost:3000/cet
000 60.007305s                       # (uncapped: 188s, returned the moment Redis was started again)
```

Same when the app is started while Redis is down. Cause: node-redis
keeps retrying in the background and `client.connect()` does not settle
until a connection succeeds. Isolated:

```js
const c = createClient({ url: "redis://localhost:6379" }); c.on("error", () => {});
await Promise.race([c.connect(), new Promise(r => setTimeout(() => r("pending"), 15000))]);
// -> "pending" after 15s, isOpen=true isReady=false   (redis@6.2.1)
```

Because each request built a new handler, each request awaited a fresh,
never-settling `connect()` in `getClient()`. `remote-cache-handler.js`
has one module-level client, but its `getClient()` awaits the same
promise, so it hangs the same way if the app starts while Redis is down.

### 3. `updateTag` never reaches other pages' remote entries

The remote handler's `get` never checks the entry's own tags. Next.js
only passes soft tags to `getExpiration`, and the `"use cache"` wrapper
only knows about tags revalidated in the current request, so
`updateTag("time-data")` from `/cet` left the `/gmt` entry stale on
every instance, including the one that ran it, until it expired
(`cacheLife` `expire: 3600`). The [`cacheHandlers`
docs](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheHandlers#get)
say `get` should report an entry whose tag was invalidated as missing or
stale.

### Fix

- Hoist the client in `cache-handler.js` to module scope (the pattern
`remote-cache-handler.js` already uses).
- `getClient()` awaits the connect promise raced against a 1s
`unref()`'d timer (suggested in review), then returns the client when
`isReady` and `null` otherwise. Requests during startup still wait for
the connection, and a down Redis costs one bounded wait instead of
blocking every request. The client keeps retrying and `isReady` flips
back on its own.
- `disableOfflineQueue: true`, so a command issued while the connection
is down rejects immediately (`ClientOfflineError`) instead of being
queued for the 5s command timeout.
- Entry `get` / `set` catch the Redis call only and degrade to a miss.
- Remote `get` compares the entry's tags against their revalidation
timestamps (one `MGET`) and misses when any is newer, the same
comparison as the built-in handler. `getExpiration` returns `Date.now()`
when Redis can't answer, so the entry is discarded rather than served.
- `revalidateTag` / `updateTags` throw when Redis isn't ready, so an
invalidation that never reached Redis surfaces as an error instead of a
success whose entries come back once Redis does.

### After

Two `next start` instances on one Redis, same script on `next@16.3.5`
and `16.4.0-canary.35` (identical results), canary's handlers vs this
PR:

| scenario | before | after |
| --- | --- | --- |
| `updateTag` from `/cet`, read `/gmt` on both instances | stale |
refreshed |
| `revalidateTag("time-data", "max")` | neither page refreshed | both
refreshed |
| tag lookup fails | 500 | 200, regenerated |
| request while Redis is stopped | no response | 200, uncached |
| `updateTag` while Redis is stopped | no response | 500, error logged |
| app started without Redis | no response | first ISR read waits ≤1.1s
once |
| Redis connections, 200 requests | 173 → 773 | 7 → 7 |
| `/cet` under load (autocannon, 10 connections × 10s, `16.3.5`) | 332
req/s, hits Redis `maxclients` | 1,106 req/s median, p99 20ms, 3
connections |
| 10 readers during 20 `updateTag`s: reads served a value from before a
completed invalidation | 3,089 of 3,089 | 0 of 33,899 |
| Redis down ~4s under load | timeouts, caching doesn't come back | 0
errors, caching resumes |

The tag check costs one Redis round trip per remote hit (−9 to −13% on a
route that only reads one remote entry). Handler-level cases, full
tables and the benchmark breakdown are in [this
comment](https://github.com/vercel/next.js/pull/98716#issuecomment-5724918096).
2026-09-18 12:07:48 +02:00

177 lines
6.2 KiB
JavaScript

const { createClient } = require("redis");
const { PHASE_PRODUCTION_BUILD } = require("next/constants");
// A custom Next.js cache handler backed by Redis. This implements the cache
// handler interface (`get`, `set`, `revalidateTag`, `resetRequestCache`)
// directly, so no third-party adapter is required.
//
// See https://nextjs.org/docs/app/guides/self-hosting#configuring-caching
// and https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheHandler
// Prefix cache entries and tag indexes so they're easy to find (and flush)
// in Redis and don't collide with other keys.
const CACHE_PREFIX = "nextjs:cache:";
const TAG_PREFIX = "nextjs:tag:";
// App page/route entries carry their cache tags in this response header
// rather than in `ctx.tags` (which is only populated for fetch entries).
const NEXT_CACHE_TAGS_HEADER = "x-next-cache-tags";
// Cache entries aren't plain JSON: app page entries contain `Buffer`s
// (e.g. `rscData`) and a `Map` (`segmentData`). Plain `JSON.stringify` would
// turn a `Map` into `{}` and lose the buffer types, so we tag those values on
// the way out and rebuild them on the way back in.
function serialize(entry) {
return JSON.stringify(entry, (_key, value) => {
if (value instanceof Map) {
return { __type: "Map", value: Array.from(value.entries()) };
}
return value;
});
}
function deserialize(text) {
return JSON.parse(text, (_key, value) => {
if (value && value.__type === "Map") {
return new Map(value.value);
}
// `Buffer#toJSON()` produces `{ type: "Buffer", data: [...] }`.
if (value && value.type === "Buffer" && Array.isArray(value.data)) {
return Buffer.from(value.data);
}
return value;
});
}
// Next.js constructs the `cacheHandler` class once per request, so the Redis
// client must live at module scope: creating it in the constructor would open
// a new connection on every request and never close it.
const client = createClient({
url: process.env.REDIS_URL ?? "redis://localhost:6379",
// Fail commands immediately while the connection is down instead of queueing
// them until Redis is back.
disableOfflineQueue: true,
});
// Redis won't work without error handling. Do not throw here, otherwise the
// client won't reconnect after a connection drop.
client.on("error", (error) => {
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
console.warn("Redis client error:", error);
}
});
// Connecting to Redis during `next build` can cause issues, so we only connect
// at runtime.
const connection =
process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD
? Promise.resolve()
: client.connect().catch((error) => {
console.warn("Failed to connect to Redis:", error);
});
// `connect()` stays pending for as long as Redis is unreachable, so cap the
// wait: requests arriving during startup wait for the connection at most
// once, and while Redis is down every request is served uncached instead of
// blocking. The client keeps retrying in the background, so `isReady` flips
// back on its own once Redis is reachable again.
const CONNECT_TIMEOUT_MS = 1000;
const ready = Promise.race([
connection,
// `unref()` so this timer never keeps the process alive.
new Promise((resolve) => setTimeout(resolve, CONNECT_TIMEOUT_MS).unref()),
]);
// Resolve a connected client, or `null` when Redis is unavailable so the app
// keeps working (without a shared cache) instead of hanging or crashing.
async function getClient() {
await ready;
return client.isReady ? client : null;
}
module.exports = class CacheHandler {
constructor(options) {
this.options = options;
}
async get(key) {
const client = await getClient();
if (!client) return null;
let entry;
try {
entry = await client.get(CACHE_PREFIX + key);
} catch (error) {
// A connection dropping mid-request degrades to a cache miss.
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
console.warn("Redis get failed:", error);
}
return null;
}
return entry ? deserialize(entry) : null;
}
async set(key, data, ctx) {
const client = await getClient();
if (!client || !data) return;
// Collect tags from both sources: `ctx.tags` (fetch entries) and the
// `x-next-cache-tags` header (app page/route entries).
const headerTags = (data.headers?.[NEXT_CACHE_TAGS_HEADER] ?? "")
.split(",")
.filter(Boolean);
const tags = [...new Set([...(ctx?.tags ?? []), ...headerTags])];
// Let Redis auto-expire the entry when it carries a finite `expire`. Key
// the TTL on `expire`, never `revalidate`: past `revalidate` the entry is
// only stale (Next.js serves it while refreshing), so evicting it there
// would defeat that. ISR entries often omit `expire` entirely, in which
// case we set no TTL and rely on `revalidateTag` to invalidate.
const expire = ctx?.cacheControl?.expire;
const options = Number.isFinite(expire)
? { expiration: { type: "EX", value: Math.max(1, Math.ceil(expire)) } }
: {};
const value = serialize({ value: data, lastModified: Date.now(), tags });
try {
await client.set(CACHE_PREFIX + key, value, options);
// Index this key under each of its tags so `revalidateTag` can find it.
await Promise.all(tags.map((tag) => client.sAdd(TAG_PREFIX + tag, key)));
} catch (error) {
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
console.warn("Redis set failed:", error);
}
}
}
async revalidateTag(tags) {
const client = await getClient();
// Don't report success for a revalidation that never reached Redis: once
// Redis is back, every instance would serve the old entries again.
if (!client) {
throw new Error(
"Redis is unavailable, so the tag revalidation was not recorded",
);
}
// `tags` is either a single tag or an array of tags.
for (const tag of [tags].flat()) {
const tagKey = TAG_PREFIX + tag;
const keys = await client.sMembers(tagKey);
if (keys.length) {
await client.del(keys.map((key) => CACHE_PREFIX + key));
}
await client.del(tagKey);
}
}
// Used for an in-memory, per-request cache. Redis is the source of truth
// here, so there's nothing to reset between requests.
resetRequestCache() {}
};