DEV Community

Cover image for Cache invalidation is the dual-write problem
Rodrigo Nogueira
Rodrigo Nogueira

Posted on

Cache invalidation is the dual-write problem

Cache invalidation, in the form most backends actually meet it, is not a unique problem. It is the dual-write problem — the failure mode covered in the transactional outbox article — in different clothing:

// the dual-write problem, messaging edition
await db.insert(orders).values(input);        // 1. write the row
await kafka.send({ topic: 'order.placed' });  // 2. publish the event

// the dual-write problem, caching edition
await db.update(projects).set({ name });      // 1. write the row
await cache.del(`project:${id}`);             // 2. invalidate the cache
Enter fullscreen mode Exit fullscreen mode

Same bug, both times. If the process crashes between step 1 and step 2, the database and the second system disagree, and nothing reconciles them. In the messaging edition, consumers silently miss an event. In the caching edition, every instance keeps serving the old value. A database and a cache cannot commit atomically, for the same reason a database and a broker cannot.

Two identical flows side by side: a database write followed by

Messaging has a proven answer: the transactional outbox — one write, inside the transaction, with the second system driven from what committed. Caching mostly relies on TTLs.

The ecosystem today

What a survey of the ecosystem shows, before building anything new:

  • @nestjs/cache-manager (the official module, ~2M downloads/week) supports TTL, del, and clear. No tags, no cross-instance invalidation. Requests for richer features — an auto-invalidation strategy and hybrid multi-store caching — were both declined in the same month, with the same reply: "we'd encourage you to collaborate with the community on publishing it as an open source package."
  • Upstream, tag-based invalidation was requested in 2018 and shipped a first version in mid-2026 — a lazy version-check design, with no way to push an invalidation to other instances.
  • bentocache, the most architecturally complete Node cache (L1 memory + L2 store + a sync bus), supports Postgres as storage — but its invalidation bus requires Redis or MQTT. The coherence layer is the one piece that demands new infrastructure.
  • Rails 8 made a database-backed cache the default for new apps; Laravel 11 ships the database cache driver by default. Database-as-cache-infrastructure is mainstream — but neither framework pushes invalidations to in-memory tiers.

The missing piece: an in-memory cache per instance, kept coherent across instances through the database those instances already share, with the invalidation atomic with the write. That now exists as @stalefree/core, with a thin NestJS adapter, @nest-native/cache.

The same fix

On Postgres, pg_notify is transactional: a notification fired inside a transaction is delivered when the transaction commits, and silently dropped if it rolls back. That property removes the dual write. The invalidation is not a second system written after the database — it is part of the same commit:

await db.transaction(async (tx) => {
  await tx.update(projects).set({ name }).where(eq(projects.id, id));

  // both of these ride THIS transaction:
  await store.invalidateTagsInTx(tx, [`project:${id}`]); // shared L2 rows die with the commit
  await bus.publishInTx(tx, { tags: [`project:${id}`] }); // every instance's L1 evicts on commit
});
Enter fullscreen mode Exit fullscreen mode

A crash before the commit means nothing happened — no write, no invalidation, no disagreement. When the commit lands, Postgres itself delivers the eviction to every instance holding a LISTEN connection. There is no window where the row changed but the invalidation was lost, because there were never two writes.

One transaction containing the row update, the L2 delete, and the notify — on commit the eviction fans out to every instance's in-memory cache; on rollback everything is discarded

This is the outbox mechanism — pg_notify riding the business transaction — pointed at a different second system.

Tag-based invalidation

Exact-key invalidation breaks as soon as one mutation affects several cached reads: a renamed project appears in project:42, in org:7:projects, and in list views. Reads declare tags; mutations evict by tag:

const project = await cache.wrap(
  `org:${orgId}:project:${id}`,            // tenancy lives IN the key
  () => this.repo.findById(id),
  { ttlMs: 30_000, tags: [`org:${orgId}:projects`, `project:${id}`] },
);

// one call, after the mutation — evicts every carrier, on every instance
await cache.invalidateTags([`project:${id}`]);
Enter fullscreen mode Exit fullscreen mode

wrap is read-through with single-flight: concurrent misses for a key share one loader run. The L1 keeps a reverse tag→keys index, so a tag eviction is O(affected entries), not O(cache size).

Two correctness rules

1. Every entry has a TTL — the API rejects infinite ones. The bus is best-effort by design; a listener mid-reconnect misses a message. The TTL is the delivery backstop: a lost invalidation means stale-until-TTL, never stale-forever. A cache whose correctness depends on a bus message arriving is a design bug, and the API makes that cache impossible to build.

2. Take atomicity where it is available. The transactional path is the strongest guarantee on offer and costs one line inside an existing transaction. Outside Postgres — or outside a transaction — invalidateTags still evicts locally, deletes L2 rows, and publishes, falling back from "atomic with the write" to "fire-and-forget with the TTL backstop."

Coherence by deployment shape

The bus is a seam; the implementation follows the deployment:

Deployment Bus Extra infrastructure
One process none — coherent by definition none
Several processes, one machine (the classic app + worker split) a unix-domain-socket mesh with crash re-election none
Several machines sharing Postgres LISTEN/NOTIFY, transactional publish none

The middle row covers SQLite completely: processes sharing a SQLite file are on one machine by definition, so the socket bus is the entire multi-process story for that dialect. The bottom row is the summary: cross-machine cache coherence, carried by a database already in production.

Three deployment shapes: one process, several processes on one machine joined by a socket, and several machines sharing one Postgres

Degradation: on the socket bus, a frame too large for the wire is not dropped — it degrades on the send side to a full clear on every receiver (colder, never staler). On Postgres, large invalidations are chunked across multiple NOTIFY payloads.

Non-goals

  • Not a distributed cache, and not a Redis replacement for hot-path KV at massive scale. The target is the common case: expensive reads, tagged, evicted when the data changes.
  • Not exactly-once delivery of invalidations — the TTL backstop covers loss.
  • Single-flight is per-process; cross-instance stampede control is out of scope.
  • Cached values are held by reference in L1 — treat them as immutable.

A running example

The pattern runs as the newest chapter of the nest-native reference app: projects.* and activity.list are cached at the API seam, mutations invalidate by tag, and the activity feed's projection invalidates its own cache at the write site. The e2e spec runs with a deliberately long ten-minute TTL — when it asserts that a mutation is visible in the next read within seconds, only invalidation can make that pass, provably not expiry.

Cache invalidation remains hard in the general case. The version most backends face — a cached read, a database write, an instance serving the old value — is the dual-write problem, and the dual-write problem has a known fix: write to one system, and let the system that can commit atomically drive the other.


Feedback and issues welcome — especially for cases the pattern does not cover.

Top comments (0)