Summary
The JSDoc example on RedisCacheHandler shows two patterns for wiring Redis with Next.js. Option 1 (passing a URL/config in super() inside an exported class) is unsafe for cacheHandler because Next.js instantiates that class per request, opening a new ioredis connection on every request. Under load this causes write EPIPE errors and can hit Redis client limits.
Option 2 (module-scoped ioredis client passed into super()) avoids the leak. The docs should not present Option 1 without a warning, or should recommend Option 2 for cacheHandler.
Problem we encountered
We use @mrjasonroy/cache-components-cache-handler with Redis in a multi-instance Next.js 16 app:
cacheHandler — ISR / full-page cache
cacheHandlers — "use cache" data cache via createCacheHandler({ type: "redis" })
After some time under load we saw repeated errors:
[RedisCacheHandler] Redis connection error: Error: write EPIPE
errno: -32,
code: 'EPIPE',
syscall: 'write'
Redis server logs looked normal. The issue was on the app side: too many client connections.
Our cacheHandlers setup did not show this — createCacheHandler() creates one Redis client at module load. The leak came from cacheHandler following the documented Option 1 pattern:
export default class NextCacheHandler extends RedisCacheHandler {
constructor(options) {
super({
...options,
redis: process.env.REDIS_URL, // string → new Redis() on every request
});
}
}
Root cause
Next.js calls new CacheHandler() per request for cacheHandler.
In RedisCacheHandler, when redis is a string or config object, the constructor runs new Redis(...). When redis is an existing client, it reuses that instance (didCreateClient = false).
So:
| Pattern |
Redis connections |
Safe for cacheHandler? |
Option 1 — URL/config in super() each request |
1 per request |
No |
Option 2 — module-scoped client in super() |
1 per worker |
Yes |
| Singleton — return shared handler from constructor |
1 per worker |
Yes |
The leak is from Option 1, not from creating multiple handler wrapper objects.
What fixed it for us
Either of these works:
Option 2 — module-scoped client (simplest):
import Redis from "ioredis";
import { RedisCacheHandler } from "@mrjasonroy/cache-components-cache-handler";
const redisClient = new Redis(process.env.REDIS_URL);
export default class CacheHandler extends RedisCacheHandler {
constructor(options) {
super({ ...options, redis: redisClient });
}
}
Singleton handler (also works):
import { RedisCacheHandler } from "@mrjasonroy/cache-components-cache-handler";
/** @type {RedisCacheHandler | undefined} */
let sharedHandler;
export default class CacheHandler {
constructor(options) {
if (!sharedHandler) {
sharedHandler = new RedisCacheHandler({
...options,
redis: process.env.REDIS_URL,
});
}
return sharedHandler;
}
}
We're not sure which is the best long-term API — a dedicated factory (like createCacheHandler for the data cache) might be cleaner — but Option 2 is the smallest change and matches what the docs already hinted at in "Option 2".
Current docs issue
The JSDoc on RedisCacheHandler (in packages/cache-handler/src/handlers/redis.ts) currently shows:
- Option 1 — extend with URL in
super() → causes connection leaks for cacheHandler
- Option 2 — module-scoped client → correct for
cacheHandler
Option 1 is listed first and reads like the default approach. It also says "Connection pooling via ioredis", which is misleading here: one ioredis instance pools commands over one connection, but a new instance per request still opens a new TCP connection each time.
The example also mixes cache-handler.mjs and data-cache-handler.mjs in the header comment, which adds confusion — the data cache should use createCacheHandler / createRedisDataCacheHandler, not this class directly.
Suggested fix
Docs-only is enough (runtime already supports existing clients):
- Remove or explicitly mark Option 1 as incorrect for
cacheHandler — do not pass a URL string in super() on every request.
- Recommend Option 2 as the primary pattern for
cacheHandler with Redis.
- Clarify scope — this class is for
cacheHandler (ISR); data cache should use createCacheHandler({ type: "redis" }).
- Soften/remove "Connection pooling via ioredis" unless scoped to "one client instance per process".
- Optionally link to the official Next.js Redis cache handler example (
CacheHandler.onCreation() solves the same per-request instantiation issue).
Environment
@mrjasonroy/cache-components-cache-handler: latest on main at time of report
next: 16.x
ioredis: peer dependency
- Redis: 7.x
- Deployment: multiple Next.js instances sharing one Redis
Summary
The JSDoc example on
RedisCacheHandlershows two patterns for wiring Redis with Next.js. Option 1 (passing a URL/config insuper()inside an exported class) is unsafe forcacheHandlerbecause Next.js instantiates that class per request, opening a new ioredis connection on every request. Under load this causeswrite EPIPEerrors and can hit Redis client limits.Option 2 (module-scoped ioredis client passed into
super()) avoids the leak. The docs should not present Option 1 without a warning, or should recommend Option 2 forcacheHandler.Problem we encountered
We use
@mrjasonroy/cache-components-cache-handlerwith Redis in a multi-instance Next.js 16 app:cacheHandler— ISR / full-page cachecacheHandlers—"use cache"data cache viacreateCacheHandler({ type: "redis" })After some time under load we saw repeated errors:
Redis server logs looked normal. The issue was on the app side: too many client connections.
Our
cacheHandlerssetup did not show this —createCacheHandler()creates one Redis client at module load. The leak came fromcacheHandlerfollowing the documented Option 1 pattern:Root cause
Next.js calls
new CacheHandler()per request forcacheHandler.In
RedisCacheHandler, whenredisis a string or config object, the constructor runsnew Redis(...). Whenredisis an existing client, it reuses that instance (didCreateClient = false).So:
cacheHandler?super()each requestsuper()The leak is from Option 1, not from creating multiple handler wrapper objects.
What fixed it for us
Either of these works:
Option 2 — module-scoped client (simplest):
Singleton handler (also works):
We're not sure which is the best long-term API — a dedicated factory (like
createCacheHandlerfor the data cache) might be cleaner — but Option 2 is the smallest change and matches what the docs already hinted at in "Option 2".Current docs issue
The JSDoc on
RedisCacheHandler(inpackages/cache-handler/src/handlers/redis.ts) currently shows:super()→ causes connection leaks forcacheHandlercacheHandlerOption 1 is listed first and reads like the default approach. It also says "Connection pooling via ioredis", which is misleading here: one ioredis instance pools commands over one connection, but a new instance per request still opens a new TCP connection each time.
The example also mixes
cache-handler.mjsanddata-cache-handler.mjsin the header comment, which adds confusion — the data cache should usecreateCacheHandler/createRedisDataCacheHandler, not this class directly.Suggested fix
Docs-only is enough (runtime already supports existing clients):
cacheHandler— do not pass a URL string insuper()on every request.cacheHandlerwith Redis.cacheHandler(ISR); data cache should usecreateCacheHandler({ type: "redis" }).CacheHandler.onCreation()solves the same per-request instantiation issue).Environment
@mrjasonroy/cache-components-cache-handler: latest on main at time of reportnext: 16.xioredis: peer dependency