All articles

A Next.js 16 Redis cacheHandler that fails fast when Redis is down

To share rendered pages between several Next.js 16 instances, point cacheHandler in next.config.js at a class that stores entries in Redis. Three details decide whether it behaves in production: connect lazily because next build also constructs the handler, prefix every key with the build ID, and give Redis commands a short timeout with no offline queue so a Redis outage turns into a cache miss rather than a hung request.

Why share the page cache at all?

With a Next.js site that regenerates pages on demand, each instance writes its regenerated pages to its own disk. That works with one instance. With several, the same page can differ between instances until each has been revalidated, and a restarted instance starts again from whatever was baked into its image. Putting the cache in Redis gives every instance, including a freshly restarted one, the same copy.

Next.js lets you replace the incremental cache with your own class. In Next 16 you set cacheHandler to a module path and export a class with get, set, revalidateTag and resetRequestCache. I started from a ready-made Redis cache handler package and ended up replacing it with about fifty lines of ioredis, mostly because I wanted to control exactly how the handler behaves when Redis is unhealthy.

What a minimal Redis cacheHandler looks like

This is a fresh, trimmed example of the shape. The next sections explain each decision in it.

// cache-handler.js
const { readFileSync } = require("fs");
const { join } = require("path");
const Redis = require("ioredis");

// Next creates a handler per request, so share the client and prefix.
let redis;
let prefix;
let connected;

module.exports = class RedisCacheHandler {
  constructor({ serverDistDir }) {
    this.serverDistDir = serverDistDir;
  }

  async store() {
    if (!redis) {
      const buildId = readFileSync(join(this.serverDistDir, "../BUILD_ID"), "utf8");
      prefix = `next:${buildId}:`;
      redis = new Redis({
        host: process.env.REDIS_HOST || "localhost",
        port: 6379,
        lazyConnect: true,
        enableOfflineQueue: false,
        connectTimeout: 1000,
        commandTimeout: 1000,
      });
      redis.on("error", (e) => console.error("Page cache:", e.message));
      connected = redis.connect().catch(() => {});
    }
    await connected;
    return redis;
  }

  async get(key) {
    try {
      const store = await this.store();
      const entry = await store.get(prefix + key);
      return entry ? JSON.parse(entry) : null;
    } catch {
      return null; // a cache failure is a miss, never an error
    }
  }

  async set(key, value) {
    const store = await this.store();
    await store.set(prefix + key, JSON.stringify({ value, lastModified: Date.now() }));
  }

  async revalidateTag() {}
  resetRequestCache() {}
};

And in the config:

// next.config.js
module.exports = {
  cacheHandler: require.resolve("./cache-handler.js"),
};

Why connect lazily?

next build loads the cache handler as well as the running server does. At build time two things are usually untrue: the BUILD_ID file does not exist yet, and Redis is not reachable from the build environment. A handler that reads the file or opens a connection in its constructor, or at module load, breaks the build.

The fix is to do neither until the first real call. The store() method above reads the build ID and creates the client on first use, and later calls reuse the same promise. Because Next creates a handler object per request, the client and prefix live in module scope rather than on the instance, otherwise you would open a new connection for every request.

Why put the build ID in the key?

A cached page is HTML that references that build's hashed assets. If a new deployment started serving the previous build's HTML from Redis, the page would point at JavaScript chunks that no longer exist. Prefixing every key with the build ID means a new build simply never sees the old build's entries, and there is nothing to invalidate on deploy. Old entries are unreachable and can be left to expire.

This only works if the build ID is stable across instances of one build and different between builds. Set generateBuildId to something deterministic, such as the git commit SHA, rather than relying on the random default, otherwise instances built separately from the same commit would not share keys.

What happens when Redis is down?

This is the part that is easy to get wrong. By default ioredis queues commands while it is disconnected and keeps retrying the connection. A command issued during an outage can wait through many reconnect attempts before it rejects. For a page cache that is the worst possible behaviour: every request waits on a cache that cannot answer, and the site looks down because Redis is.

Three options change that:

  • enableOfflineQueue: false makes commands reject immediately when the client is not connected, instead of queueing them.
  • commandTimeout bounds a command sent to a connection that is up but unresponsive.
  • connectTimeout bounds the initial connection attempt.

With those set to about a second, and with get catching every error and returning null, a Redis outage means a cache miss, and Next renders the page. The site gets slower but stays up. I deliberately let set throw, because a failed write is worth seeing in the logs.

The same lesson applied to a second place in the same project, a small helper that cached some global data in Redis from application code. It had the same unbounded wait while Redis was down. A commandTimeout of a second fixed it the same way. If your site talks to Redis from more than one place, check each client for this.

What I deliberately left out

The handler does not implement tag invalidation: revalidateTag is empty. That is a choice that suits a site where pages are regenerated by path, and it will not suit one that depends on tag-based invalidation. If you need tags, you have to record which keys belong to which tag and delete them on revalidateTag.

It also does not set expiry on entries. If your Redis instance has a memory limit, set an eviction policy such as allkeys-lru, otherwise old builds' keys accumulate. A cache that can evict is fine here, since a missing entry only costs a render.

Finally, I only enable the handler in environments where Redis exists, by checking an environment variable in the config. Locally Next falls back to its file-based cache, which keeps development simple.

Key takeaways

  • A custom Next.js cacheHandler is constructed during next build, so it must not read the build output or connect to Redis until its first real call.
  • Prefixing cache keys with the build ID stops a new deployment serving HTML that references the previous build's assets, and removes any need to flush Redis on deploy.
  • With ioredis, set enableOfflineQueue: false and short connect and command timeouts so a Redis outage becomes a cache miss instead of a hung request.
  • Treat every failed cache read as a miss and return null; reserve errors for writes you want to see in the logs.
  • Decide explicitly whether you need tag invalidation, because an empty revalidateTag silently opts you out of it.