npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

redis-tiered-cache

v0.1.1

Published

Distributed caching framework for Node.js — L1 memory + L2 Redis, cache stampede protection, tag invalidation, multi-cluster routing, and a plugin system.

Readme

redis-tiered-cache

npm version CI license

A distributed caching framework for Node.js services that run more than one process. An in-process LRU sits in front of Redis, the two stay coherent across the fleet over Pub/Sub, and a cache miss on a hot key runs its loader once — not once per request, and not once per instance.

  • Two tiers — L1 memory + L2 Redis, with automatic L1 population and cross-instance invalidation
  • 🛡️ Stampede protection — in-process coalescing plus a distributed lock, so one loader runs per key fleet-wide
  • 🏷️ Tag invalidationinvalidateTag('users') instead of enumerating every affected key
  • 🌍 Multiple Redis clusters — primary/replica, multi-region, Redis Cluster, or your own routing strategy
  • 🧯 Degrades, never crashes — circuit breaker per cluster, fail-open reads, retries with full jitter
  • 🔌 Extensible — plugin hooks, swappable serializers (JSON/MessagePack) and compressors (gzip/brotli/LZ4)
  • 🟦 TypeScript, zero runtime dependencies, ESM + CJS

Install

npm install redis-tiered-cache ioredis

ioredis@^5 is an optional peer dependency — you only need it for the Redis tier. A memory-only cache installs nothing extra. @msgpack/msgpack and lz4js are optional peers too, loaded through dynamic import() only if you configure them.

Node 20+.

Quickstart

import { createCache } from 'redis-tiered-cache';

const cache = createCache({
  namespace: 'api',
  ttl: '5m',
  memory: { max: 10_000, ttl: '30s' },
  clusters: {
    primary: { host: 'redis-primary', role: 'primary' },
    replica: { host: 'redis-replica', role: 'replica' },
  },
  routing: 'write-primary-read-replica',
});

wrap() is the API you will use most — read-through with stampede protection:

const user = await cache.wrap(`user:${id}`, () => db.users.findById(id), {
  ttl: '10m',
  tags: ['users', `user:${id}`],
});

500 concurrent requests for the same cold key across 10 instances produce one database query. Everything else waits for its result.

Invalidate on write without listing keys:

async function updateUser(id: number, patch: Partial<User>) {
  const user = await db.users.update(id, patch);
  await cache.invalidateTags([`user:${id}`, `org:${user.organisationId}`]);
  return user;
}

Direct operations behave as you would expect, and are fully typed:

await cache.set('config', { theme: 'dark' }, { ttl: '1h', tags: ['config'] });
const config = await cache.get<Config>('config'); // Config | undefined

await cache.getMany<Product>(['p:1', 'p:2']); // one MGET, misses simply absent
await cache.increment('views:home', 1, { ttl: '1d' }); // atomic
await cache.clear(); // this namespace only — never FLUSHDB

How it works

A read walks the tiers and populates L1 on the way back. A write goes to Redis first, then L1, then broadcasts.

| Layer | Responsibility | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | L1 memory | LRU with TTL, holding decoded values — no deserialization on a hit. Absorbs hot keys that would otherwise saturate one Redis slot. | | L2 Redis | The shared source of truth. Written first, so a failed remote write never leaves one pod serving a value no one else can see. | | Pub/Sub | Every mutation broadcasts the affected keys. Peers drop their L1 copies; messages carry an origin id so a write never invalidates itself. | | Distributed lock | SET NX PX with compare-and-swap release and auto-extension. Arbitrates between processes after in-process coalescing has collapsed local callers to one. | | Router | Picks which cluster serves each operation. Reads get an ordered failover list; writes get a fan-out set with an authoritative first target. | | Circuit breaker | Per cluster, rolling-window failure counting. Turns a slow failure into an instant one so traffic degrades to L1 + source instead of stalling. |

Values are stored with a self-describing header naming the serializer and compressor that produced them, so changing either is safe mid-deploy — two versions of your app can read the same Redis without a coordinated cutover.

Stampede protection

wrap() applies four layers, in order:

  1. Cache lookup. A fresh hit returns immediately.
  2. Stale-while-revalidate. A value past its fresh window is returned now and refreshed behind the request, so expiry never becomes a latency spike.
  3. In-process coalescing. Concurrent callers in one process attach to a single execution — no lock, no extra round trip.
  4. Distributed lock. The one winner across the fleet re-checks Redis, then runs the loader. Losers re-read once it frees.

Layers 3 and 4 are ordered deliberately: coalescing locally first means one lock attempt per process rather than per request.

createCache({
  ttl: '5m',
  stale: {
    enabled: true,
    staleTtl: '1m', // serve stale for a minute past expiry
    earlyExpirationBeta: 1, // probabilistic early refresh (XFetch)
  },
});

earlyExpirationBeta spreads refreshes over time instead of concentrating them at the instant every instance flips from fresh to stale.

TTLs, namespaces and tags

Durations accept milliseconds or a human string — '500ms', '30s', '5m', '2h', '7d', compound '1h30m', or 'never'. Malformed input throws at the call site rather than silently falling back to a default. Every TTL is jittered ±5% so keys written together do not expire together.

Keys are laid out as <namespace>:<version>:<key>:

createCache({ namespace: 'tenant-a', version: 'v2' }); // → 'tenant-a:v2:user:1'

Bumping version orphans every previously written key at once — how you ship a breaking change to a cached shape without a flush and the self-inflicted stampede that follows one. cache.namespace('tenant-b') returns a view sharing the same connections, L1 and subscription, so multi-tenant services do not need a connection per tenant.

Tag membership lives in a Redis Set per tag, walked with SSCAN and deleted with UNLINK in batches, so a tag covering a million keys never blocks the server.

Multiple clusters

createCache({
  region: process.env.AWS_REGION,
  clusters: {
    'primary-eu': { host: '…', role: 'primary', region: 'eu-west-1' },
    'replica-us': { host: '…', role: 'replica', region: 'us-east-1' },
  },
  routing: 'read-nearest',
});

| Strategy | Reads | Writes | | ---------------------------- | ----------------------------- | ---------------------- | | read-primary | primary, replicas as failover | primary | | read-replica | replicas first | primary | | read-nearest | same region, then lowest RTT | primary | | write-primary | primary | primary | | write-all | primary | every writable cluster | | write-primary-read-replica | replicas first | primary |

Reads fail over on error, not on a miss — re-querying every cluster on every miss would multiply read cost exactly when the cache is cold. Routing is fully replaceable; pass a factory to reach the live cluster registry:

routing: (registry) => ({
  name: 'by-workload',
  selectRead: (ctx) => [registry.require(pick(ctx.key))],
  selectWrite: (ctx) => [registry.require(pick(ctx.key))],
}),

Supplying nodes instead of host switches to Redis Cluster and enables hash tagging automatically.

API

createCache(config)

| Option | Type | Description | | -------------------------- | ---------------------------------- | ---------------------------------------------------------------- | | namespace / version | string | Key prefix segments. Bump version to orphan old keys | | ttl | Duration | Default lifetime. Defaults to '5m' | | memory | object | { enabled, max, maxBytes, ttl, clone }. L1 tier | | clusters | Record<string, ClusterConfig> | Named Redis deployments. Omit for memory-only | | routing | string \| CacheRouter \| factory | Strategy name, router, or factory | | serialization | object | { serializer, additional }'json' (default) or 'msgpack' | | compression | object | { enabled, algorithm, threshold } — gzip, brotli or lz4 | | lock | object | { enabled, ttl, timeout, retryDelay, autoExtend } | | stale | object | { enabled, staleTtl, earlyExpirationBeta } | | invalidation | object | { enabled, channel, batchWindow } | | retry / circuitBreaker | object | Resilience policy | | timeout | Duration | Per-operation deadline. Defaults to '1s' | | failOpen | boolean | Resolve Redis failures as misses. Defaults to true | | plugins | CachePlugin[] | Registered at construction |

Methods

  • get(key, opts?) / getWithMeta(key, opts?) — read, optionally with tier and staleness metadata.
  • set(key, value, opts?) — write to every tier and broadcast.
  • wrap(key, loader, opts?) — read-through with stampede protection. Accepts shouldCache, forceRefresh, lockTimeout, onLockTimeout.
  • delete / deleteMany / exists / clear / touch.
  • getMany / setMany — batched read and write.
  • increment / decrement — atomic counters. Bypass L1 by design.
  • invalidateTag(tag) / invalidateTags(tags) — remove every key carrying a tag, everywhere.
  • namespace(ns, version?) — a namespaced view over the same connections.
  • use(plugin), on / once / off — extension and events.
  • metrics() / health() — snapshot and per-cluster status.
  • connect() / close() — both idempotent. Call close() on SIGTERM.

Per-call options include ttl, tags, skipMemory, skipRedis, cluster, timeout, signal and silent.

Exports

  • createCache, CacheManager — a named registry for services running several caches with different policies.
  • Errors: CacheError and SerializationError, RedisConnectionError, LockTimeoutError, PluginError, CircuitOpenError, ConfigurationError, TimeoutError, CacheClosedError. Each carries a stable code — branch on that, not instanceof.
  • Plugins: LoggerPlugin, MetricsPlugin, PluginHost.
  • Building blocks: MemoryStore, RedisStore, IoRedisDriver, ClusterRegistry, StrategyRouter, RedisLockProvider, JsonSerializer, MsgpackSerializer, GzipCompressor, CircuitBreaker, SingleFlight, KeyBuilder, parseDuration, ManualClock.

Every export is named and the package is sideEffects: false, so a bundler drops what you do not import.

Events and metrics

cache.on('hit', ({ key, tier, durationMs }) => statsd.timing('cache.hit', durationMs, { tier }));
cache.on('error', ({ operation, error }) => logger.error({ operation, err: error }));

const { hitRate, memoryHits, dbHits, lockTimeouts, latency } = cache.metrics();

Events: hit, miss, memory-hit, redis-hit, set, delete, clear, touch, error, lock, unlock, lock-timeout, refresh, invalidate, circuit-open, circuit-close, connect, disconnect — all fully typed. An error event with no listener never throws; a cache is expected to degrade, not crash the process.

Worth alerting on: falling hitRate (TTLs too short, or invalidation too aggressive), rising lockTimeouts (loaders outrunning lock.ttl), sustained circuitOpens (Redis unhealthy), and errors climbing while hits stays flat (failing open, silently).

Performance

Apple M-series, Node 24, against a real Redis 8.10 on loopback. Run npm run bench yourself — without REDIS_URL it uses an in-memory driver and measures framework overhead in isolation.

| Scenario | ops/sec | mean | vs raw ioredis | | ----------------------------------------- | ---------: | ------: | -------------: | | L1 hit | ~2,180,000 | 0.46 µs | 188× | | wrap() on a warm key | ~2,230,000 | 0.45 µs | 191× | | L1 hit with 2 plugins registered | ~809,000 | 1.24 µs | 70× | | Raw ioredis GET + JSON.parse (baseline) | ~11,600 | 86 µs | 1.0× | | L2 hit (L1 bypassed) | ~10,700 | 94 µs | 0.92× | | Write, memory + Redis | ~10,400 | 96 µs | 0.95× | | Write, memory + Redis, 2 tags | ~10,100 | 99 µs | 0.93× |

Two numbers matter here. An L1 hit is ~190× faster than going to Redis, because it skips the round trip and the parse entirely — that is the whole argument for a memory tier, and it grows as your Redis moves further away. And an L2 hit costs ~9 µs more than calling ioredis yourself (94 vs 86 µs, ~10%), which is what routing, envelope decoding, metrics and events add on top of the driver.

Writes land within 5–7% of raw ioredis, tagged or not — the tag index is written concurrently with the value rather than after it.

Stampede protection: 3,000 concurrent requests across 30 cold keys → 30 loader executions, exactly one per key.

Payload sizes for a representative 200-item object: JSON 16,804 B · MessagePack 6,485 B · JSON + gzip 1,445 B.

Limitations

  • This is a cache, not a database. Every value must be reconstructible from a source of truth. L1 is eventually consistent across instances, bounded by memory.ttl.
  • Pub/Sub invalidation is best-effort. An instance disconnected at the moment of publish misses that message; a short memory.ttl (10–60 s) is what bounds the damage. If a workload cannot tolerate that window at all, keep those values out of L1 with memory.enabled: false or per-call skipMemory.
  • Replica reads are subject to replication lag. A read immediately after a write may miss.
  • write-all has no conflict resolution. Concurrent writes to the same key in two regions resolve last-writer-wins per cluster, and the clusters can disagree.
  • The distributed lock is not a general-purpose lock. It protects a cache population, where the worst case of losing it is a duplicated loader run. Do not reuse it for operations with side effects — those need real fencing.
  • wrap() is exactly-once in the common case, at-least-once under lock timeout or Redis failure. The default favours availability; onLockTimeout: 'throw' inverts that.
  • Encrypted or transformed payloads belong in a serializer, not a plugin. afterGet is observational by design — see docs/plugins.md.

Operational notes

  • Keep memory.ttl short. It is the safety net for a dropped invalidation.
  • Leave failOpen: true unless a stale-free guarantee genuinely outranks availability. A cache that throws when Redis blips turns a degradation into an outage.
  • Call cache.close() on SIGTERM — it flushes buffered invalidations so peers are not left holding stale entries.
  • Set a namespace whenever anything else shares the Redis instance. clear() is namespace-scoped and never issues FLUSHDB, but namespacing also keeps keys legible during an incident.
  • Tag at every write site. Retrofitting tags leaves existing entries un-invalidatable until they expire.
  • Set memory.clone: true if callers mutate cached objects; otherwise one caller's mutation corrupts the entry for the whole process.

Documentation

  • Architecture — layering, read/write protocols, locking, invalidation guarantees, and the reasoning behind each trade-off
  • Configuration — every option, with defaults and guidance
  • API — full signatures and semantics
  • Plugins — the hook model, and when to use a serializer instead
  • Migration — from raw ioredis, cache-manager, or a homegrown two-tier cache

Runnable examples in examples/: basics (no Redis needed), Express, multi-cluster, plugins.

Contributing

npm install
npm test        # hermetic — no Redis server required
npm run build

The suite runs against an in-memory driver implementing the same CacheDriver contract as ioredis, so stampede behaviour, pub/sub invalidation, cluster failover and circuit breaking are all deterministic and need no server. REDIS_URL=redis://localhost:6379 npm test additionally runs the integration suite. See CONTRIBUTING.md.

License

MIT