Generation-fenced Redis cache for Node.js: it blocks the stale write that lands after your invalidation, by rejecting it atomically inside Redis.
- Zero runtime dependencies. Redis client is an optional peer.
- One narrow job. Use it next to the cache library you already have.
- The guarantee is exercised against a real Redis server, not a mock — see 30-second proof.
Any backend that combines a Redis cache with explicit invalidation (delete the key on mutation) can hit this timeline:
request A mutation redis
| | |
t0 -------+ GET works:list -> MISS | |
| start slow compute (3s) | |
| | |
t1 -------+------------------------------+ DEL works:list --------> key removed (correct)
| | |
t2 -------+ compute finished ------------+---- SET works:list ----> pre-t1 data is back
| | |
-------+------------------------------+--------------------------+---->
every reader now gets pre-mutation data, until the next
invalidation or the TTL expires
The write at t2 carries data that was read before the mutation, and it lands after the invalidation. It
resurrects exactly what the invalidation just removed.
The reason no amount of deleting fixes this: delete-based invalidation can only delete keys that exist at the moment it runs. It cannot stop a write that has not arrived yet.
Two terms, used consistently throughout this README and the source:
- Stale resurrection — the phenomenon: invalidated data reappears in the cache and is served as if fresh.
- Invalidation-crossing write — the cause: a write whose computation started before an invalidation and
whose
SETcompletes after it.
The window is exactly as wide as your slowest computation, and it opens on every mutation that races one. It is rare per request, structural in aggregate, and close to impossible to diagnose from the outside: the cache contains a value that was true at some point, just not now.
| Technique | Why the invalidation-crossing write still lands |
|---|---|
| TTL | Bounds how long the stale value is served. Does not prevent the resurrection. |
| stale-while-revalidate | Background refreshes make slow computations more frequent, which widens the crossing window. |
| single-flight / request coalescing | Removes duplicate concurrent computations. Says nothing about a computation crossing an invalidation. |
| Tag or pattern deletion | Same limit as a single DEL: it clears what exists now, not what arrives next. |
| Distributed lock around the computation | Serializes computations, and makes invalidation queue behind the lock. Trades one problem for another. |
These are all worth having. None of them is about ordering a write against an invalidation, which is the only thing that stops stale resurrection.
Generation fencing:
- A monotonic counter per namespace. One Redis integer key, the namespace's generation.
- The generation is captured when the computation starts — before the cache read, before the loader runs.
- Invalidation bumps the generation first, then sweeps the keys (
INCR, thenSCAN+UNLINK). - Every write is an atomic compare-and-set. A Lua script reads the counter and writes the value only if the generation still matches the one the computation captured. If it moved, the script writes nothing and returns 0. The whole check-and-write is one Redis command, so no invalidation can interleave with it.
The bump-then-sweep order is what makes invalidation complete, and it gives two independent safety nets:
- A write that arrives after the bump is rejected by the compare-and-set — its generation is behind.
- A write that slipped in just before the bump is removed by the sweep that follows.
The same timeline as above, fenced:
t0 request A: generation() -> 7, MISS, slow compute starts
t1 invalidate(): INCR gen -> 8, then SCAN + UNLINK the namespace
t2 request A finishes, writes with generation 7
Lua: current generation is 8, 8 != 7 -> return 0, nothing written
-> next reader misses and computes post-mutation data
An invalidation-crossing write is therefore never observable: it is either rejected by the compare-and-set or removed by the sweep. A rejected write is not an error — it means the value was known to be stale before it was ever visible, which is the outcome you wanted.
npm install cache-fence redisNode.js >= 22. redis (node-redis) >=5.0.0 <7 is an optional peer dependency — bring your own client, or
adapt another one through the five-method interface in Redis client adapter.
cache-fence itself has no runtime dependencies.
import { createClient } from 'redis';
import { createFencedCache } from 'cache-fence';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const cache = createFencedCache({ redis, namespace: 'catalog' });
// Read-through. The generation is captured before the cache read; the write-back at the
// end is fenced against it. Concurrent callers for the same key share one computation.
const works = await cache.getOrCompute('works:list', () => db.listPublishedWorks(), {
ttlMs: 60_000,
});
// On mutation: bump the generation, then sweep the namespace.
await cache.invalidate();Stale-while-revalidate is one option away. The background refresh goes through the same fence:
const works = await cache.getOrCompute('works:list', () => db.listPublishedWorks(), {
ttlMs: 60_000,
staleTtlMs: 600_000, // serve stale up to 10 min while refreshing behind the fence
});Note what is not in this code: generation values. On the high-level path you never read, pass, or store one.
Capture, comparison and rejection happen inside getOrCompute. The low-level API exists for the cases it does
not cover, and is described below.
The claim is testable, so test it:
git clone https://github.com/emulette/cache-fence.git && cd cache-fence
npm install
npm test # requires Docker: spins up a real Redis via testcontainerstest/race.test.ts does both halves against a real Redis server:
- It reproduces stale resurrection with an unfenced cache — plain
GET/SET/DEL, the pattern every cache library implements — and asserts that the deleted value is back in Redis after the late write. - It runs the same interleaving through cache-fence and asserts the write was rejected: the compare-and-set returns 0, the key stays absent, and the next read recomputes.
No mocked Redis, no mocked Lua.
Everything the package exports:
import {
createFencedCache, // factory
FencedCache, // the class, if you prefer `new`
FENCED_CACHE_ERRORS, // error message constants
} from 'cache-fence';
import type {
FencedCacheOptions,
GetOrComputeOptions,
InvalidationResult,
FencedCacheErrorEvent,
FencedCacheOperation,
RedisCommands,
Serializer,
} from 'cache-fence';| Option | Type | Default | Meaning |
|---|---|---|---|
redis |
RedisCommands |
— | Client, or adapter. See below. |
namespace |
string |
— | Groups keys under one generation counter. Non-empty, and must not contain *, { or } (it becomes a cluster hash tag). Invalid values throw from the constructor. |
scanCount |
number |
1000 |
COUNT hint for the invalidation SCAN. |
unlinkBatchSize |
number |
1000 |
Keys per UNLINK call during the sweep. |
serializer |
Serializer |
JSON | { serialize(value: unknown): string; deserialize(raw: string): unknown }. |
onError |
(event: FencedCacheErrorEvent) => void |
— | Receives errors the cache suppressed. Exceptions thrown by the handler are ignored. |
Key layout, for the namespace catalog:
{catalog}:gen generation counter (outside the data prefix, so a sweep cannot delete it)
{catalog}:k:f:<key> fresh entry
{catalog}:k:s:<key> stale entry (only when staleTtlMs is set)
The sweep matches {catalog}:k:*, which is why the counter lives outside that prefix.
options: { ttlMs: number; staleTtlMs?: number }. Both must be positive integers; staleTtlMs enables
stale-while-revalidate and should exceed ttlMs.
Sequence: capture the generation → read the fresh entry → (if SWR) read the stale entry and, on a hit, serve it
immediately while a fenced background refresh runs → otherwise run loader → write back through the fence.
- Single-flight: concurrent callers for the same key in the same process share one computation. In-process deduplication only — it is not a distributed lock, and does not claim to be.
- Loader errors propagate. They are yours; the cache does not swallow them.
- A fence rejection is silent and correct. The computed value is still returned to the caller, it is just not cached. No error, no event.
- A cached
nullis a hit and comes back asnull. - With SWR, the background refresh reuses the generation captured when the serving request started, so a refresh spanning an invalidation is rejected too. Refreshes are deduplicated per key, so they cannot stack, and the stale entry is only written when the fresh write was accepted.
- No cancellation or timeout contract. The loader runs to completion — there is no
AbortSignalsupport, and concurrent callers joined to a flight share its outcome. If the computation needs a deadline, enforce it inside the loader.
Reads the fresh entry. undefined means a miss (a cached null returns null). Stale entries are not
consulted here — only getOrCompute serves those. Redis and deserialization errors are thrown.
The escape hatch, for computations getOrCompute doesn't wrap: write paths, jobs, or your own composition. This
is the library's actual primitive.
const gen = await cache.generation(); // 0 for an untouched namespace
const value = await expensiveComputation();
const accepted = await cache.setIfGeneration('works:list', value, gen, { ttlMs: 60_000 });
if (!accepted) {
// An invalidation crossed the computation. The value was dropped; this is the fence working.
}| Method | Returns | Notes |
|---|---|---|
generation() |
Promise<number> |
Current generation; 0 if the counter does not exist. Throws if the counter holds a non-integer. |
setIfGeneration(key, value, generation, { ttlMs }) |
Promise<boolean> |
true if written, false if the fence rejected it. Serialization and Redis errors are thrown, unlike on the getOrCompute path. |
bumpGeneration() |
Promise<number> |
INCR on the counter, returns the new generation. Invalidates every generation captured before this call, without touching keys. |
invalidate() |
Promise<InvalidationResult> |
{ generation, deletedKeys }. Bumps, then sweeps. |
invalidate() performs bump-then-sweep in that order, deduplicating keys returned more than once by SCAN and
unlinking them in batches. Failures are thrown rather than reported through onError: a half-invalidated
namespace is something the caller must be able to see and retry.
Every message this library can produce, as functions. Nothing else in the package builds message strings, so
tests can assert against these instead of hardcoding text: invalidNamespace, invalidGeneration,
invalidTtl, unserializableValue.
The whole Redis surface is five operations, in node-redis v5+ signatures:
export interface RedisCommands {
get(key: string): Promise<string | null>;
incr(key: string): Promise<number>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
scanIterator(options: { MATCH: string; COUNT: number }): AsyncIterable<string | string[]>;
unlink(keys: string[]): Promise<number>;
}A node-redis client satisfies this as-is — pass it straight to createFencedCache. Any other client, wrapper or
connection pool is roughly ten lines. The shape, using ioredis names (illustrative, not a shipped adapter):
const adapter: RedisCommands = {
get: (key) => io.get(key),
incr: (key) => io.incr(key),
eval: (script, { keys, arguments: args }) => io.eval(script, keys.length, ...keys, ...args),
scanIterator: ({ MATCH, COUNT }) => io.scanStream({ match: MATCH, count: COUNT }),
unlink: (keys) => io.unlink(...keys),
};scanIterator may yield single keys or batches; both are handled.
On a Redis Cluster, four of the five operations route themselves: get and incr are single-key, eval
follows its first key (and both keys the script touches share the {namespace} hash tag), and unlink only
ever receives keys from one namespace, hence one slot. The one operation that needs cluster-specific work is
scanIterator: SCAN cursors are per-node state, so the adapter must walk every master and drain each node's
iterator in turn — scanning a single node would silently leave stale keys on the other masters during an
invalidation sweep. test/cluster-fixture.ts contains a complete node-redis
cluster adapter following this pattern.
When Redis is unreachable, the correct answer is a slow answer, not a fast wrong one. getOrCompute degrades to
computing without caching and reports through onError. It never serves something it cannot verify.
| Situation | Behavior | onError event |
|---|---|---|
| Generation read fails | Run loader, return the value, cache nothing (an unfenceable write is not attempted) |
operation: 'generation' |
| Fresh read fails | Run loader, return the value, skip the write-back |
operation: 'get' |
| Stale read fails (SWR) | Fall through to loader; the fenced write-back is still attempted |
operation: 'get' |
| Write fails (Redis error or serialization) | Value is still returned to the caller | operation: 'setIfGeneration' |
| Background SWR refresh fails | Nothing surfaces to any caller; never an unhandled rejection | operation: 'swrRefresh' |
| Fence rejects the write | Value returned, not cached | none — this is not an error |
loader throws |
Propagates to the caller | none |
const cache = createFencedCache({
redis,
namespace: 'catalog',
onError: ({ operation, key, error }) => logger.warn({ operation, key, error }, 'cache degraded'),
});Two consequences worth stating explicitly:
- The low-level API and
invalidate()throw instead of degrading. Callers of a primitive must see failures. - SWR refreshes are fenced like any other write. Serving stale never means writing unverified data: if an invalidation crossed the refresh, the refresh's write is rejected and the entry simply expires.
- Not another cache framework. No layers, no drivers, no decorators, no key catalog. Competing on a feature matrix would mean becoming the thing this exists to avoid.
- Use it alongside your cache library. cache-manager, cachified, bentocache and friends solve stampedes and SWR well, and none of them fences invalidation-crossing writes. Keep them for the bulk of your cache; put cache-fence on the hot spots where stale data is a bug rather than a delay — inventory, permissions, pricing, publication state.
- Zero runtime dependencies. The Redis client is an optional peer dependency. Nothing else is installed.
- Redis-only, by design. The guarantee is server-side atomicity via Lua. A generic storage adapter cannot promise that, so there isn't one.
- Redis Cluster: the counter and the data keys share the
{namespace}hash tag, so they land in the same slot and the compare-and-set never hitsCROSSSLOT. The test suite verifies this against a live three-master cluster — including aCROSSSLOTnegative control and a cross-master invalidation sweep (CLUSTER_TESTS=1 npm run test:cluster). Failover, resharding and replica reads are not covered. The clusterscanIteratoradapter pattern is described in Redis client adapter. - One generation per namespace. Invalidation is namespace-wide by construction. Use narrower namespaces for narrower blast radius.
- In-process single-flight. Deduplicates within one process. Cross-process stampede control is not this library's job; the fence is orthogonal to it.
The mechanism has been proven for over a decade. What did not exist is a general-purpose package for Redis and Node.
- Facebook memcache leases (NSDI 2013) — the same idea: a token issued on miss, invalidated on write, used to reject stale set-backs. Implemented inside the memcached server, so not portable to Redis.
- Martin Kleppmann's fencing tokens (2016) — the same ordering argument, formalized for distributed locks and shared storage rather than caches.
- Uber CacheFront — Lua compare-and-set rejection keyed on row timestamps, operated at very large read volume. Internal infrastructure, not a published library.
cache-fence packages that mechanism for Redis and Node, with an explicit compare-and-set API rather than an implicit one.
The price of the guarantee, measured (node bench/run.mjs: Redis 7 in local Docker, sequential operations on
one connection — absolute numbers are dominated by loopback round-trip time and vary by machine; the ratios
are the signal):
| operation | p50 µs | mean µs | vs unfenced (p50) |
|---|---|---|---|
setIfGeneration (fenced write, Lua CAS) |
89.6 | 91.9 | +5.6% |
plain SET with TTL (baseline) |
84.8 | 87.5 | — |
getOrCompute cache hit (2 round trips) |
164.0 | 168.9 | +100.4% |
raw GET (baseline, 1 round trip) |
81.8 | 84.3 | — |
- A fenced write stays one round trip: the ~6% is the Lua interpreter plus a server-side counter
GET. - A fenced cache hit pays a second round trip for the generation read — the design cost of the guarantee,
roughly 2× a raw
GETon loopback. - Invalidating a 10,000-key namespace takes ~10 ms (~1M keys/s). Note that
SCANtraverses the whole keyspace, not just the namespace, so sweep time scales with total database size.
Methodology, caveats and reproduction: bench/.
v0.1.x, pre-1.0. The API surface is deliberately small, but it may still change before 1.0.
The fencing mechanism is derived from a pattern running in a production backend; this package is new, and does not yet have a production track record of its own. What can be checked today is the guarantee itself: every claim in this README is exercised by the test suite against a real Redis server — and a real Redis Cluster.
Known limitations:
getOrComputehas no cancellation or timeout contract: the loader can run unboundedly, there is noAbortSignal, and concurrent callers joined to a flight share its outcome.- Cluster failover, resharding and replica reads are untested — see Design constraints.
Issues and PRs are welcome, particularly reproductions of stale resurrection in real systems.
Licensed under either of
- MIT license (LICENSE-MIT)
- Apache License, Version 2.0 (LICENSE-APACHE)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.