Add a Redis Cache-Aside Layer to a REST API
A complete cache-aside layer for a REST API: key design, TTL and jitter, stampede protection, tag-based invalidation, and cache-hit tests.
Project goal
Wrap an existing REST API with a cache-aside layer that serves reads from Redis when warm, falls back to the database on a miss, and invalidates affected keys on writes.
Final features
- Key design
- Cache-aside read
- Stampede protection
- Tag invalidation
- Metrics
Architecture
A getOrLoad helper owns the read path: it checks Redis, and on a miss it loads from the database, writes the value back with a jittered TTL, and returns it. A single-flight map ensures concurrent misses for the same key trigger one load. Writes invalidate by a versioned tag prefix.
Prerequisites
- Node.js 20 LTS or later
- A reachable Redis 7+ instance
- An existing REST API with a read/write split
Dependencies
- ioredis ^5.4.1 — Async Redis client with pipelining
- express ^4.21.0 — Host REST API
- pg ^8.12.0 — Origin database for cache misses
Directory structure
redis-cache-layer/
├── src/cache.ts
├── src/tags.ts
├── src/api.ts
├── src/metrics.ts
├── tests/cache.test.ts
└── package.json
Phases
1. Key design
Compose keys from namespace, resource, version, and id.
const key = (ns, id, v) => `${ns}:v${v}:${id}`;
Expected behavior: A predictable key scheme makes invalidation and debugging tractable.
2. Cache-aside read
getOrLoad checks Redis, loads on miss, and writes back with a jittered TTL.
export async function getOrLoad(key, ttl, load) {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const value = await load();
await redis.set(key, JSON.stringify(value), 'EX', ttl + Math.floor(Math.random() * 30));
return value;
}
Expected behavior: Reads stay fast when warm and correct when cold.
3. Stampede protection
Track in-flight loads per key so only one request queries the origin.
const inflight = new Map();
const p = inflight.get(key) ?? load().finally(() => inflight.delete(key));
inflight.set(key, p);
Expected behavior: A hot key expiring must not send a hundred identical queries to the database.
4. Tag invalidation
Record key membership under a tag set and bump the tag version on write.
await redis.sadd(`tag:${tag}`, key);
async function invalidateTag(tag) { const keys = await redis.smembers(`tag:${tag}`); if (keys.length) await redis.del(...keys); }
Expected behavior: A single write invalidates every cached view of the mutated resource.
5. Metrics
Count hits, misses, and load latency to expose the cache effectiveness.
metrics.hits += 1; // or misses += 1
metrics.observeLoad(ms);
Expected behavior: Operators can see the hit ratio and detect a cold or poisoned cache.
Testing
Use an in-process fake Redis or a container. Tests assert that a second read is served without calling the loader, that a write invalidates the tag, and that ten concurrent misses call the loader once.
Troubleshooting
Writes leave stale reads
Only delete keys on write; the tag set was never populated because the read path forgot to add membership.
Thundering herd after deploy
Every key expired at once because a fixed TTL was used; add jitter and warm keys gradually.
Deployment
Run Redis with an eviction policy such as allkeys-lru and a maxmemory ceiling. Warm critical keys after deploy, and never treat the cache as the source of truth; a flush must only slow the API, not break it.
Known limitations
- The example caches whole JSON documents; partial-field caching needs a different key strategy.
- Single-flight runs per process and does not coordinate across instances.
- TTL jitter is small and not a substitute for a proper jitter strategy at scale.
Project overview
A cache is a bet that stale data is worse than no data for a moment, and it only pays off if the read path and the invalidation path are designed together. This build adds a cache-aside layer that serves warm reads from Redis and invalidates on write.
You will own the read path with a single helper, load from the database on a miss, write the value back with a jittered TTL, guard against a stampede with single-flight, and invalidate by a versioned tag prefix on writes.
The single-flight map is the detail that separates a cache that protects the database from one that amplifies load. Ten concurrent misses for the same key must produce one database read, not ten.
Invalidation by a versioned tag prefix keeps the write path cheap: instead of enumerating keys to delete, a write bumps a version that makes existing keys unreachable, and they expire naturally.
Each phase adds one property: a key design, the read path, stampede protection, invalidation, and metrics, so the cache is correct and observable at every stage.
It is written for teams that added a cache to fix a slow endpoint and then spent a week tracking down stale reads. It treats the cache as a layer with a single entry point and a defined invalidation story, because those two choices are what make a cache safe to operate.
What you will build
The layer provides five capabilities, each with a test against a real or faked Redis:
- Key design
- Cache-aside read
- Stampede protection
- Tag invalidation
- Metrics
A key design makes values addressable; a cache-aside read serves warm data; single-flight collapses a stampede into one load; tag invalidation clears related keys on write; and metrics make the hit ratio visible.
The design never treats the cache as a source of truth, so a flush only slows the API rather than breaking it, and every path has a working fallback to the database.
Architecture and data model
A getOrLoad helper owns the read path: it checks Redis, and on a miss it loads from the database, writes the value back with a jittered TTL, and returns it. A single-flight map ensures concurrent misses for the same key trigger one load. Writes invalidate by a versioned tag prefix.
A get-or-load helper owns the read path: it checks Redis, and on a miss it loads from the database, writes the value back with a TTL that includes jitter, and returns it. Every reader goes through this helper rather than touching the cache directly.
A single-flight map ensures that concurrent misses for the same key trigger one load, so a cold key under load does not multiply into a database stampede.
Writes invalidate by bumping a version in a tag prefix, so related keys become unreachable without enumerating them, and each cached value carries the version it was stored under.
TTLs carry jitter so a large set of keys written together does not expire together, which would otherwise turn a steady hit ratio into a recurring spike of misses.
The helper is the only code that knows the serialization format and the TTL, so changing either is a one-line change rather than a search across the codebase. That single ownership is also what makes the metrics trustworthy, since every read passes through the same place.
Prerequisites and dependencies
You need a Redis instance sized for the working set and a configuration for the eviction policy and the maximum memory.
- Node.js 20 LTS or later
- A reachable Redis 7+ instance
- An existing REST API with a read/write split
The dependencies cover the HTTP surface, the Redis client, and the database access the loader falls back to:
ioredis(^5.4.1) — Async Redis client with pipeliningexpress(^4.21.0) — Host REST APIpg(^8.12.0) — Origin database for cache misses
pnpm init
pnpm add ioredis express pg
pnpm add -D typescript tsx vitest
Set an eviction policy such as least-recently-used and a maximum memory ceiling, so the cache degrades by evicting rather than by failing writes. Decide and document whether a cache outage should fail open to the database or fail closed.
Directory structure
Separate the key design, the read helper, the single-flight guard, the tag invalidation, and the metrics so each is testable with a fake Redis:
redis-cache-layer/
├── src/cache.ts
├── src/tags.ts
├── src/api.ts
├── src/metrics.ts
├── tests/cache.test.ts
└── package.json
Phase 1: Key design
Compose keys from namespace, resource, version, and id.
const key = (ns, id, v) => `${ns}:v${v}:${id}`;
A predictable key scheme makes invalidation and debugging tractable.
Design the key so it includes the entity and the version, and never encode a value that changes with every request. A key that includes a request id is a cache that never hits.
Phase 2: Cache-aside read
getOrLoad checks Redis, loads on miss, and writes back with a jittered TTL.
export async function getOrLoad(key, ttl, load) {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const value = await load();
await redis.set(key, JSON.stringify(value), 'EX', ttl + Math.floor(Math.random() * 30));
return value;
}
Reads stay fast when warm and correct when cold.
Implement the cache-aside read as one helper so there is a single place that knows the TTL, the serialization, and the fallback. Scattered cache reads are how a codebase develops two subtly different TTLs.
Phase 3: Stampede protection
Track in-flight loads per key so only one request queries the origin.
const inflight = new Map();
const p = inflight.get(key) ?? load().finally(() => inflight.delete(key));
inflight.set(key, p);
A hot key expiring must not send a hundred identical queries to the database.
Add single-flight so a cold key under concurrent load triggers one database read, because the whole point of a cache is to reduce database work rather than to relabel it.
Phase 4: Tag invalidation
Record key membership under a tag set and bump the tag version on write.
await redis.sadd(`tag:${tag}`, key);
async function invalidateTag(tag) { const keys = await redis.smembers(`tag:${tag}`); if (keys.length) await redis.del(...keys); }
A single write invalidates every cached view of the mutated resource.
Invalidate by bumping a version in the tag prefix on write, so an update makes related entries unreachable without a delete-by-pattern scan over the keyspace.
Phase 5: Metrics
Count hits, misses, and load latency to expose the cache effectiveness.
metrics.hits += 1; // or misses += 1
metrics.observeLoad(ms);
Operators can see the hit ratio and detect a cold or poisoned cache.
Emit hit, miss, and load-latency metrics from the helper, so the cache’s effectiveness is observable in one place rather than inferred from database load.
Testing
Use an in-process fake Redis or a container. Tests assert that a second read is served without calling the loader, that a write invalidates the tag, and that ten concurrent misses call the loader once.
Assert that a second read is served without calling the loader, using a fake that counts loader calls, which is the only convincing proof that the cache is on the path.
Assert that a write invalidates the tag, because an invalidation bug produces stale data that looks correct until someone reads after an update.
Assert that ten concurrent misses for the same key call the loader once, which is the stampede property and the reason single-flight exists.
Assert that the TTL includes jitter by checking a set of written keys does not share an exact expiry, since an unjittered TTL schedules a synchronized miss storm.
Use a real Redis in a container for the invalidation tests where possible and a fake for the pure logic, because a fake can model expiry loosely and let a TTL bug pass that a real instance would catch.
pnpm vitest run
Deployment and operations
Run Redis with an eviction policy such as allkeys-lru and a maxmemory ceiling. Warm critical keys after deploy, and never treat the cache as the source of truth; a flush must only slow the API, not break it.
Run Redis with an eviction policy and a memory ceiling, and warm the critical keys after a deploy so a fresh cache does not hand the database a burst.
Never treat the cache as the source of truth; a flush must only slow the API, and a cache outage must have a defined behavior that has been tested.
Monitor the hit ratio, the eviction rate, and the loader latency together, because a falling hit ratio with a stable eviction rate points at a key or TTL change rather than at capacity.
Separate the cache metrics from the database metrics so a stampede is visible as a loader spike rather than as generic database load.
Decide in advance whether a Redis outage falls back to the database or fails the request, and test that path, because the answer determines whether the cache protects the system or becomes another dependency that can take it down.
Security considerations
Never cache responses that depend on the authenticated viewer. Scope keys by tenant to prevent cross-tenant leakage, and do not store secrets or personal data without an explicit retention decision.
Do not cache anything that a different user is not allowed to see under the same key, because a shared key across users turns a cache into a leak.
Keep sensitive values out of the cache or encrypt them there, since a Redis instance is often reachable by more services than the database is.
Bound the value size and the number of keys a single request can write, so a caller cannot fill the cache with large or numerous entries.
Treat a cache key as part of the data model and review it, because a key collision or a missing tenant component is an isolation bug rather than a performance bug.
Do not cache a response that depends on an authorization decision under a key that does not include the decision, because that is how a cached permission becomes a permanent one.
Do not cache a value whose correctness depends on time without including the time in the key or the TTL, because a cached expiry decision will otherwise outlive the moment at which it was true.
Observability
Emit cache hit ratio, evictions, and load latency as metrics. Alert on a hit ratio that collapses after a deploy, which usually means keys changed shape.
Track the hit ratio as the primary metric, because it is the number that decides whether the cache is earning its complexity.
Chart the eviction rate, since a high rate means the working set exceeds the memory and the cache is thrashing rather than caching.
Measure loader latency and stampede frequency, because those two describe the database work the cache is supposed to remove.
Alert on a sudden drop in hit ratio, because it usually means the key format changed or the cache was flushed by a deploy.
Chart the loader call rate, because it is the real measure of database work removed and is more actionable than the hit ratio when the two disagree.
Troubleshooting
Writes leave stale reads. Only delete keys on write; the tag set was never populated because the read path forgot to add membership.
Thundering herd after deploy. Every key expired at once because a fixed TTL was used; add jitter and warm keys gradually.
If the hit ratio collapses, check the key format first, since a version or an id accidentally added to the key changes every address and empties the effective cache.
If the database sees a spike on a cold key, check single-flight, because a missing guard lets a stampede through exactly when the cache is least helpful.
If stale data survives a write, check the tag version and the reader, because a reader that does not consult the version will happily serve an entry the write meant to retire.
Limitations and next steps
- The example caches whole JSON documents; partial-field caching needs a different key strategy.
- Single-flight runs per process and does not coordinate across instances.
- TTL jitter is small and not a substitute for a proper jitter strategy at scale.
The layer is cache-aside only; there is no write-through or write-behind path, so a write does not populate the cache and the next read pays a miss.
Invalidation is eventual within the TTL window, so a reader can see a stale value until the version bump propagates and the old entry expires.
There is no local in-process cache in front of Redis, so every read still pays a network round trip.
The example assumes a single Redis; a clustered deployment would need the key design to keep related keys on the same shard or accept cross-slot operations.
The layer caches single entities and simple lists rather than composed views, so a page that joins several entities may need several reads rather than one cached document.