cache-hub
v2.2.4
Published
Zero-runtime-dependency caching and atomic state toolkit for Node.js services.
Maintainers
Readme
cache-hub
Zero-runtime-dependency caching and atomic state toolkit for Node.js services.
cache-hub provides an in-memory LRU + TTL cache, optional Redis integration,
read-through caching, function-level caching, distributed invalidation, stable
cache-key serialization, atomic state backends, and rate-limit state primitives
behind a small CacheLike contract.
Chinese documentation: docs/README.zh-CN.md
Table of Contents
- Why cache-hub
- Installation
- Quick Start
- Module Reference
- Redis Defaults
- Testing
- Benchmarking
- Build
- Node.js Support
- Troubleshooting
- License
Why cache-hub
- Zero runtime dependencies -
dependenciesstays empty; Redis is an optional peer dependency. - Memory cache with LRU + TTL - O(1) operations with entry-count and memory-size limits.
- Multi-level cache - L1 memory plus optional L2 remote cache, TTL-preserving backfill, timeout fallback, and configurable write policy.
- Redis adapter - wraps ioredis as
CacheLike; uses SCAN instead of KEYS for production-safe pattern operations. - Read-through caching - cache miss fetch, write-back, in-process de-duplication, and optional Redis lease de-duplication.
- Function cache - cache any async function with
withCacheor theFunctionCacheregistry. - Tag invalidation - memory, Redis, multi-level, and distributed tag invalidation for grouped cache entries.
- Distributed invalidation - Redis Pub/Sub broadcasts pattern and tag invalidation across service instances.
- Stable key serialization - deterministic cache keys with sorted object keys, cycle handling, and special value sentinels.
- Atomic state backends - memory and Redis counter primitives for high-concurrency state updates.
- Rate-limit primitives - fixed-window, sliding-window, token-bucket, and leaky-bucket state stores for middleware authors.
- Dual package format - ESM and CommonJS builds with subpath exports.
Installation
npm install cache-hubRedis-backed features require ioredis:
npm install ioredisioredis is an optional peer dependency. Projects that only use memory caching
do not need to install it.
Quick Start
Memory Cache
import { MemoryCache } from 'cache-hub';
const cache = new MemoryCache({
maxEntries: 1000,
defaultTtl: 60_000,
enableStats: true,
});
await cache.set('user:1', { name: 'Alice' });
const user = await cache.get<{ name: string }>('user:1');
console.log(user?.name); // Alice
const stats = cache.getStats();
console.log(stats.hitRate); // 0..1Read-through Cache
import { MemoryCache } from 'cache-hub';
import { readThrough } from 'cache-hub/read-through';
const cache = new MemoryCache({ defaultTtl: 30_000 });
const user = await readThrough(cache, 30_000, 'user:1', async () => {
return db.findUser(1);
});readThrough returns cached values immediately on hit. On miss, it runs the
fetcher, writes non-undefined results back to cache, and shares one in-flight
promise for concurrent calls with the same key.
Read-through with Redis Lease
Use readThroughWithLease when many Node.js processes may miss the same key at
the same time. One process acquires the Redis lease and runs the fetcher; other
processes wait briefly for the cache to be filled.
import { createRedisCacheAdapter } from 'cache-hub/redis';
import { createRedisLeaseStore } from 'cache-hub/lease';
import { readThroughWithLease } from 'cache-hub/read-through';
const cache = createRedisCacheAdapter('redis://localhost:6379');
const leaseStore = createRedisLeaseStore(cache);
const value = await readThroughWithLease({
cache,
ttlMs: 2_000,
key: 'response:/users/1',
leaseStore,
leaseTtlMs: 1_000,
waitForOwnerMs: 1_200,
pollIntervalMs: 10,
fetcher: async () => renderExpensiveResponse(),
});
await cache.close();The default timeout behavior is onLeaseTimeout: 'fetch', which favors
availability. Use onLeaseTimeout: 'throw' if duplicate origin fetches must be
rejected instead of allowed.
Function Cache
import { MemoryCache } from 'cache-hub';
import { withCache } from 'cache-hub/function-cache';
const cache = new MemoryCache({ maxEntries: 500 });
const getUser = withCache(
async (userId: number) => db.findUser(userId),
{
cache,
ttl: 60_000,
namespace: 'users',
condition: (result) => result !== null,
},
);
const user = await getUser(1);The default key builder uses stableStringify. Long keys are compressed with a
SHA-256 digest after the configured key length threshold.
Multi-level Cache
import { MemoryCache } from 'cache-hub';
import { MultiLevelCache } from 'cache-hub/multi-level';
import { createRedisCacheAdapter } from 'cache-hub/redis';
const local = new MemoryCache({ maxEntries: 500, defaultTtl: 30_000 });
const remote = createRedisCacheAdapter('redis://localhost:6379');
const cache = new MultiLevelCache({
local,
remote,
remoteTimeoutMs: 50,
backfillOnRemoteHit: true,
writePolicy: 'both',
});
await cache.set('product:42', { name: 'Keyboard' }, 120_000);
const product = await cache.get<{ name: string }>('product:42');
console.log(product?.name); // Keyboard
await remote.close();Tags
Tags let you invalidate a group of cache entries without knowing every cache key at call time.
import { MemoryCache } from 'cache-hub';
import { createRedisCacheAdapter } from 'cache-hub/redis';
const memory = new MemoryCache({ enableTags: true });
await memory.set('user:1:profile', { name: 'Alice' }, 60_000, {
tags: ['user:1', 'tenant:a'],
});
await memory.invalidateByTag('user:1');
const redis = createRedisCacheAdapter('redis://localhost:6379', {
metaKeyPrefix: 'my-app:cache-meta',
deleteCommand: 'unlink',
});
await redis.set('user:1:profile', { name: 'Alice' }, 60_000, {
tags: ['user:1', 'tenant:a'],
});
const deleted = await redis.invalidateByTag('tenant:a');
await redis.close();set(key, value, ttl) without tags clears any previous tag relationship for the
same key. This prevents an old tag from deleting a newer untagged value.
Distributed Invalidation
import { MemoryCache } from 'cache-hub';
import { DistributedCacheInvalidator } from 'cache-hub/distributed';
const local = new MemoryCache({ maxEntries: 1000 });
const invalidator = new DistributedCacheInvalidator({
redisUrl: process.env.REDIS_URL ?? 'redis://localhost:6379',
cache: local,
channel: 'app:cache-invalidation',
});
await invalidator.invalidate('user:*');
await invalidator.invalidateTag('tenant:a');
await invalidator.close();Calling invalidate(pattern) first invalidates the current instance and then
broadcasts the same pattern to other subscribers. invalidateTag(tag) does the
same for caches that support invalidateByTag.
Fixed-window Rate-limit Store
import { createMemoryFixedWindowRateLimitStore } from 'cache-hub/rate-limit';
const store = createMemoryFixedWindowRateLimitStore();
const result = store.increment('rl:user:42', 60_000, 100);
if (result.remaining === 0) {
console.log(`Retry after ${result.retryAfterMs}ms`);
}Redis-backed rate-limit state uses Lua scripts for atomic increment/decrement:
import { createRedisCacheAdapter } from 'cache-hub/redis';
import { createRedisFixedWindowRateLimitStore } from 'cache-hub/rate-limit';
const redisCache = createRedisCacheAdapter('redis://localhost:6379');
const store = createRedisFixedWindowRateLimitStore(redisCache);
await store.increment('rl:user:1', 60_000, 100);
await store.decrement('rl:user:1');
await store.resetPrefix('rl:user:');
await redisCache.close();cache-hub/rate-limit is a low-level primitive for middleware authors. It does
not impose a specific HTTP framework adapter.
Atomic State Backend
import { createRedisCacheAdapter } from 'cache-hub/redis';
import { createRedisAtomicStateBackend } from 'cache-hub/atomic';
const redisCache = createRedisCacheAdapter('redis://localhost:6379');
const atomic = createRedisAtomicStateBackend(redisCache);
const result = await atomic.incrementWithTtl('counter:tenant:42', 1, 60_000);
console.log(result.value, result.ttlMs);
await redisCache.close();The Redis backend uses Lua scripts for atomic read-modify-write behavior. It is
safe to use as a storage primitive for high-concurrency counters where plain
get -> set would lose updates.
Module Reference
cache-hub
import { MemoryCache } from 'cache-hub';
import type { CacheLike, CacheStats, MemoryCacheOptions } from 'cache-hub';new MemoryCache(options?)
| Option | Type | Default | Description |
|---|---:|---:|---|
| maxEntries | number | 10000 | Maximum number of entries before LRU eviction. |
| maxMemory | number | 0 | Estimated max memory in bytes. 0 disables the memory limit. |
| defaultTtl | number | 0 | Default TTL in milliseconds. 0 means no expiration. |
| cleanupInterval | number | 0 | Periodic expired-entry cleanup interval in milliseconds. |
| enableStats | boolean | true | Enables hit/miss statistics. |
| enableTags | boolean | false | Enables tag indexes and invalidateByTag. |
| enabled | boolean | true | Disables cache reads/writes when set to false. |
MemoryCache also exposes getRemainingTtl(key) and
getRemainingTtlMany(keys). A non-expiring existing key returns null; a
missing or expired key returns undefined.
CacheLike
Every cache implementation can be used through this interface:
interface CacheLike {
get<T = any>(key: string): T | undefined | Promise<T | undefined>;
set(key: string, value: any, ttl?: number, options?: CacheSetOptions): void | Promise<void>;
del(key: string): boolean | Promise<boolean>;
exists(key: string): boolean | Promise<boolean>;
has(key: string): boolean | Promise<boolean>;
clear(): void | Promise<void>;
keys(pattern?: string): string[] | Promise<string[]>;
getMany(keys: string[]): Record<string, any> | Promise<Record<string, any>>;
setMany(entries: Record<string, any>, ttl?: number): boolean | Promise<boolean>;
delMany(keys: string[]): number | Promise<number>;
delPattern(pattern: string): number | Promise<number>;
getRemainingTtl?(key: string): number | null | undefined | Promise<number | null | undefined>;
getRemainingTtlMany?(keys: string[]): Record<string, number | null> | Promise<Record<string, number | null>>;
invalidateByTag?(tag: string): void | number | Promise<void | number>;
getStats?(): CacheStats;
resetStats?(): void;
destroy?(): void;
setLockManager?(lm: LockManager): void;
}cache-hub/read-through
import { readThrough, readThroughWithLease } from 'cache-hub/read-through';
function readThrough<V>(
cache: CacheLike,
ttl: number,
key: string,
fetcher: () => Promise<V>,
): Promise<V>;ttl <= 0runs the fetcher without writing to cache.nullis cached as a valid value.undefinedis treated as a miss signal and is not cached.- Same-key concurrent calls share one in-flight promise.
readThroughWithLease(options)adds aCacheLeaseStorefor cross-process de-duplication. It is useful for short TTL response cache entries that may be regenerated by many workers at once.
readThroughWithLease options:
| Option | Type | Default | Description |
|---|---|---|---|
| cache | CacheLike | required | Cache to read from and write to. |
| key | string | required | Cache key and lease resource key. |
| ttlMs | number | required | Cache TTL in milliseconds. <= 0 bypasses cache and lease. |
| fetcher | () => Promise<T> | required | Origin function used on miss. |
| leaseStore | CacheLeaseStore | required | Lease store, usually created with createRedisLeaseStore. |
| leaseTtlMs | number | min(ttlMs, 5000), at least 50 | Lease lifetime. Keep it longer than the normal fetch latency. |
| waitForOwnerMs | number | leaseTtlMs + 25 | How long non-owner callers wait for cache fill. |
| pollIntervalMs | number | 10 | Cache polling interval while waiting. |
| onLeaseTimeout | 'fetch' \| 'throw' | 'fetch' | Fallback behavior when no owner fills the cache in time. |
cache-hub/multi-level
import { MultiLevelCache } from 'cache-hub/multi-level';
new MultiLevelCache(options);| Option | Type | Default | Description |
|---|---|---|---|
| local | CacheLike | required | L1 local cache. |
| remote | CacheLike | undefined | Optional L2 remote cache. |
| writePolicy | 'both' \| 'local-first-async-remote' | 'both' | Write-through or local-first async write policy. |
| backfillOnRemoteHit | boolean | true | Backfills L1 after L2 hit. Preserves remote TTL when supported. |
| remoteTimeoutMs | number | 50 | L2 get timeout in milliseconds. Timeout falls back to L1 miss behavior. |
| publish | (msg) => void | undefined | Optional callback for distributed invalidation messages. |
| remoteInvalidationErrors | 'ignore' \| 'throw' | 'ignore' | Controls whether remote invalidateByTag errors are swallowed or rethrown. |
cache-hub/redis
import { createRedisCacheAdapter } from 'cache-hub/redis';
const adapter = createRedisCacheAdapter('redis://localhost:6379');Options:
| Option | Type | Default | Description |
|---|---|---|---|
| metaKeyPrefix | string | __cache-hub | Prefix for Redis tag metadata keys. Use an app-specific prefix when several apps share a Redis database. |
| scanCount | number | 100 | SCAN / SSCAN count hint. Must be positive. |
| deleteCommand | 'del' \| 'unlink' | 'del' | Use unlink for asynchronous Redis memory reclamation on large values. |
The Redis adapter implements CacheLike and adds:
| Method | Description |
|---|---|
| getRemainingTtl(key) | Returns remaining TTL in milliseconds, null for non-expiring keys, and undefined for missing keys. |
| getRemainingTtlMany(keys) | Batch TTL lookup. |
| invalidateByTag(tag) | Deletes cache entries attached to a tag and returns the number of deleted business keys. |
| pruneTagMetadata(tag) | Optional maintenance helper that removes stale tag metadata for business keys that already expired naturally. |
| close() | Closes only the connection created by the adapter. Externally supplied ioredis instances are not closed. |
| getRedisInstance() | Returns the underlying ioredis instance for advanced use cases. |
Pattern operations use SCAN with COUNT 100; KEYS is not used.
For tagged writes with a TTL, the reverse key-tags metadata receives the same TTL.
Tag sets are not given a direct TTL because one tag can contain both non-expiring and short-lived keys.
cache-hub/lease
import { createRedisLeaseStore } from 'cache-hub/lease';
const leaseStore = createRedisLeaseStore(redisCacheOrIoredis, {
leaseKeyPrefix: 'my-app:cache-lease',
ownerId: 'api-worker-1',
});| Option | Type | Default | Description |
|---|---|---|---|
| leaseKeyPrefix | string | __cache-hub:lease | Prefix for Redis lease keys. |
| ownerId | string | random UUID | Stable owner prefix included in lease tokens. |
The Redis lease store uses SET key token NX PX ttlMs to acquire a lease and Lua
scripts to release or renew only when the token still matches.
cache-hub/function-cache
import { FunctionCache, withCache } from 'cache-hub/function-cache';const cachedFn = withCache(asyncFn, {
cache,
ttl: 60_000,
namespace: 'users',
keyBuilder: (...args) => `custom:${args.join(':')}`,
condition: (result) => result !== null,
});withCache(fn).invalidateAll() only deletes keys that were actually written by
that wrapped function. It does not delete unrelated manual keys that happen to
share the same prefix.
cache-hub/distributed
import { DistributedCacheInvalidator } from 'cache-hub/distributed';
const invalidator = new DistributedCacheInvalidator({
cache,
redisUrl: 'redis://localhost:6379',
});| Option | Description |
|---|---|
| cache | Required cache instance that receives delPattern(pattern) and, for tag messages, invalidateByTag(tag) calls. |
| redisUrl | Redis URL. Defaults to redis://localhost:6379 when neither redisUrl nor redis is provided. |
| redis | Existing ioredis instance used for publishing. |
| channel | Pub/Sub channel. Defaults to cache-hub:invalidate. |
| instanceId | Unique instance id used to filter self-sent messages. |
Methods:
| Method | Description |
|---|---|
| invalidate(pattern) | Invalidates the current instance with delPattern(pattern) and broadcasts a backward-compatible pattern message. |
| invalidatePattern(pattern) | Alias for invalidate(pattern). |
| invalidateTag(tag) | Invalidates the current instance with invalidateByTag(tag) and broadcasts a tag message. |
cache-hub/atomic
import {
createMemoryAtomicStateBackend,
createRedisAtomicStateBackend,
} from 'cache-hub/atomic';| API | Description |
|---|---|
| MemoryAtomicStateBackend | Synchronous in-memory atomic counter backend. |
| RedisAtomicStateBackend | Async Redis atomic counter backend backed by Lua scripts. |
| incrementWithTtl(key, amount, ttlMs) | Atomically increments a counter and assigns TTL on first write. |
| decrement(key, amount?) | Atomically decrements a counter while preserving TTL. |
| reset(key) | Deletes one atomic state key. |
| resetPrefix(prefix) | Deletes keys under a literal prefix with SCAN. |
| cleanupExpired(now?) | Memory-only helper that removes expired counters and returns the number of removed entries. |
cache-hub/rate-limit
import {
createMemoryFixedWindowRateLimitStore,
createMemoryRateLimitStateStore,
createRedisFixedWindowRateLimitStore,
createRedisRateLimitStateStore,
} from 'cache-hub/rate-limit';| API | Description |
|---|---|
| MemoryFixedWindowRateLimitStore | Synchronous in-memory fixed-window counter. |
| RedisFixedWindowRateLimitStore | Async Redis fixed-window counter backed by Lua scripts. |
| MemoryRateLimitStateStore | In-memory sliding-window, token-bucket, and leaky-bucket state primitives. |
| RedisRateLimitStateStore | Redis Lua-backed sliding-window, token-bucket, and leaky-bucket state primitives. |
| increment(key, windowMs, limit, amount?) | Increments the current window and returns hits, remaining quota, reset time, and retry-after. |
| decrement(key, amount?) | Rolls back a counter, useful when downstream work fails after reservation. |
| checkSlidingWindow(key, windowMs, limit, cost?) | Reserves sliding-window state and returns an opaque rollback token when allowed. |
| consumeTokenBucket(key, capacity, refillPerSecond, cost?) | Atomically consumes token-bucket capacity and returns retry timing. |
| consumeLeakyBucket(key, capacity, leakPerSecond, cost?) | Atomically consumes leaky-bucket capacity and returns retry timing. |
| reset(key) | Deletes one rate-limit key. |
| resetPrefix(prefix) | Deletes keys under a literal prefix with SCAN. |
| cleanupExpired(now?) | Memory-only helper that removes expired or naturally neutral rate-limit state. |
cache-hub/stringify
import { stableStringify } from 'cache-hub/stringify';
stableStringify({ b: 2, a: 1 }); // '{"a":1,"b":2}'
stableStringify(NaN); // '"__NaN__"'stableStringify sorts object keys, handles cycles, supports custom
serializers, and keeps cache keys deterministic across processes.
Redis Defaults
Redis-backed examples use:
redis://localhost:6379This URL means local Redis on port 6379 with no password. If your Redis
requires authentication, use the standard Redis URL form:
redis://:password@host:6379For tests, set REDIS_URL when you need a non-default endpoint:
REDIS_URL=redis://127.0.0.1:6379 npm run test:integrationTesting
# All Vitest tests; Redis integration tests run when Redis is reachable
npm test
# Coverage
npm run test:coverage
# Redis integration tests only
npm run test:integration
# Skip Redis integration tests explicitly
SKIP_INTEGRATION=true npm run test:integrationnpm test runs the full Vitest suite. Redis integration cases execute when a
reachable Redis server is available; otherwise they log a skip message.
Integration tests require ioredis in the development environment. The package
keeps ioredis as an optional peer dependency for consumers and as a dev
dependency for real integration coverage.
Coverage target: statements, branches, functions, and lines all at 100%.
Benchmarking
# Build first, then print benchmark tables
npm run benchmark
# Print JSON to stdout
npm run benchmark -- --json
# Write JSON to a file
npm run benchmark -- --json --output benchmark-results.jsonThe benchmark script focuses on direct library hot paths. Treat the numbers as local performance signals, not as a replacement for production HTTP middleware or real Redis network benchmarks.
Build
# Type check only
npm run typecheck
# Build ESM, CommonJS, and declaration files
npm run buildBuild output:
dist/
├── esm/
├── cjs/
└── types/The package exposes matching ESM, CJS, and type declaration paths for every public subpath export.
Node.js Support
| Node.js | Status | |---|:---:| | 18 LTS | Supported | | 20 LTS | Supported | | 22 LTS | Supported |
cache-hub requires Node.js >=18.0.0.
Troubleshooting
| Symptom | Check |
|---|---|
| redis-adapter requires ioredis | Install ioredis in the consuming project: npm install ioredis. |
| Redis tests are skipped | Confirm Redis is running and REDIS_URL points to the reachable endpoint. |
| Redis auth fails | Use redis://:password@host:6379 or pass an already configured ioredis instance. |
| Pattern deletes are slower than expected | delPattern and keys use SCAN for safety; this avoids blocking Redis like KEYS. |
| Cache misses after storing undefined | undefined is the miss signal. Use null or a sentinel object for cacheable empty results. |
| Tag invalidation does not remove Redis entries | Confirm entries were written with set(key, value, ttl, { tags }) and that all instances use the same metaKeyPrefix. |
| Expired Redis keys still appear in tag metadata | This is expected; invalidateByTag cleans stale members lazily while scanning the tag. |
| Too many origin fetches after short TTL expiry | Use readThroughWithLease with a Redis lease store and set leaseTtlMs longer than normal fetch latency. |
