Async Memoize with TTLLoading saved progress…

Async Memoize with TTL

Async memoization with a TTL wraps an async function so that a resolved result is cached under a key built from its arguments, served to later callers for a fixed time window, and shared by any overlapping calls for the same key so the underlying work runs only once. It is the standard fix for a "thundering herd" of identical requests: many callers ask for the same thing at once, and you want a single network round-trip whose answer everyone reuses until it goes stale.

Implement asyncMemoizeTtl(fn, ttl). It returns a new function that memoizes fn. The cache key is JSON.stringify(args). A resolved value is reused for ttl milliseconds; after that the next call re-runs fn. Calls that arrive for the same key while a call is still in flight all resolve from one shared promise. A rejected call is not cached — the next call retries.

Signature

function asyncMemoizeTtl<A extends unknown[], R>(
  fn: (...args: A) => Promise<R>,
  ttl: number, // ms a resolved value stays fresh
): (...args: A) => Promise<R>;

Examples

let calls = 0;
const load = asyncMemoizeTtl(async (id) => {
  calls++;
  return fetchUser(id);
}, 1000);

await load(7); // miss -> runs fn, calls === 1
await load(7); // hit  -> from cache, calls === 1
// ...1000ms later...
await load(7); // ttl expired -> runs fn, calls === 2
const load = asyncMemoizeTtl(fetchUser, 1000);

// Three callers fire before the first request resolves:
const [a, b, c] = await Promise.all([load(7), load(7), load(7)]);
// fetchUser(7) ran once; a, b, and c are the same resolved value.

Notes

  • Key by arguments — Build the cache key with JSON.stringify(args) so calls with equal arguments share an entry.
  • Coalesce, do not duplicate — While a call for a key is pending, additional calls for that key must attach to the same promise; fn runs a single time.
  • TTL covers resolved values only — A rejection is never cached, so a failed call is retried on the next invocation.
  • Real time — Freshness is wall-clock milliseconds; compare against Date.now().
  • Do not worry about — cache eviction, size limits, cancellation, or non-serialisable arguments; a Map keyed by the JSON string is enough here.

FAQ

Is a rejected call cached?
No. A rejection clears the in-flight entry and writes nothing to the cache, so the very next call for that key runs fn again and gets a fresh chance to succeed.
How are the arguments turned into a cache key?
The wrapper uses JSON.stringify(args), so two calls with equal serialisable arguments share one entry. Non-serialisable arguments like functions, symbols, or circular objects will not key reliably.
Why keep a separate in-flight map instead of just caching the promise?
Caching the promise forever would also cache failures and ignore the TTL. A separate in-flight map coalesces callers while the request is pending, then you cache only the resolved value with an expiry.
Loading editor…