@gustavoqnt/ttl-cache
v0.1.1
Published
In-memory TTL cache with LRU eviction, stale-while-revalidate, and request deduplication. Zero dependencies.
Downloads
211
Maintainers
Readme
@gustavoqnt/ttl-cache
In-memory TTL cache with LRU eviction, stale-while-revalidate (SWR), and request deduplication. TypeScript-first, zero dependencies.
Quick Start
npm install @gustavoqnt/ttl-cacheBasic usage
import { TtlCache } from '@gustavoqnt/ttl-cache';
const cache = new TtlCache<string, User>({
ttlMs: 60_000, // 1 minute (default: 30s)
maxSize: 1_000, // LRU eviction when exceeded
});
cache.set('user:1', { id: 1, name: 'Alice' });
const user = cache.get('user:1'); // User | undefinedStale-While-Revalidate
const user = await cache.getOrSet(
'user:1',
() => fetchUserFromDb(1),
{ ttlMs: 60_000, swrMs: 10_000 },
);|--- fresh (60s) ---|--- stale/SWR (10s) ---|--- expired ---|
0 60s 70s
│ │
└─ TTL expires └─ fully expired
serve stale + call loader,
background refresh wait for resultRequest Deduplication
// 3 concurrent calls = 1 loader execution
const [a, b, c] = await Promise.all([
cache.getOrSet('user:1', () => fetchUser(1)),
cache.getOrSet('user:1', () => fetchUser(1)),
cache.getOrSet('user:1', () => fetchUser(1)),
]);AbortSignal Support
const controller = new AbortController();
// Cancel this caller's wait without killing the loader for others
const user = await cache.getOrSet(
'user:1',
() => fetchUser(1),
{ signal: controller.signal },
);Comparison
| Feature | @gustavoqnt/ttl-cache | lru-cache | node-cache |
|---------|:---:|:---:|:---:|
| TTL | ✅ | ✅ | ✅ |
| LRU eviction | ✅ | ✅ | ❌ |
| Stale-while-revalidate | ✅ | ⚠️ partial | ❌ |
| Request dedup | ✅ | ❌ | ❌ |
| AbortSignal | ✅ | ❌ | ❌ |
| TypeScript-first | ✅ | ✅ | ⚠️ |
| Zero deps | ✅ | ✅ | ✅ |
| ESM + CJS | ✅ | ✅ | CJS only |
API Reference
new TtlCache<K, V>(options?)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| ttlMs | number | 30000 | Default TTL in ms. |
| maxSize | number | undefined | Max entries. LRU eviction when exceeded. |
| onEvict | (key, value, reason) => void | — | Callback on eviction. Reason: 'expired' | 'evicted' | 'manual' | 'clear' |
| cleanupIntervalMs | number \| false | false | Periodic sweep interval. Disabled by default — expiration is lazy + manual via prune(). |
Sync Methods
| Method | Returns | Description |
|--------|---------|-------------|
| set(key, value, options?) | void | Set a value. Optional per-key ttlMs. |
| get(key) | V \| undefined | Get a value. Returns undefined if not fresh. Does not serve stale. |
| peek(key) | V \| undefined | Get without updating LRU order. Returns fresh or stale values. |
| has(key) | boolean | Check existence (fresh or stale — not fully expired). |
| delete(key) | boolean | Remove an entry. Returns true if found. |
| clear() | void | Remove all entries. |
| size | number | Current entry count (including stale). |
| prune() | void | Manually remove all fully expired entries. |
Async Methods
| Method | Returns | Description |
|--------|---------|-------------|
| getOrSet(key, loader, options?) | Promise<V> | Get from cache or populate via loader. The only method that serves stale values (SWR). Deduplicates by default. |
GetOrSetOptions
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| ttlMs | number | constructor ttlMs | TTL for this entry. |
| swrMs | number | 0 | SWR window after TTL expires. |
| dedupe | boolean | true | Deduplicate concurrent calls for the same key. |
| signal | AbortSignal | — | Cancel this caller's wait. Does not cancel the loader for other waiters. |
Observability
| Method | Description |
|--------|-------------|
| getStats() | Returns { hits, misses, stale, loads, evictions, size } |
| on(event, handler) | Subscribe to: 'hit', 'miss', 'set', 'evict', 'load', 'stale' |
| off(event, handler) | Unsubscribe |
Iteration
for (const [key, value] of cache) { /* only fresh entries */ }
for (const key of cache.keys()) { /* ... */ }
for (const value of cache.values()) { /* ... */ }
for (const [key, value] of cache.entries()) { /* ... */ }- Yields only fresh entries (not stale, not expired).
- Expired entries are lazily pruned during iteration.
Cleanup
cache.dispose(); // Clears periodic sweep timer (if enabled) and all event listenersDesign Decisions
Why get() doesn't return stale values
get() is predictable: it returns fresh data or undefined. SWR is an async concern — it involves background loaders and error handling. That's why SWR is exclusive to getOrSet().
If you need to read a value without caring about freshness, use peek().
Why cleanup is disabled by default
Automatic timers in libraries are controversial — they can keep the process alive unexpectedly. By default, expiration is lazy (cleaned on access) and manual (via prune()).
If you want periodic cleanup, enable it explicitly:
const cache = new TtlCache({
cleanupIntervalMs: 60_000, // sweep every 60s
});
// Timer is unref()'d in Node.js — won't keep the process aliveSWR window is after TTL
The mental model is intuitive:
|--- fresh (ttlMs) ---|--- stale/SWR (swrMs) ---|--- expired ---|TTL expires → stale window opens → fully expired. This matches the HTTP stale-while-revalidate semantics.
Use Cases
- Database query caching — Cache expensive queries with TTL and SWR for near-zero latency reads
- API response caching — Deduplicate concurrent requests to the same upstream endpoint
- Configuration caching — Long TTL + SWR for hot-reload without downtime
- Rate limit friendly — Dedup prevents thundering herd when multiple consumers request the same resource
Performance
Local benchmarks (Node.js v22, indicative):
| Operation | Throughput | Notes |
|-----------|------------|-------|
| set 10k entries | 703 ops/s | ~1.4ms per batch |
| get 10k entries (100% hit) | 445 ops/s | ~2.2ms per batch |
| LRU eviction (20k→10k) | 244 ops/s | ~4.1ms per batch |
| Dedup: 100 concurrent getOrSet | 97 ops/s | 1 loader execution shared |
| SWR: serve stale immediately | 51 ops/s | No blocking on loader |
Run benchmarks yourself:
pnpm benchSee BENCHMARK.md for detailed instructions.
Gotchas
- Memory: No built-in memory-size limit — use
maxSizeto bound entry count - Dispose: Always call
dispose()if you enabledcleanupIntervalMs - SWR errors: Background refresh errors are silently swallowed; the stale value stays until fully expired
- Dedup errors: If a loader throws, the error propagates to all concurrent waiters
- AbortSignal: Aborting cancels the caller's wait, not the loader execution (other waiters and the cache still benefit from the load)
License
MIT
