Skip to content

Pawangupta123/CacheForge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

NexoCache

Caching for Node.js that behaves correctly when things go wrong.
Stampede-protected · fail-open · framework-agnostic · zero runtime dependencies

npm version npm downloads per month CI status MIT licence

Bundle size Zero runtime dependencies Dual ESM and CJS TypeScript types included Node version


npm i @nexocache/cache
import { 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.


Why this exists

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.


Quick start

In-process

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');

Redis

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:15

node-redis works identically — the client is detected structurally, so neither library is a dependency of this package.

Decorators

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.

HTTP responses

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.


Against the alternatives

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.


The things that matter

Stampede protection

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.

A cached null is not a miss

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 — miss

This is a contract, not an implementation detail. Every store must honour it.

Fail-open

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 immediately

clear() will never flush your database

await 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.

Never wait for a refresh

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.

Stampede protection across processes

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.

Circuit breaker

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.

Tags

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 gone

The 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.

Version bumps invalidate everything, for free

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' });

Batch reads are one round trip

await cache.getMany(['user:1', 'user:2', 'user:3']); // one MGET, not three GETs

Against a real Redis this is 20× a read loop.

Encryption at rest

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.

Observability that costs nothing when unused

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.


API

createCache(options?)

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[] []

Methods

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.

WrapOptions

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

TTL format

'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 reference

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.

Plugins

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.

Custom stores

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.


Benchmarks

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 65× regression a benchmark caught

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.


Verified, not asserted

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.


Documentation

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

Requirements

Node 18+. ioredis or redis are optional peer dependencies, needed only for the Redis store.

Development

npm install
npm run check   # typecheck + lint + test — the PR gate
npm run build   # tsup → ESM + CJS + .d.ts
npm run bench   # benchmark suite

Redis integration tests are skipped unless a server is reachable:

docker run -d -p 6379:6379 redis:7-alpine
REDIS_URL=redis://localhost:6379 npm test

Contributions welcome — see CONTRIBUTING.md.

License

MIT