Home / Articles

Keeping Redis Caches Honest in Node.js: Invalidation Without Stale Reads

Compare TTL, delete-on-write, pub/sub and versioned-key invalidation for Redis in Node.js, see the race each leaves open, and build a fail-open cacheAside wrapper.

Publisher: REACTAPP.TOP16 min read3730 words

This article is published in English.

Sources: Redis Caching in Node.js, Part 2: Invalidation That Doesn’t Lie

Picture a user who uploads a new profile photo, gets a success response, reloads the page and still sees the old picture. They try again with the same result, and a few minutes later the new photo shows up on its own. The bug report that follows is almost impossible to reproduce, because Redis is still handing out a copy of the row that the database has already replaced. A slow database at least returns the correct answer; a fast cache that returns the wrong one is worse than no cache at all. This guide focuses on the write path of a Redis cache in Node.js: four ways to invalidate entries, when each justifies its cost, the race condition each one leaves open, a reusable cacheAside() wrapper that combines read-path protections with invalidation, and the mistakes that most frequently keep outdated values alive.

The read side of caching, meaning cache-aside loading, stampede protection and what happens when a hot key expires, is a separate topic; our overview of Redis caching fundamentals and pitfalls covers the basics. None of those techniques prevent stale data after a write, which is what the rest of this article addresses.

Four invalidation strategies

The strategies below are listed roughly in the order you should consider them, from cheapest to most involved.

Strategy 1: rely on the TTL alone

Give every entry an expiry, accept that a value may be up to that many seconds old, and record that number somewhere the product team can see it.

This is the correct answer for more data than most teams assume. Catalog copy, category hierarchies, public profile text and hourly exchange rates rarely need to be current to the millisecond. When a value may lag by five minutes, an expiry of 300 seconds is all the code you need:

await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 300);

The profile photo is a different story. The person who made the change expects to see it immediately, and an expiry cannot guarantee that. For user-edited data, you need to invalidate when the write happens.

Strategy 2: delete the entry on write

After updating the database, remove the corresponding cache key. The next read misses, loads the new row and repopulates the cache:

async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  await redis.del(`user:${id}`);

  return updated;
}

Two details make this work correctly.

Delete rather than overwrite. Since you already hold the updated value, writing it into Redis with SET looks like a free optimization. The problem appears with two concurrent writers: their SET commands can arrive at Redis in the opposite order from their database commits, leaving the older write cached until the TTL runs out. Deletes are order-independent; whichever arrives last, the result is an empty key.

Write to the database before deleting. Reversing the order opens a wide window:

// Wrong: delete before write
await redis.del(`user:${id}`);                     // 1. cache is empty
const updated = await db.users.update(id, patch);  // 2. write the row

// Between 1 and 2, a reader can miss the cache, read the OLD row,
// and put it back. The cache now holds the old value until its TTL.

Between the delete and the update, any reader that misses the cache will load the old row and put it right back.

Writing first narrows the window without eliminating it. The following timeline shows how a slow reader can still restore stale data:

Reader                              Writer
------                              ------
GET user:42        -> miss
SELECT user 42     -> old row
                                    UPDATE user 42 (commit)
                                    DEL user:42
SET user:42 <- old row

The reader fetched the row before the writer committed, then stored it after the writer's delete. That old value remains cached until the TTL expires. The sequence needs a slow reader and a writer that lands in exactly the wrong gap, so it is rare, but on a key read thousands of times per minute, rare can easily mean daily.

The standard remedy is a second delete, scheduled for slightly longer than a normal load takes:

async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  const key = `user:${id}`;
  await redis.del(key);
  // Catches a reader that loaded the old row before the commit.
  setTimeout(() => redis.del(key).catch(() => {}), 500).unref();
  return updated;
}

The unref() call keeps the pending timer from holding the process open during shutdown, and the empty catch ensures a failed delete never produces an unhandled rejection.

Two further cautions apply:

  • Transactions. If the update runs inside a database transaction, run the delete once the commit has succeeded, outside the transaction's callback. Deleting before the commit recreates the wrong ordering shown above.
  • Crashes. If the process dies between committing and deleting, the invalidation is simply lost, and only the TTL will eventually correct it. When losing an invalidation is unacceptable, trigger invalidation from a change-data-capture feed instead, for example Debezium on Postgres or MySQL, or change streams on MongoDB. The invalidation then depends on the commit itself rather than on a request handler surviving long enough.

Strategy 3: broadcast invalidations to local memory caches

When every process reads straight from the shared Redis, one DEL is seen by all of them at once, and pub/sub adds nothing.

It becomes necessary once processes keep a small in-memory layer in front of Redis, such as a Map or lru-cache, to avoid a network round trip for very hot keys. When instance A deletes a key from Redis, instance B's local copy is unaffected. Publishing the invalidated key lets every instance drop its own copy:

// Every instance runs this at startup.
// A subscribed connection can't run other commands, hence duplicate().
const sub = redis.duplicate();
await sub.subscribe('cache:invalidate');
sub.on('message', (_channel, key) => localCache.delete(key));

// Whoever writes to the database:
async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  await redis.del(`user:${id}`);
  await redis.publish('cache:invalidate', `user:${id}`);

  return updated;
}

Note the duplicate() call: a Redis connection in subscriber mode cannot issue ordinary commands, so the subscription needs a connection of its own.

If you run Redis 6 or newer, look at its built-in client-side caching support (CLIENT TRACKING) before rolling your own. The server remembers the keys a connection has fetched and notifies that connection when any of them is modified. Support varies between client libraries, so confirm that yours implements it.

Strategy 4: versioned keys

When serving an old value is a correctness problem rather than a cosmetic one, embed a version number in the key:

async function getOrder(id) {
  const version = (await redis.get(`order:${id}:ver`)) ?? '0';
  const key = `order:${id}:v${version}`;

const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
  const order = await db.orders.findById(id);
  if (!order) return null;

  await redis.set(key, JSON.stringify(order), 'EX', 86400);

  return order;
}

async function updateOrder(id, patch) {
  const updated = await db.orders.update(id, patch);
  await redis.incr(`order:${id}:ver`);

  return updated;
}

Reads fetch the current version from a counter key and build the data key from it. Writes simply increment the counter.

Run the strategy 2 race again under this scheme. A slow reader fetches the outdated row; meanwhile the writer commits and bumps the counter; then the reader caches what it fetched. Its write goes to order:42:v0, a key that no reader will look up again, so the outdated copy sits on an abandoned key until it expires. That immunity is what you pay the extra complexity for.

The rules that keep it safe:

  • Only writers touch the counter, and only with INCR. If a read initializes it with something like a SET to 0, that can clobber an increment made a moment before and resurrect the previous version.
  • Keep the counter alive. Leave it without an expiry, or give it one much longer than the data entries. A counter that expires starts over at zero, which makes any leftover v0 or v1 entries visible once more.

The costs are two round trips per read instead of one, plus memory held by old versions until they expire. That trade is justified for orders, balances, stock levels and anything related to billing, and excessive for most other data.

Choosing a strategy

A compact comparison:

  • TTL only: trivial to implement; data may be up to one TTL old; use it when some staleness is acceptable and you can state the number.
  • Delete on write: low complexity; fresh immediately, apart from a rare race that lasts until the TTL; the default for anything users edit.
  • Pub/sub: medium complexity; keeps in-memory copies fresh as long as the message arrives; needed once you add a local cache in front of Redis.
  • Versioned keys: high complexity; stale rebuilds are never read; reserved for data where an old value means wrong money or wrong inventory.

For the typical service, expiries combined with delete-on-write are sufficient, plus the delayed extra delete for heavily read keys that users can change. Add pub/sub on the day you introduce an in-process cache, and keep versioned keys for the handful of entities where a wrong answer has a financial cost.

A reference cacheAside() wrapper

Caching logic scattered through request handlers is where these rules get forgotten, so it pays to write the logic once and import it everywhere. The module below uses ioredis and combines:

  • fail-open Redis calls that degrade to the database instead of failing requests,
  • stale-while-revalidate, so slightly old values are served while a refresh runs,
  • per-process single-flight, so concurrent callers in one process share one load,
  • a cross-process lock with a compare-and-delete unlock, so instances do not all rebuild the same key,
  • invalidation via pub/sub plus a delayed second delete.
// cache.js
const Redis = require('ioredis');
const { randomUUID } = require('node:crypto');
const logger = require('./logger');

// Fail fast, so a dead Redis makes requests slower instead of hanging them.
const redis = new Redis(process.env.REDIS_URL, {
  commandTimeout: 250,
  maxRetriesPerRequest: 1,
});

// A subscribed connection can't run other commands, so it gets its own,
// with default options so it keeps reconnecting.
const sub = new Redis(process.env.REDIS_URL);
const CHANNEL = 'cache:invalidate';

// Compare-and-delete, as in Part 1: never release a lock you no longer own.
const UNLOCK = `
  if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
  end
  return 0
`;

// ---- Invalidation fan-out to in-process caches ----
const listeners = new Set();
sub.subscribe(CHANNEL).catch(err => logger.error({ err }, 'cache: subscribe failed'));
sub.on('message', (_channel, key) => {
  for (const fn of listeners) {
    try { fn(key); } catch (err) { logger.warn({ err, key }, 'cache: listener failed'); }
  }
});

function onInvalidate(fn) {
  listeners.add(fn);
  return () => listeners.delete(fn);
}

// ---- Fail-open reads and writes ----
async function readEntry(key) {
  const raw = await redis.get(key).catch(() => null);
  if (!raw) return null;
  try { return JSON.parse(raw); } catch { return null; }
}

async function writeEntry(key, value, { freshSec, staleSec }) {
  const now = Date.now();
  const entry = {
    value,
    freshUntil: now + freshSec * 1000,
    staleUntil: now + (freshSec + staleSec) * 1000,
  };

  await redis.set(key, JSON.stringify(entry), 'EX', freshSec + staleSec).catch(() => {});
  return value;
}

// ---- Single-flight: per process first, then across processes ----
const inflight = new Map();

function once(slot, fn) {
  const existing = inflight.get(slot);
  if (existing) return existing;
  const p = fn().finally(() => inflight.delete(slot));
  inflight.set(slot, p);

  return p;
}

async function rebuild(key, loader, opts, { wait }, attempt = 0) {
  const lockKey = `lock:${key}`;
  const token = randomUUID();

  // If Redis is down the SET rejects. Treat that as "got the lock" and just load.
  const got = await redis.set(lockKey, token, 'NX', 'EX', opts.lockSec).catch(() => 'OK');
  if (got === 'OK') {
    try {
      // Another instance may have finished a rebuild while we were getting the lock.
      const entry = await readEntry(key);
      if (entry && Date.now() < entry.freshUntil) return entry.value;
      return await writeEntry(key, await loader(), opts);
    } finally {
      await redis.eval(UNLOCK, 1, lockKey, token).catch(() => {});
    }
  }

  // Someone else holds the lock. A background refresh can leave the work to them.
  if (!wait) return undefined;
  await new Promise(r => setTimeout(r, 50));
  const entry = await readEntry(key);
  if (entry && Date.now() < entry.freshUntil) return entry.value;
  if (attempt >= 20) return loader(); // about a second of waiting: do it ourselves

  return rebuild(key, loader, opts, { wait }, attempt + 1);
}

// ---- Public API ----
async function cacheAside(key, loader, { freshSec = 60, staleSec = 300, lockSec = 5 } = {}) {
  const opts = { freshSec, staleSec, lockSec };
  const entry = await readEntry(key);
  const now = Date.now();
  if (entry && now < entry.freshUntil) return entry.value;
  if (entry && now < entry.staleUntil) {
    // Stale but servable: return it now, refresh once in the background.
    once(`refresh:${key}`, () => rebuild(key, loader, opts, { wait: false }))
      .catch(err => logger.warn({ err, key }, 'cache: refresh failed'));
    return entry.value;
  }

  // Cold, or past staleUntil: the caller waits for a load.
  return once(`load:${key}`, () => rebuild(key, loader, opts, { wait: true }));
}

async function invalidate(key, { redeleteMs = 500 } = {}) {
  await redis.del(key).catch(() => {});
  await redis.publish(CHANNEL, key).catch(() => {});

  // Second delete catches a rebuild that read the old row before the write committed.
  setTimeout(() => redis.del(key).catch(() => {}), redeleteMs).unref();
}
module.exports = { cacheAside, invalidate, onInvalidate, redis };

Using it from application code takes one call on the read path and one on the write path:

const { cacheAside, invalidate } = require('./cache');
const user = await cacheAside(`user:${id}`, () => db.users.findById(id), {
  freshSec: 60,
  staleSec: 300,
});

async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  await invalidate(`user:${id}`);
  return updated;
}

Several design choices in the wrapper are not obvious, so read it through before adopting it.

Separate single-flight slots for refresh and load

Background refreshes and blocking loads are keyed under different prefixes, refresh: and load:. When a background refresh loses the race for the cross-process lock, it intentionally gives up with undefined, since some other instance is handling the rebuild. If a caller who actually needs a value shared that slot, it would also receive nothing.

A second cache read after taking the lock

Once rebuild() holds the lock, it reads the entry one more time. A different process could have completed the rebuild during the wait, and running the loader twice would waste a query.

Bounded waiting when the lock is taken

A caller that cannot get the lock polls every 50 ms for up to 20 attempts, about one second, and then runs the loader itself. A few duplicate queries are preferable to a queue of callers that never drains if the lock holder stalls.

Fail-open lock acquisition

If Redis is unavailable, SET NX rejects, the .catch converts the rejection into 'OK', and every caller proceeds to the loader. That keeps the service up, but it also means the database absorbs the full load until Redis recovers. Confirm that it can.

Tuning the defaults

  • lockSec should exceed the loader's p99 latency, so the lock does not expire mid-rebuild.
  • redeleteMs should also exceed your slowest loader. 500 ms suits a single indexed query; a dashboard aggregation that takes four seconds needs more.

What is deliberately left out

Probabilistic early expiration is not included. Stale-while-revalidate already removes the expiry cliff for frequently read keys, and combining the two in one entry format complicates reasoning about when a refresh happens. For keys with highly variable rebuild costs, a probabilistic check can replace the freshUntil comparison in cacheAside().

Where each concern lives in the code

  • Miss, load and store: cacheAside() and rebuild()
  • Stale-while-revalidate: the freshUntil and staleUntil fields of each entry
  • Per-process single-flight: once()
  • Cross-process lock and safe unlock: rebuild() and the UNLOCK Lua script
  • Fail-open behavior: .catch() on every Redis call, plus commandTimeout
  • Delete on write with a delayed second delete: invalidate()
  • Local cache invalidation: invalidate() and onInvalidate()

Common pitfalls that leave stale or broken data

These are ordered roughly by how much damage they tend to cause.

Treating Redis errors as fatal

A bare await on a Redis call throws as soon as Redis is unreachable:

const cached = await redis.get(key); // throws when Redis is unreachable

A cache is an optimization. An outage should cost you latency, never a wave of 500 responses. The minimal fix is to convert failures into cache misses:

const cached = await redis.get(key).catch(() => null);

With ioredis defaults, the .catch alone is not enough. When the connection drops, the client buffers commands and keeps retrying them, by default up to 20 times per request, instead of failing fast, which leaves requests stuck waiting on a server that is gone. Configuring commandTimeout and a small maxRetriesPerRequest, as the wrapper's client does, makes them reach the loader promptly. Each request still pays a few hundred milliseconds during an outage, which is tolerable for short incidents. During a prolonged outage, a circuit breaker that stops calling Redis after several consecutive errors wins that time back.

Invalidating without a TTL

Deletes get lost: a process crashes after committing, a deployment kills a request midway, a broadcast arrives while a subscriber is mid-restart. Without a TTL, any of these leaves a stale key in Redis indefinitely. Give every key an expiry, even a long one such as a day. Deletes keep entries current most of the time; the expiry is the backstop that ensures an incorrect entry cannot live forever.

Storing values without serializing them

Redis stores strings, so passing a plain object silently corrupts the entry:

await redis.set(key, JSON.stringify(value)); // correct
await redis.set(key, value);                 // an object is stored as "[object Object]"

Serialize with JSON.stringify when writing and parse with JSON.parse when reading. Wrap values in an envelope such as { value, freshUntil, staleUntil } from the start, even if only value is used at first; adding stale-while-revalidate later then does not force you to handle two formats at once.

Make a conscious choice about null results too. The wrapper stores them, a technique called negative caching, so repeated requests for a nonexistent row no longer reach the database, which is generally desirable. Where a row may exist shortly after a miss, for example a freshly inserted record queried against a replica that lags behind, cache misses with a shorter expiry.

Unbounded keys built from user input

A key derived directly from a query string looks harmless:

const key = `search:${req.query.q}`;

Each unique search string produces its own key, and any client can mint millions. What follows is governed by maxmemory-policy. With allkeys-lru, the flood pushes out genuinely hot entries and hit rates crater. With noeviction, which self-hosted Redis uses unless configured otherwise, memory runs out and writes are refused with OOM errors.

Hashing the query does not help: a hash limits the length of each key, not the number of keys. Instead, canonicalize the input (trim whitespace, lowercase, order the parameters) so equivalent searches map to one key, keep these entries short-lived, and cache only the searches that are frequent or costly. Configure maxmemory and an eviction policy on purpose instead of inheriting defaults.

Sharing one Redis instance across environments

If staging and production share an instance, both environments read and write one user:42. Dedicated instances are the real solution; as a fallback, namespace every key, which ioredis can do for you:

const redis = new Redis(process.env.REDIS_URL, {
  keyPrefix: `${process.env.APP_ENV}:`,
});

Base the prefix on a purpose-built variable like APP_ENV, because staging commonly runs with NODE_ENV=production as well. Note that keyPrefix covers keys only, not pub/sub channels, so add the environment to the invalidation channel name yourself.

Mutating a shared cached value

Because of single-flight, callers that overlap in time all get one shared object rather than separate copies:

const a = cacheAside('user:42', load); // both wait on one promise
const b = cacheAside('user:42', load);

(await a).name = 'changed';
console.log((await b).name); // 'changed'

Anything fetched from Redis is parsed into a fresh object per caller, so the problem is confined to the shared-promise case, which is why it shows up rarely and resists reproduction. Consider cached values immutable, or structuredClone() one before editing it. Freezing returned values with Object.freeze in development makes strict-mode code throw at the offending assignment rather than corrupt data quietly.

Key takeaways

  • Put an expiry on every key: deletes keep data current, expiries bound how long a mistake can survive.
  • Start with cache-aside plus delete-on-write: commit to the database, then delete (never overwrite) the key, outside any transaction. Schedule a repeat delete for busy keys that users modify.
  • Fail open, and configure the client to fail fast instead of queueing commands while disconnected.
  • Shield popular keys when reading; stale-while-revalidate combined with single-flight handles most traffic patterns.
  • Use pub/sub only for in-process caches, and keep local TTLs short because messages can be dropped.
  • Reserve versioned keys for data where staleness is a correctness bug.
  • Limit how many keys can exist; hashing only limits their length.
  • Centralize caching in one wrapper and tune its defaults to your loaders' measured latencies.

One final caveat: all of this presumes one standalone Redis server. Sentinel and Cluster deployments replicate asynchronously, and a failover may discard the latest writes, including the lock keys the wrapper depends on. Account for that before relying on these locks for anything stronger than best-effort coordination.