cache-hub
Zero-runtime-dependency multi-level caching 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, and fixed-window rate-limit primitives behind a small
CacheLike contract.

Chinese documentation: docs/README.zh-CN.md
Table of Contents
Why cache-hub
- Zero runtime dependencies -
dependencies stays 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, and in-flight request de-duplication.
- Function cache - cache any async function with
withCache or the FunctionCache registry.
- Distributed invalidation - Redis Pub/Sub broadcasts cache invalidation across service instances.
- Stable key serialization - deterministic cache keys with sorted object keys, cycle handling, and special value sentinels.
- Rate-limit primitives - optional fixed-window stores for memory and Redis-backed HTTP middleware implementations.
- Dual package format - ESM and CommonJS builds with subpath exports.
Installation
npm install cache-hub
Redis-backed features require ioredis:
npm install ioredis
ioredis 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);
const stats = cache.getStats();
console.log(stats.hitRate);
Read-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.
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);
await remote.close();
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.close();
Calling invalidate(pattern) first invalidates the current instance and then
broadcasts the same pattern to other subscribers.
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.
Module Reference
cache-hub
import { MemoryCache } from 'cache-hub';
import type { CacheLike, CacheStats, MemoryCacheOptions } from 'cache-hub';
new MemoryCache(options?)
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): 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 | Promise<void>;
getStats?(): CacheStats;
resetStats?(): void;
destroy?(): void;
setLockManager?(lm: LockManager): void;
}
cache-hub/read-through
import { readThrough } from 'cache-hub/read-through';
function readThrough<V>(
cache: CacheLike,
ttl: number,
key: string,
fetcher: () => Promise<V>,
): Promise<V>;
ttl <= 0 runs the fetcher without writing to cache.
null is cached as a valid value.
undefined is treated as a miss signal and is not cached.
- Same-key concurrent calls share one in-flight promise.
cache-hub/multi-level
import { MultiLevelCache } from 'cache-hub/multi-level';
new MultiLevelCache(options);
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. |
cache-hub/redis
import { createRedisCacheAdapter } from 'cache-hub/redis';
const adapter = createRedisCacheAdapter('redis://localhost:6379');
The Redis adapter implements CacheLike and adds:
getRemainingTtl(key) | Returns remaining TTL in milliseconds, null for non-expiring keys, and undefined for missing keys. |
getRemainingTtlMany(keys) | Batch TTL lookup. |
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.
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',
});
cache | Required cache instance that receives delPattern(pattern) 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. |
cache-hub/rate-limit
import {
createMemoryFixedWindowRateLimitStore,
createRedisFixedWindowRateLimitStore,
} from 'cache-hub/rate-limit';
MemoryFixedWindowRateLimitStore | Synchronous in-memory fixed-window counter. |
RedisFixedWindowRateLimitStore | Async Redis fixed-window counter backed by Lua scripts. |
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. |
reset(key) | Deletes one rate-limit key. |
resetPrefix(prefix) | Deletes keys under a literal prefix with SCAN. |
cache-hub/stringify
import { stableStringify } from 'cache-hub/stringify';
stableStringify({ b: 2, a: 1 });
stableStringify(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:6379
This URL means local Redis on port 6379 with no password. If your Redis
requires authentication, use the standard Redis URL form:
redis://:password@host:6379
For tests, set REDIS_URL when you need a non-default endpoint:
REDIS_URL=redis://127.0.0.1:6379 npm run test:integration
Testing
npm test
npm run test:coverage
npm run test:integration
SKIP_INTEGRATION=true npm run test:integration
npm 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
npm run benchmark
npm run benchmark -- --json
npm run benchmark -- --json --output benchmark-results.json
The 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
npm run typecheck
npm run build
Build output:
dist/
├── esm/
├── cjs/
└── types/
The package exposes matching ESM, CJS, and type declaration paths for every
public subpath export.
Node.js Support
| 18 LTS | Supported |
| 20 LTS | Supported |
| 22 LTS | Supported |
cache-hub requires Node.js >=18.0.0.
Troubleshooting
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. |
License
MIT