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.
function asyncMemoizeTtl<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
ttl: number, // ms a resolved value stays fresh
): (...args: A) => Promise<R>;
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.
JSON.stringify(args) so calls with equal arguments share an entry.fn runs a single time.Date.now().Map keyed by the JSON string is enough here.