Skip to content

Build1 publisher2 min readPublished

Invalidating a cache tag on one Next.js instance leaves the other one serving sold-out stock

A dev.to postmortem traces a 2 AM rollback to per-instance caches and rebuild timers, and the part of the fix that actually crosses instances is a cacheHandler writing invalidation timestamps to shared storage.

The Engineer · Build desk

Illustration accompanying Invalidating a cache tag on one Next.js instance leaves the other one serving sold-out stock

What happened

  • A dev.to post blames a stale inventory dashboard on instances holding different local caches and rebuild timers, with a rebuild on one racing another's stale cache while users hit the stale one.
  • The post's recommended default is revalidateTag for shared read-heavy content, with updateTag reserved for write flows that need read-your-own-writes inside Server Actions.
  • For production it prescribes a custom cacheHandler whose updateTags and refreshTags hooks persist invalidation timestamps in shared storage, named as Redis or DynamoDB.
  • Its verification step is a two-instance staging run where one instance receives the webhook and the others must stop serving stale data without a large spike in origin requests.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Until invalidation state is shared, whether a customer sees sold-out stock depends on which instance the load balancer happened to pick.
  • cost The remedy puts a shared store and a hand-written cache handler into the invalidation path, and the platform team has to run both for a deployment chosen to avoid managed infrastructure.
  • decision Each call site now decides who waits: the writer's request blocks on immediate expiry, or the reader gets a stale response while the data regenerates behind it.

The webhook lands on exactly one instance. That instance runs `revalidateTag('product:123:inventory', 'max')`, which marks the tag stale in its own Data Cache and serves the stale value while it regenerates [4]. The sibling instance received no request. It answers from its own cache, because on a single instance `revalidateTag` updates local state [9].

The post's example caches with `next: { tags: [productTag(id), productsListTag()], revalidate: 3600 }` [8]. Read that config line literally and it sets the ceiling on the damage: an instance that never hears about the invalidation is entitled to serve its copy for 3600 seconds, which is 60 minutes [14]. The dev.to post's author wrote that the incident ended in "a 2 AM rollback and a big trust hit" [3].

Reaching for `revalidatePath` instead would have made the scope worse. The post says it is useful when one route corresponds to one record, then calls it a blunt instrument [6]: it "carpet-bombs route segments and layouts", forcing many pages to cold-start, it can cause origin spikes when many pages are revalidated at once, and it couples invalidation to URL structure [7]. Tags are content-addressed, so the invalidation names the data and survives a route refactor [7].

In my view the ordering matters for anything behind a load balancer. The handler hooks go in first: `updateTags` persists the invalidation timestamp, and every instance refreshes that state before it serves new requests [10]. Centralising helpers like `productTag` and `productInventoryTag` in `lib/cache-tags.ts` prevents typos and tag rot [13]. A correctly spelled tag still only marks state on the instance that received the webhook [9].

One layer sits past the server cache. `revalidateTag(tag, 'max')` marks the Data Cache stale and does not clear the client Router Cache for users with active tabs, so the post calls `router.refresh()` after the server action completes [11]. For the writer's own read, `updateTag` expires immediately and blocks, so the next render reads fresh data [5].

This is one practitioner's postmortem, and the post does not say how many instances were running.

What to watch

  • Whether Next.js ships a shared cacheHandler for self-hosted multi-instance deploys instead of leaving updateTags and refreshTags to the team.
  • Whether the post's author publishes the handler implementation and the origin-request numbers from the two-instance staging run.
  • Whether updateTag's blocking expiry gets measured latency figures under write load, which the post does not provide.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories