Caching for Node.js that behaves correctly when things go wrong.
Stampede-protected · fail-open · framework-agnostic · zero runtime dependencies
npm i @nexocache/cacheimport { createCache } from '@nexocache/cache';
const cache = createCache({ defaultTtl: '5m' });
// Read through the cache. On a miss it calls the function, stores the result, returns it.
// A hundred simultaneous callers for a cold key produce ONE database query.
const user = await cache.wrap(`user:${id}`, () => db.users.findById(id));That is the whole API for most applications. Everything below is what happens when the easy path is not enough.
Caching is easy to add and hard to get right. The failures are not in the happy path — they show up in production, at scale, and they are always the same handful:
| The failure | What NexoCache does |
|---|---|
| A hot key expires and every in-flight request hits the database at once | Concurrent misses share one origin call. Verified at 10,000 concurrent → 1 query |
| Redis goes down and takes the application with it | Fail-open. A store failure becomes a miss; the origin serves the request |
| You cannot cache "this user does not exist", so a bad ID hammers the database forever | A cached null is distinguishable from a miss |
cache.clear() on a shared Redis wipes another service's data |
clear() scans its own prefix. FLUSHDB is never issued |
| Invalidating "everything about user 15" means tracking every key it appears in | Tags. One call drops them all |
| Decryption fails and the raw ciphertext reaches your application | A failed read transform is a miss, never raw bytes |
| Ten pods miss the same key at once — ten queries, not one | Distributed lock. One pod computes, the rest read the result |
| An unreachable Redis makes every request pay a full connection timeout | Circuit breaker. After repeated failures the store is skipped, not waited on |
| The in-process cache grows until the process dies | Bounded by default. 10k writes into maxSize: 1000 retain exactly 1,000 |
Every row has a test asserting the correct behaviour. Several of them exist because the first implementation got it wrong.
import { createCache } from '@nexocache/cache';
const cache = createCache({ defaultTtl: '5m' });
await cache.set('config', { theme: 'dark' }, { ttl: '1h' });
await cache.get<Config>('config');
await cache.delete('config');Swap the store. Nothing else changes.
import Redis from 'ioredis';
import { createCache, RedisStore } from '@nexocache/cache';
const cache = createCache({
store: new RedisStore({ client: new Redis(), prefix: 'myapp', version: 'v1' }),
defaultTtl: '10m',
});
await cache.wrap(`user:${id}`, () => db.users.findById(id));
// Redis key: myapp:v1:user:15node-redis works identically — the client is detected structurally, so neither library
is a dependency of this package.
import { createCache, createDecorators } from '@nexocache/cache';
const cache = createCache({ defaultTtl: '5m' });
export const { Cache, CacheEvict } = createDecorators(cache);
class UserService {
@Cache({ ttl: '5m', tags: (args) => [`user:${args[0]}`] })
async getUser(id: string) {
return db.users.findById(id); // key: UserService:getUser:15
}
@CacheEvict({ key: (args) => `UserService:getUser:${args[0]}` })
async updateUser(id: string, patch: Patch) {
return db.users.update(id, patch); // evicts *after* the write succeeds
}
}Works under both decorator standards — legacy experimentalDecorators (NestJS,
TypeORM, Angular) and TC39 Stage-3 — from the same build. The protocol is detected at
runtime, and both paths are tested against real compilers.
import express from 'express';
import { expressCache } from '@nexocache/cache';
app.get('/products', expressCache({ cache, ttl: '1m', tags: ['products'] }), handler);
app.post('/products', async (req, res) => {
const created = await db.products.create(req.body);
await cache.invalidateTag('products'); // every cached list, gone
res.status(201).json(created);
});Fastify is supported through onRequest/onSend hooks.
| NexoCache | cache-manager | keyv | lru-cache¹ | |
|---|---|---|---|---|
| Stampede protection | ✅ | ❌ | ❌ | ❌ |
| Distributed (cross-process) | ✅ | ❌ | ❌ | ❌ |
| Circuit breaker | ✅ | ❌ | ❌ | ❌ |
| Decorators (both standards) | ✅ | ❌ | ❌ | ❌ |
| Tag invalidation | ✅ | ❌ | ❌ | ❌ |
| Stale-while-revalidate | ✅ | ❌ | ❌ | ❌ |
Cached null ≠ miss |
✅ | ❌ | ❌ | ❌ |
| Fail-open by default | ✅ | ❌ | ❌ | n/a |
clear() cannot flush your DB |
✅ | ❌ | ❌ | n/a |
| Encryption at rest | ✅ | ❌ | ❌ | ❌ |
| HTTP adapters | ✅ | ❌ | ❌ | ❌ |
| Built-in metrics | ✅ | ❌ | ❌ | partial |
| Runtime dependencies | 0 | 1 | 1 | 0 |
| Read throughput | 1.99M/s | 1.45M/s | 128k/s | 12.3M/s |
Use lru-cache instead if all you need is a fast synchronous in-process map. It is
~6× faster on reads because it has no Promise to await — no async API can close that
gap, and claiming otherwise would be dishonest. It also has no stores, no TTL parsing, no
tags and no plugins.
Against everything in the same category, NexoCache is faster: 1.4× cache-manager on
reads, 1.6× on writes, 1.7× on read-through, 2.0× under concurrent misses.
A hot key expiring is the moment caches make outages worse. Every in-flight request misses simultaneously and they all reach the database together.
await Promise.all(
Array.from({ length: 10_000 }, () => cache.wrap('hot', () => db.query(...))),
);
// db.query ran exactly once. Measured: 10,000 callers, 53ms, 1 query.Concurrent callers share one promise, released in finally — so a rejected factory cannot
wedge the key for everyone after it.
Most libraries collapse the two, which makes negative caching impossible — you cannot cache "this user does not exist" without re-querying every time.
await cache.set('user:404', null);
await cache.get('user:404'); // null — cached
await cache.get('user:999'); // undefined — missThis is a contract, not an implementation detail. Every store must honour it.
Redis going down should not take your application with it.
cache.on('cache:error', ({ error, key, operation }) => {
logger.warn({ err: error, key, operation }, 'cache degraded');
});
// Reads report a miss, writes are dropped, the origin serves the request.Configuration errors are the deliberate exception — a bad TTL string is a bug and throws at construction, not at 3 a.m. on the first cache miss:
createCache({ defaultTtl: '5x' }); // throws InvalidTtlError immediatelyawait cache.clear();
// SCAN MATCH myapp:v1:* → UNLINK in batches.
// FLUSHDB is never issued. Your other keys are untouched.Glob metacharacters in a prefix are escaped, so prefix: 'a*b' cannot widen the pattern
into keys it does not own. An empty prefix is rejected at construction for the same reason.
await cache.wrap(key, load, {
ttl: '5m',
refreshThreshold: '1m', // renew in the background from the 4-minute mark
staleWhileRevalidate: '10m', // and if that fails, serve stale rather than block
});Refresh-ahead keeps a hot key permanently fresh. Stale-while-revalidate covers the rest, and keeps working when the origin is down — a failed refresh leaves the stale value in place rather than causing a stampede.
In-process deduplication collapses concurrent misses inside one Node process. On ten pods that is still ten database queries. A distributed lock makes it one:
import { createCache, RedisStore, createRedisLock } from '@nexocache/cache';
const cache = createCache({
store: new RedisStore({ client }),
distributedLock: { lock: createRedisLock({ client }) },
});
// Verified against a real Redis: 10 independent processes, 1 origin call.Acquisition is SET NX PX — one atomic round trip. Release is a Lua compare-and-delete,
so a holder can only ever release its own lock.
It never blocks indefinitely. If the holder crashes, or the lock store is itself
unreachable, the waiter computes anyway after waitTimeoutMs. Duplicating a query is a
performance problem; a request that never returns is an outage.
An unreachable Redis is worse than no Redis: every operation pays a full connection timeout, and at any real request rate that is hundreds of sockets queued to fail.
createCache({ store, circuitBreaker: { failureThreshold: 5, resetTimeoutMs: 10_000 } });
// On by default. Pass `false` to disable.
cache.circuitState; // 'closed' | 'open' | 'half-open' | 'disabled'After five consecutive failures the store is skipped entirely and reads go straight to the origin — the same outcome fail-open already gave, minus the wait. Recovery is a single probe, not a flood: a store that just came back does not get a stampede for its trouble.
Measured: a dead store received 3 calls out of 50 instead of 50.
await cache.set('user:15:profile', profile, { tags: ['user:15'] });
await cache.set('feed:home', feed, { tags: ['user:15', 'feed'] });
await cache.invalidateTag('user:15'); // both goneThe alternative — matching key prefixes — only works when your key layout happens to line up with your invalidation boundaries, and over-deletes silently when it does not.
Shipping a breaking change to a cached value's shape? Bump the version. Old keys stop being addressed and expire on their own TTL. No migration, no downtime, and rolling back is changing the string back.
new RedisStore({ client, prefix: 'app', version: 'v2' });await cache.getMany(['user:1', 'user:2', 'user:3']); // one MGET, not three GETsAgainst a real Redis this is 20× a read loop.
Anyone with redis-cli can read every cached value. On a managed Redis that is more
people than you think.
createCache({
store: new RedisStore({ client }),
plugins: [encryptionPlugin({ key: process.env.CACHE_KEY! })],
});AES-256-GCM with a fresh IV per write, so the same value encrypts differently each time. Authenticated, so a tampered entry fails to decrypt — and a failed decryption is reported as a miss, never as raw bytes.
const { hitRate, entries, bytes, errors } = await cache.getStats();Ten typed events (cache:hit, cache:miss, cache:stale, cache:error, …). With no
listener attached, no event payload is allocated. Latency is sampled rather than measured
on every call — reading the clock twice per operation cost more than the rest of a cache
hit combined.
| Option | Type | Default |
|---|---|---|
store |
CacheStore |
new MemoryStore() |
defaultTtl |
Ttl |
'5m' — 0 means no expiry |
prefix |
string |
'' |
keyGenerator |
KeyGenerator |
built-in |
failOpen |
boolean |
true |
clock |
Clock |
Date.now — injected for deterministic tests |
plugins |
CachePlugin[] |
[] |
| Method | Returns | Notes |
|---|---|---|
get<T>(key) |
T | undefined |
undefined = miss, null = cached null |
set<T>(key, value, opts?) |
void |
{ ttl, tags } |
delete(key) |
boolean |
true if a live entry was removed |
has(key) |
boolean |
|
clear() |
void |
Scoped to this store's namespace |
getMany<T>(keys) |
(T | undefined)[] |
One round trip where supported |
wrap<T>(key, factory, opts?) |
T |
Read-through + stampede protection |
wrapMethod<T>(parts, factory, opts?) |
T |
wrap with a generated key |
generateKey(parts) |
string |
|
invalidateTag(tag) |
number |
Live entries removed |
invalidateTags(tags) |
number |
|
keysByTag(tag) |
string[] |
|
getMetrics() |
MetricsSnapshot |
Synchronous — safe on every scrape |
getStats() |
CacheStats |
Adds entries and bytes |
on(event, listener) |
() => void |
Returns unsubscribe |
disconnect() |
void |
Disposes plugins, releases the store |
None of these throw on a store failure. Only configuration errors throw.
| Option | Effect |
|---|---|
ttl |
Lifetime |
tags |
Group invalidation |
shouldCache |
(value) => boolean — the value is returned either way |
staleWhileRevalidate |
Serve stale past the TTL while refreshing |
refreshThreshold |
Refresh this long before expiry |
'500ms' · '30s' · '5m' · '2h' · '1d' · '1h30m' · '1.5h' · a number
(milliseconds) · 0 for no expiry.
Anything unparseable throws InvalidTtlError at the point it was configured.
| Decorator | Method runs | Cache effect |
|---|---|---|
@Cache |
Only on a miss | Read-through, stampede-protected |
@CachePut |
Always | Always stores the result |
@CacheEvict |
Always | Removes the entry, after success by default |
@CacheClear |
Always | Clears everything, after success by default |
Options: ttl, key, namespace, condition, shouldCache, tags,
staleWhileRevalidate, refreshThreshold, beforeInvocation.
| Plugin | Purpose |
|---|---|
loggerPlugin |
Structured logs. Values omitted by default |
compressionPlugin |
gzip/brotli above a byte threshold |
encryptionPlugin |
AES-256-GCM at rest |
telemetryPlugin |
An OpenTelemetry span per operation |
Six lifecycle hooks. before* run in order, after* in reverse, so paired transforms
unwind correctly. A throwing hook is reported and skipped — a buggy plugin cannot take
down the cache it is instrumenting.
Four methods:
import type { CacheStore, StoreEntry } from '@nexocache/cache';
class MyStore implements CacheStore {
readonly name = 'my-store';
async get<T>(key: string): Promise<StoreEntry<T> | undefined> { ... }
async set<T>(key: string, value: T, ttlMs?: number): Promise<void> { ... }
async delete(key: string): Promise<boolean> { ... }
async clear(): Promise<void> { ... }
}Optionally add has, disconnect, and the batch, tag or measurable capability
interfaces. Two things to get right: return undefined for a miss and { value: null }
for a cached null, and make clear() touch only your own namespace.
Operations per second, one machine, one session. Reproduce with npm run bench.
| Operation | NexoCache | cache-manager | keyv | lru-cache¹ |
|---|---|---|---|---|
get — hit |
1,988,639 | 1,450,848 | 127,718 | 12,305,409 |
set |
665,307 | 407,268 | 126,349 | 1,124,464 |
| read-through on a hit | 1,138,275 | 680,125 | — | — |
| 100 concurrent misses | 19,694 | 9,847 | — | — |
| read 50 keys | 185,053 | — | 2,554 | — |
¹ Synchronous and memory-only. See the note above.
The first run of these benchmarks was embarrassing: read-through measured 18,627 ops/sec
against cache-manager's 726,681. Thirty-nine times slower.
MemoryStore tracked LRU recency the tidy way — delete the key from the Map and
re-insert it, so iteration order is recency order. Three lines, correct, and
pathological: every read mutates the map, and V8 leaves a tombstone on each delete. At
100k entries a cache hit had degraded to 53 microseconds.
Replacing it with a doubly linked list threaded through the entries — so a read relinks four pointers and never touches the map — took it to 0.59 microseconds.
Every correctness test passed before and after. Only a benchmark found it. That is why docs/benchmarks.md exists, and why every performance claim here has a number behind it.
| Tests | 507 — 497 main, 10 under a separate legacy-decorator compiler |
| Coverage | 97% line, plus an 80% mutation score on the core logic |
| Memory hit latency | 0.59 µs (budget: 1 ms) |
| Redis hit latency | 0.68 ms (budget: 10 ms) — indistinguishable from the raw client |
| 10,000 concurrent, one key | 53 ms, 1 origin call |
| 10 processes, one key | 1 origin call — verified against a real Redis |
| Bounded growth | 10k writes into maxSize: 1000 → exactly 1,000 retained |
| Runtime dependencies | 0 |
| Package size | 764 KB unpacked, 9 files |
| Supply chain | Published with npm provenance from GitHub Actions |
Redis integration tests run against a real server in CI. Both decorator standards are compiled by real compilers, in separate Vitest projects, because they are mutually exclusive compiler modes and testing only one would prove nothing about the other.
| Guide | |
|---|---|
| FAQ | Why is my cache always missing, and the rest |
| Best Practices | Keys, TTLs, invalidation, failure, security |
| Plugins | Hooks, built-ins, writing your own |
| Migration | From cache-manager, keyv, lru-cache, or a hand-rolled Map |
| Benchmarks | The numbers and how they were taken |
| Design document | HLD, LLD, contracts, diagrams, every subsystem and why |
| Examples | Runnable Express and Fastify apps |
Node 18+. ioredis or redis are optional peer dependencies, needed only for the Redis
store.
npm install
npm run check # typecheck + lint + test — the PR gate
npm run build # tsup → ESM + CJS + .d.ts
npm run bench # benchmark suiteRedis integration tests are skipped unless a server is reachable:
docker run -d -p 6379:6379 redis:7-alpine
REDIS_URL=redis://localhost:6379 npm testContributions welcome — see CONTRIBUTING.md.
MIT