Ch. 6 · Node.js

Node.js Caching Layers and Invalidation

Choose where to cache, set TTLs, avoid caching errors, and protect a cold cache with single-flight loading.

~2 min readadvancedupdated Oct 5, 2026

Caching trades staleness for speed. A cache can live in the process, in a shared store such as Redis, or at the CDN, and each choice has different invalidation and consistency properties. The two hardest parts are deciding when data is stale and preventing many requests from missing at once.

Before you start

You should be comfortable with async code and the request lifecycle. This article focuses on cache design; it assumes a basic understanding of TTLs.

Step-by-step walkthrough

Step 1: Pick the layer that matches the data

An in-process cache is fastest but not shared across workers and cleared on restart. A shared store survives restarts and is consistent across instances. A CDN cache fits public, cacheable responses. Choose based on how long the data may be stale and how widely it must be shared.

Step 2: Set a TTL and an invalidation path

A TTL bounds staleness, and an explicit invalidation on write keeps read-after-write correct where it matters. Caching without either means stale data lives until the process restarts. Also never cache errors or empty results the same way as successes, or a transient failure becomes sticky.

Step 3: Protect against stampedes

When a popular key expires, many requests miss at once and hit the database together. Single-flight the load so only the first request fetches and the rest await the same promise, and consider serving slightly stale data while refreshing in the background.

Worked scenario

A small TTL cache stores successful values only.

const cache = new Map();
async function getCached(key, ttlMs, load) {
  const hit = cache.get(key);
  if (hit && Date.now() - hit.at < ttlMs) return hit.value;
  const value = await load();
  cache.set(key, { value, at: Date.now() });
  return value;
}
JavaScript

Walk through the example

A hit within the TTL returns the stored value; a miss calls load and stores the result. Because only the success path stores, an exception propagates without being cached. To add single-flight, keep a map of in-flight promises so concurrent misses share one load call.

Common mistake

Caching a rejection by storing the promise and never clearing it, so the failure persists. Another is an unbounded in-process cache with no TTL, which grows until it exhausts memory. Always bound size and age.

Verify the behavior

Assert that a second call within the TTL does not call load. Assert that a rejection is not cached and the next call retries. Fire concurrent misses for the same key and confirm load runs once when single-flight is enabled.

Interview exercise

Why is caching an error as bad as caching nothing?

Answer and reasoning

A cached error can serve a failure long after the underlying problem is fixed, so users see an outage that no longer exists. It also hides recovery and can persist across a deploy if the store survives. Cache only successes, and let failures retry, so a transient problem heals on the next request.

Continue learning

Compare the related pattern in Single-flight requests and cache stampedes in Cache stampede. Read the HTTP caching documentation and try the Node.js interview questions.

More in Node.js

read ✓Node.js · mid

Node.js Response Compression with zlib

Compress HTTP responses with gzip or brotli streams, negotiate the encoding, and avoid compressing data that is already compressed.

~2 min readread →
read ✓Node.js · hard

Node.js Clustering Across CPU Cores

Use cluster to run several workers on all cores, restart crashed workers, and understand shared-port and shared-state limits.

~2 min readread →
esc