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).
Next.js Redis Cache Integration Example
This example is tailored for self-hosted setups and demonstrates how to back Next.js caching with Redis, using no third-party adapter. It wires up both of Next.js's cache handler APIs against the redis client, storing everything in a single Redis instance:
cacheHandler(singular) — the ISR / incremental cache for pages, route handlers, and images. Implemented incache-handler.jswithget,set,revalidateTag, andresetRequestCache.cacheHandlers(plural) — the'use cache'family. Theremotehandler inremote-cache-handler.jsstores'use cache: remote'entries withget,set,refreshTags,getExpiration, andupdateTags.
Both are configured in next.config.js, which enables cacheComponents: true (required for 'use cache') and sets cacheMaxMemorySize: 0 so Redis is the single shared source of truth across instances.
Check out this repository that contains a comprehensive setup for Kubernetes.
How to use
Execute create-next-app with npm, Yarn, or pnpm to bootstrap the example:
npx create-next-app --example cache-handler-redis cache-handler-redis-app
yarn create next-app --example cache-handler-redis cache-handler-redis-app
pnpm create next-app --example cache-handler-redis cache-handler-redis-app
Once you have installed the dependencies, you can begin running the example Redis server by using the following command:
docker compose up -d
Then, build and start the Next.js app as usual. The custom cache handlers are only used for production builds (next start), so run:
npm run build
npm run start
To see the cache logs, set NEXT_PRIVATE_DEBUG_CACHE=1 when starting the app.
How it works
The /[timezone] page renders a mostly static shell and, inside it, a 'use cache: remote' function (getCurrentTime) that fetches the current time. This exercises both handlers at once:
-
ISR cache (
cache-handler.js): stores the prerendered page entries as JSON under anextjs:cache:prefix, and tracks which keys belong to each tag in a Redis set (nextjs:tag:<tag>).revalidateTagdeletes every key associated with a tag. -
Remote cache (
remote-cache-handler.js): stores each'use cache: remote'entry under anextjs:use-cache:prefix (the streamed value is base64-encoded). Tag revalidation is timestamp-based:updateTagsrecordsnextjs:use-cache-tag:<tag>= now. On a hit,getcompares the entry's own tags (fromcacheTag) against those timestamps and reports a miss if any is newer, andgetExpirationreports the latest time for the route's soft tags so Next discards older entries. Clicking Revalidate callsupdateTag('time-data'), which regenerates the remote entry. -
Building without Redis: both handlers skip connecting during
next build(they checkNEXT_PHASE) and degrade gracefully when Redis is unavailable, so the app still builds and runs, just without a shared cache. Revalidation is the exception:revalidateTagandupdateTagsthrow while Redis is unavailable, because an invalidation that never reached Redis would be lost, and the old entries would be served again once Redis is back. -
Redis server setup: ensure your Redis server is running before starting the app. Configure the connection with
REDIS_URL(defaults toredis://localhost:6379).
Note: This example fetches the current time from a public API (
timeapi.io) purely as sample data to demonstrate caching and revalidation. It is included for learning purposes only. If you reuse it, review and respect that API's terms of use and rate limits, and swap in your own data source for real applications.
Revalidation: paths, soft tags, and advisory parameters
The Revalidate button (revalidate-from.tsx) calls updateTag('time-data'), but that is only one entry point into the remote handler's tag machinery. Two related capabilities are handled without extra code, and two handler parameters are intentionally unused:
-
revalidatePathworks without extra code. Next.js derives implicit soft tags from the route path (prefixed_N_T_, e.g._N_T_/[timezone]) and routes path revalidation through the same tags asrevalidateTag. ArevalidatePath('/…')call therefore reachesupdateTags(['_N_T_/…']), and the next read callsgetExpiration(['_N_T_/…']). Because both methods operate generically over any tag string, path revalidation propagates across instances through Redis exactly like an explicit tag. -
get(cacheKey, softTags)ignoressoftTagsby design. A handler can honor soft tags one of two ways: implementgetExpirationto return the most recent revalidation timestamp (Next.js then performs the soft-tag staleness check itself), or returnInfinityfromgetExpirationand compare timestamps insideget. This handler does the former, so it never needs to read thesoftTagsargument. -
updateTags(tags, durations)ignoresdurationsby design.durations({ expire }) is only populated when a revalidation call carries acacheLifeprofile, such asrevalidateTag(tag, 'max'); it defers a tag's expiration into the future instead of expiring it immediately. This example performs only immediate revalidation, sodurationsis alwaysundefined— equivalent to recording the current timestamp, which is whatupdateTagsalready does.
See the Soft Tags section of the cacheHandlers documentation for the full tag architecture.
Documentation
For detailed information, see the official Next.js documentation:
cacheHandler(ISR / incremental cache) ↗cacheHandlers('use cache') ↗'use cache: remote'↗- Self-hosting: configuring caching ↗
Development and Production Considerations
-
The provided
compose.yamlis intended for local development. For production deployment, refer to the official Redis installation and management guidelines. -
Inspecting the cache: The
redis-stackimage bundles RedisInsight on port8001. Open it in your browser (linked from the example UI) to watch both thenextjs:cache:(ISR) andnextjs:use-cache:(remote) keys appear and expire. -
Clearing Redis Cache: To clear the Redis cache, use RedisInsight Workbench or the following CLI command:
docker exec -it cache-handler-redis redis-cli 127.0.0.1:6379> flushall OK