All articles

Making Next.js 16 tag revalidation work across several instances

On a decoupled Drupal site, editors expect a published change to appear on the front end within seconds. Drupal knows exactly which content changed, so the natural design is for Drupal to call a revalidation endpoint on the Next.js app with a list of cache tags, and for Next.js to drop only the entries carrying those tags.

That works on a single process. In production the app runs as several instances behind a load balancer, and there it does not. This is why, and what fixed it.

Revalidation is per process by default

When Drupal calls the endpoint, the load balancer delivers that request to one instance. revalidateTag() records the invalidation in that process's memory. Every other instance still believes its cached copy is fresh and keeps serving it until its own timer expires.

Nothing errors, and the logs show a successful revalidation. An editor refreshing the page can see new content, then old content again, depending on which instance answers.

Next.js 16 has two cache layers, and both need fixing

Next.js 16 has two separate cache handler hooks, and they are easy to confuse.

  • cacheHandlers (plural) stores entries created by the "use cache" directive when cacheComponents is on. This is where tagged data lookups live.
  • cacheHandler (singular) is the older incremental cache that stores full rendered routes.

Replacing only one of them leaves the other serving stale pages. The data cache can be correctly invalidated while the full-route cache on another instance still returns yesterday's HTML.

A shared tag manifest in Redis

The fix was to make "when was this tag last invalidated?" a shared fact instead of a per-process one. Revalidations write a timestamp per tag into a Redis hash. Before handling a request, each instance re-syncs that hash into a local map, so the check stays cheap.

// Called by revalidateTag(): record the invalidation for every instance
async function expireTags(tags) {
  const now = Date.now();
  const fields = tags.flatMap(tag => [tag, String(now)]);
  await redis.hset('cache:tags', ...fields);
}

// Is a cached entry older than the latest invalidation of any of its tags?
function isStale(entryTags, entryTimestamp) {
  return entryTags.some(tag => (localTags.get(tag) ?? 0) > entryTimestamp);
}

The "use cache" handler stores entries in Redis too, so every instance shares one copy. For the full-route cache it was enough to extend the built-in file system cache and add one check: on get, look up the entry's tags in the shared manifest and return nothing if any changed after the entry was written. Next.js then renders fresh and caches the result again.

Two details that are not in the docs

revalidatePath() does not reach "use cache" entries

Drupal sometimes sends a path rather than tags, for example when a URL alias changes. revalidatePath() clears the route, but data cached with "use cache" on that route survives. The answer was to tag cached lookups with a tag derived from the path as well, and revalidate that tag alongside the path.

Only the first kind of revalidation in a request reaches the handler

Calling revalidateTag() and then revalidatePath() in the same request handler silently dropped the second one. Moving the path revalidation into after(), which runs once the response is sent, gave it its own phase:

import { after } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';

for (const tag of tags) revalidateTag(tag, { expire: 0 });

after(() => {
  revalidatePath(path, 'page');
});

Degrade instead of failing

A cache that takes the site down when Redis has a bad minute is worse than no cache. Three rules kept it safe:

  • No Redis during the build. next build runs where there is no Redis, so the handler checks the build phase and stays in memory. Builds never wait on a connection.
  • A short deadline on every command. A socket that looks connected but never answers would otherwise hang the request. With a deadline of a few hundred milliseconds, the handler gives up and falls back.
  • A bounded in-memory fallback, limited by bytes. Cached pages vary in size by orders of magnitude, so a maximum entry count does not bound memory. The fallback store tracks total bytes, with an entry count only as a backstop.

Testing what editors actually do

Unit tests against a Redis mock covered the handler itself. The confidence came from end-to-end tests that act like an editor. Each one changes something in Drupal and asserts the front end reflects it:

  • a page scheduled to publish or unpublish
  • a menu item archived
  • a page renamed so a redirect is created
  • a media item unpublished

Those are exactly the cases where a stale instance shows up, and exactly the ones editors report.

Takeaways

  • With more than one instance, assume revalidateTag() reaches only the instance that received the request, until you make tag state shared.
  • In Next.js 16, check both cacheHandlers and cacheHandler. Fixing one is not enough.
  • Tag path-based data explicitly if the CMS can invalidate by path.
  • Run a second kind of revalidation inside after().
  • Give the cache a fallback, a deadline and a byte budget, so it can only make the site faster, never take it down.