System Design · Caching

Cache Invalidation at Scale: Redis Streams, SSE, and Varnish PURGE

Splitting "who caches" from "who invalidates" into two services — connected by a durable signal instead of a direct call — turns cache invalidation from a tangle of point-to-point calls into one clean, replayable event.

The problem: two hard things in computer science

The old joke is that there are only two hard problems in computer science: cache invalidation, naming things, and off-by-one errors. Cache invalidation earns its spot because caching is easy in isolation — the hard part is telling every layer that's holding a copy of the data that it's now stale, without either missing a copy (stale reads) or purging so aggressively you lose the point of caching in the first place.

That gets harder as soon as more than one service is involved. If the service that writes content is also the one that has to know about every Varnish node, every SSE listener, and every downstream cache, it ends up doing infrastructure work that has nothing to do with its actual job. The fix is to split it into two services with one job each:

  • Caching service — owns the data, serves cached reads, and knows the instant something changes
  • Invalidation service — owns nothing but the fan-out: turning one "this changed" signal into every purge and notification that needs to happen because of it

The two are connected by a signal, not a function call — so the caching service doesn't need to know or care who's listening.

Architecture at a glance

📝
Caching Service
Serves cached reads. On write, publishes a change event — nothing more.
📡
Redis Stream
Durable, ordered, replayable log of change events — the decoupling point.
🧹
Invalidation Service
Consumes the stream, decides what's affected, fans the signal out.

The caching service never talks to Varnish or any SSE client directly — it only ever emits one event.

🚪
Fan-out target 1 — Varnish fleet

Targeted PURGE / BAN requests sent to every edge node caching the affected URL, so the next reader always gets fresh content.

📶
Fan-out target 2 — SSE subscribers

A live broadcast to anything holding its own in-memory or local cache — admin dashboards, edge workers, other service instances — so they evict without polling.

Why Redis Streams, not plain Pub/Sub

Redis Pub/Sub is tempting because it's simple — PUBLISH and SUBSCRIBE, done. But it's fire-and-forget: if the invalidation service is mid-deploy or briefly disconnected when the event fires, that event is gone forever, and whatever it should have purged stays stale with no way to know. Streams fix exactly that gap.

PUB/SUB
STREAMS
At-most-once — a missed subscriber loses the event
At-least-once — events persist until acknowledged
No replay — history is gone the instant it's published
Full replay by ID — a restarted consumer picks up where it left off
No consumer groups — every subscriber gets every message
Consumer groups — scale invalidation workers horizontally, each event handled once

The signal: what actually gets published

The caching service publishes one small, self-describing event — nothing about who needs to act on it:

XADD cache:invalidations * \
  entity   "article" \
  id       "12489" \
  urls     "/news/12489,/news/12489/amp,/api/articles/12489" \
  tags     "section:news,author:42" \
  reason   "content-updated"

The urls field drives the Varnish purge; the tags field lets the invalidation service do broader sweeps later (e.g. "purge everything by author 42") without the caching service needing to enumerate every affected URL up front.

Fan-out target 1: Varnish PURGE and BAN

Varnish gives you two invalidation primitives, and the invalidation service picks whichever fits the event:

  • PURGE — removes one exact object from cache immediately. Cheap, precise, the default choice when the event lists specific URLs.
  • BAN — marks anything matching a pattern as stale; a background lurker thread reclaims it lazily. Used for tag-based sweeps where enumerating every URL isn't practical.
sub vcl_recv {
    if (req.method == "PURGE") {
        if (client.ip !~ purge_acl) {
            return (synth(405, "Not allowed"));
        }
        return (purge);
    }
}
# Invalidation service, per URL in the event:
curl -X PURGE https://edge-01.internal/news/12489
curl -X PURGE https://edge-02.internal/news/12489

# Tag-based sweep via BAN:
varnishadm ban "obj.http.x-tags ~ section:news"

Fan-out target 2: SSE for the live broadcast

Varnish handles the HTTP cache layer, but plenty of things hold their own copies too — an admin dashboard showing "last purged," an edge worker keeping a local LRU cache, another service instance with an in-process cache. Polling Redis for changes wastes cycles and adds latency; a raw WebSocket is more than the job needs, since nothing ever needs to talk back up the connection. Server-Sent Events fits the actual shape of the problem: one-directional, plain HTTP, automatic reconnect built into every browser and most HTTP clients.

// Invalidation service — SSE endpoint
app.get('/events/invalidations', (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  });

  const send = (id, data) => {
    res.write(`id: ${id}\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };

  const lastId = req.headers['last-event-id'] || '$';
  subscribeFromStreamId(lastId, (id, event) => send(id, event));

  req.on('close', () => unsubscribe());
});

The client's Last-Event-ID header is the piece that matters most: if a browser tab or edge worker drops its connection for ten seconds, reconnecting sends that header automatically, and the invalidation service resumes the Redis Stream read from exactly that ID — no gap, no missed purge, no client-side bookkeeping required.

Putting it together

An editor updates an article. The caching service writes through to its own cache, then XADDs one event to the Redis Stream — synchronously, so publish failure is visible immediately instead of silently lost.
The invalidation service, running as a Redis Streams consumer group, reads the event. If two workers are running for throughput, the group guarantees only one of them handles it.
It resolves the affected URLs and tags, then fires PURGE requests at every Varnish node in the fleet in parallel.
In the same handler, it broadcasts the event over SSE to every open connection — dashboards update their "last purged" indicator, edge workers evict the URL from their local cache.
Once every purge succeeds, it XACKs the stream entry. If the service crashes mid-purge, the un-acked entry is still claimable by the next worker on restart — the purge simply runs again.

Handling the failure modes

Purges must be idempotent. Because Streams guarantee at-least-once delivery, the same event can be processed twice — once before a crash, once again after restart. A PURGE that runs twice against an already-cold cache is a harmless no-op; a fix that assumed exactly-once delivery would corrupt state on the second run.

  • Invalidation service restarts mid-purge — the stream entry stays unacknowledged and is reclaimed by the consumer group on restart; the purge simply re-fires.
  • An SSE client disconnects briefly — it reconnects with Last-Event-ID and the invalidation service replays everything it missed straight from the Redis Stream.
  • One Varnish node is down — its purge call fails independently of the others; a dead node serving stale content until it recovers is an acceptable trade-off against blocking every other node's purge on one failure.

Key takeaways

Separate the service that knows data changed from the service that acts on it — connect them with an event, not a direct call.
Redis Streams beat Pub/Sub here specifically because invalidation can't afford to silently drop a missed event.
Varnish's PURGE vs. BAN split maps directly onto "I know the exact URLs" vs. "I know the pattern" — pick per event, not globally.
SSE fits one-directional server-push better than WebSockets when clients never need to talk back — and Last-Event-ID gives you resumability for free.
Every purge handler must tolerate running twice — at-least-once delivery makes that a certainty, not an edge case.

Untangling cache consistency across services that were never designed to talk to each other?

Get in touch