Tiny async cache: TTL + LRU, single-flight dedup, and stale-while-revalidate. Zero dependencies.
Caching an expensive async call sounds simple — until ten requests for the same
cold key arrive at once and all ten hit your database (a cache stampede), or a
value expires mid-traffic and every caller suddenly blocks on a slow refresh.
flightcache solves both: it coalesces concurrent calls into one (single
flight) and can serve a slightly stale value instantly while refreshing in the
background (stale-while-revalidate).
import{memoize}from"flightcache";constgetUser=memoize((id: string)=>db.users.find(id),{ttl: 30_000,// fresh for 30sstaleWhileRevalidate: 60_000,// then serve stale up to 60s more while refreshingmax: 1000,// LRU cap});// 50 concurrent calls for the same id → the DB is hit exactly once.awaitPromise.all(ids.map(getUser));- No stampede. Concurrent calls for the same key share a single in-flight promise instead of all recomputing.
- No latency cliff. With
staleWhileRevalidate, expiry returns the cached value immediately and refreshes in the background — readers never wait on a slow recompute. - TTL + LRU built in. Per-entry TTL and least-recently-used eviction.
- Failures aren't cached. A rejected computation propagates and is retried next call, not memoized.
- Zero dependencies, ESM + CJS + types, and an injectable clock for tests.
npm install flightcache
# or: pnpm add flightcache / yarn add flightcache / bun add flightcacheWrap an async (or sync) function. The returned function caches by key and exposes
.cache, .invalidate(...args), and .clear().
| Option | Type | Default | Description |
|---|---|---|---|
ttl | number (ms) | 60000 | How long a value stays fresh. |
staleWhileRevalidate | number (ms) | 0 | Extra window to serve stale while refreshing. |
max | number | unbounded | LRU cap on cached keys. |
key | (...args) => string | JSON.stringify(args) | Derive the cache key from arguments. |
singleFlight | boolean | true | Coalesce concurrent same-key calls. |
clock | () => number | Date.now | Injectable clock for deterministic tests. |
constm=memoize(fetchThing,{ttl: 5_000});awaitm("a");m.invalidate("a");// drop one keym.clear();// drop everythingm.cache.size;// inspect the underlying cacheThe storage layer on its own — a TTL + LRU map.
import{TTLCache}from"flightcache";constcache=newTTLCache<string,Buffer>({max: 500,ttl: 10_000});cache.set("k",buf);cache.get("k");// bumps recency; undefined if expiredcache.peek("k");// no recency changecache.has("k");cache.delete("k");cache.keys();// live keys, oldest → newest- This is an in-memory, single-process cache (per Node instance / browser tab).
For cross-process sharing, put a Redis/Memcached layer behind the same
memoizefunction body. - Keys default to
JSON.stringify(args); passkeyfor stable hashing of complex arguments or to ignore volatile fields.
This project follows the all-contributors specification. Contributions of any kind are welcome — code, docs, bug reports, ideas, reviews! See the emoji key for how each contribution is recognized, and open a PR or issue to get involved.
Thanks goes to these wonderful people:
Tung Tran 💻🚧 |
MIT © Tung Tran