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

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.