Skip to content

RedisCacheHandler JSDoc example leaks Redis connections when used as cacheHandler #45

Description

@tobiasvdorp

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:

  1. Option 1 — extend with URL in super()causes connection leaks for cacheHandler
  2. 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):

  1. Remove or explicitly mark Option 1 as incorrect for cacheHandler — do not pass a URL string in super() on every request.
  2. Recommend Option 2 as the primary pattern for cacheHandler with Redis.
  3. Clarify scope — this class is for cacheHandler (ISR); data cache should use createCacheHandler({ type: "redis" }).
  4. Soften/remove "Connection pooling via ioredis" unless scoped to "one client instance per process".
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions