Next.js5 min read

revalidateTag() Works Locally and Fails in Production: Fixing Next.js Cache Sync Across Multiple Nodes

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE
Published 13 August 20265 min read

Why revalidateTag() and revalidatePath() silently stop working once your Next.js App Router app runs on more than one server instance, and how to fix it.

Abhishek Pandey

Abhishek Pandey

Full-Stack & AI Product Engineer

Server Action revalidation is one of those Next.js features that works perfectly on localhost and then quietly breaks the moment you deploy to anything with more than one server instance. You call revalidateTag('user-profile'), the UI updates instantly on your machine, you ship it, and a week later someone reports that half their users see the update immediately while the other half are stuck looking at old data for minutes.

That's not a flaky bug. It's three separate caching layers behaving exactly as designed, just not in the way a single-node dev environment ever exposes. Here's what's actually happening at each layer, and what fixes it.

Multi-node Data Cache drift: one revalidation call, three servers, one gets the memo

Next.js ships with an in-memory Data Cache by default, and "in-memory" is the whole problem once you're running Kubernetes pods, ECS tasks, or an edge cluster instead of a single process. Here's the sequence: a user hits a Server Action that mutates a row in Postgres and calls revalidateTag('user-profile'). Node A, the one that happened to handle the request, purges its own local copy of that tag. Nodes B and C never got the memo — they keep serving stale cached JSON or HTML to whoever the load balancer routes to them next.

The fix isn't a workaround, it's the missing piece: a shared cache handler that all your nodes read from and write to, instead of each keeping its own private in-memory copy.

// next.config.mjs
import { CacheHandler } from '@neshca/cache-handler';

export default {
  cacheHandler: process.env.NODE_ENV === 'production'
    ? require.resolve('./cache-handler.js')
    : undefined,
};

Point that custom handler at Redis or Memcached, and a single revalidateTag() call invalidates the key centrally, once, for every instance — not just whichever one happened to answer the request.

The browser doesn't know your server revalidated anything

This is the layer that trips people up because it feels like it should just work: revalidateTag() purges the server-side Data Cache, full stop. It says nothing to the user's browser, which is holding its own separate in-memory Router Cache built up as they've navigated around your app. If they mutate data and then navigate back to a page they'd already visited, Next.js can serve them the pre-fetched, now-stale page tree straight from that Router Cache — the server-side purge never gets a chance to matter.

Two things close this gap:

  • Call router.refresh() in the Client Component after the Server Action resolves. This is what actually tells the browser to throw away its cached page tree and re-fetch fresh Server Components. Skipping it is the single most common reason people report "revalidation isn't working" when the server side is actually fine.
  • Tune staleTimes in your Next.js config so dynamic routes aren't being cached client-side more aggressively than you intended in the first place.

Revalidating before the database write actually lands

This one's a straightforward ordering bug, but it's easy to write by accident because both calls look async and both get awaited eventually:

// ANTI-PATTERN: Revalidating before transaction commit completes
export async function updateProfile(formData: FormData) {
  const dbPromise = db.user.update({ ... });

  // Revalidation fires while DB write is still in flight!
  revalidateTag('user-profile');
  await dbPromise;
}

revalidateTag() fires immediately, before dbPromise has resolved. If anything re-renders the route in that window, Next.js goes and fetches the record again — and gets the pre-mutation value, because the write hasn't landed yet. You've just re-cached the stale data on purpose, seconds after telling the cache to refresh.

The fix is just discipline about ordering: await the write, then revalidate.

// PATTERN: Strictly ordered mutation and revalidation
export async function updateProfile(formData: FormData) {
  // 1. Await database write confirmation
  await db.user.update({
    where: { id: userId },
    data: { name: formData.get('name') },
  });

  // 2. Issue revalidation only after write succeeds
  revalidateTag('user-profile');

  return { success: true };
}

Nothing clever here. revalidateTag() just needs to be the thing that happens after the database confirms the write, not a fire-and-forget call sitting next to it.

Where each fix belongs

Issue Symptom Production fix
In-memory Data Cache drift UI updates for some users on refresh, stays stale for others Deploy a centralized Redis (or Memcached) cache handler
Stale client navigation Server Action succeeds, but the client route still shows old data Call router.refresh() right after the Server Action resolves
Database race condition Data gets re-cached stale immediately after the Server Action await the DB write before calling revalidateTag()

These aren't three variations on the same bug — they're three different caches (server Data Cache, browser Router Cache, and your own async ordering) that all need to agree before a revalidation actually shows up on screen. Get a shared cache handler into production and you've solved the layer that only shows up under real multi-instance load, which is exactly the one local development can never warn you about.

Tags:#Next.js#App Router#Server Actions#Caching#Redis

Related Articles

View All Articles ↗