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.You will wrap an async function so that identical requests share one run, its answer is reused for a while, and failures are never remembered.
Your page loads and three components all ask for user 7 at the same instant. Without help, that is three network requests for the same data. You want the first request to be the only one — the other two should ride along on it — and once it answers, you want to reuse that answer for a bit before asking again. That "reuse for a bit" is the TTL (time to live); the "ride along" is coalescing.
Think of each key as owning a little clock. The first call starts the work and, when it finishes, stamps the answer with an expiry ttl milliseconds in the future. Any call that arrives while the clock is still valid gets the stored answer for free; once the clock runs out, the next call starts fresh work and re-stamps.
The obvious move is one Map from key to promise: if you have not seen the key, call fn and store the promise it returns; otherwise hand back the stored promise.
function asyncMemoizeTtl(fn, ttl) {
const cache = new Map(); // key -> promise
return function (...args) {
const key = JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, fn(...args)); // store the promise itself
}
return cache.get(key);
};
}
This actually coalesces correctly — concurrent callers for the same key all receive the one stored promise, so fn runs once. But it breaks the other two rules. It never looks at ttl, so a value is cached forever and goes stale. And because it stores the promise, a rejected call is remembered as a rejected promise: every later call for that key replays the same failure instead of retrying.
The fix is to split that single promise-cache into two maps with different lifetimes. One holds the resolved value with an expiry; the other holds the shared promise only while the call is in flight.
function asyncMemoizeTtl(fn, ttl) {
// Resolved values live here: key -> { value, expiresAt }.
const cache = new Map();
// Calls still running live here: key -> the one shared Promise.
const inflight = new Map();
return function memoized(...args) {
const key = JSON.stringify(args);
// 1. Fresh cached value? Serve it without touching fn.
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return Promise.resolve(cached.value);
}
// 2. A call for this key is already running? Share its promise.
if (inflight.has(key)) {
return inflight.get(key);
}
// 3. Cold: run fn once. Cache the value on success; on failure
// cache nothing so the next call retries. Either way, stop
// treating the key as in-flight.
const shared = Promise.resolve(fn(...args)).then(
(value) => {
cache.set(key, { value, expiresAt: Date.now() + ttl });
inflight.delete(key);
return value;
},
(err) => {
inflight.delete(key);
throw err;
}
);
inflight.set(key, shared);
return shared;
};
}
module.exports = { asyncMemoizeTtl };
The key shift from the naive version is what you store and when you drop it. You cache the resolved value, not the promise, so a failure leaves the cache empty and the next call retries. You keep the shared promise in inflight only while it is pending and delete it in both settle handlers, so coalescing works during the request but nothing is remembered forever. Freshness is then a simple expiresAt > Date.now() check, stamped at the moment the value resolves.
Say ttl = 1000 and fn is a network fetch that takes 200ms. Watch key [7]:
load(7). cache is empty and inflight is empty, so this is the cold path. You call fn(7), wrap it, and store the shared promise in inflight under [7]. Return it.load(7) again, while the request is still pending. cache still has nothing, but inflight has [7], so you return the same promise. fn is not called a second time; both callers now await one request.fn(7) resolves with { name: 'Ada' }. The success handler runs: it writes cache['[7]'] = { value: { name: 'Ada' }, expiresAt: 1200 } and deletes inflight['[7]']. Both callers receive { name: 'Ada' }.load(7). cache has [7] with expiresAt 1200, and 1200 > 800, so it is a hit. You return Promise.resolve({ name: 'Ada' }) — no fn, no network.load(7). cache still has [7], but 1200 > 1300 is false, so the value is stale. inflight is empty, so you fall to the cold path and call fn(7) again, opening a fresh window.cache only inside the success handler, and leave it untouched when fn rejects.inflight.delete(key) when the call settles, the key looks "in flight" forever, so step 2 always short-circuits and fn never re-runs after expiry. Fix: delete it in both the success and failure handlers.Map keyed by the raw args array compares by reference, so load(7) and load(7) become two different keys that never coalesce or hit. Fix: serialise with JSON.stringify(args).expiresAt at call time — computing the expiry when the call starts (not when it resolves) means a slow fn eats into the window. Fix: set expiresAt to Date.now() + ttl inside the success handler, after the value arrives.keyFn(args) so non-serialisable arguments (a DOM node, a class instance) can be keyed by an id you choose instead of JSON.stringify.Map grows without limit; add a max size with least-recently-used eviction, or a periodic sweep that drops expired entries.Promise.resolve().then(() => fn(...args)) so a fn that throws synchronously becomes a rejected promise instead of throwing out of the memoized function.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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.You will wrap an async function so that identical requests share one run, its answer is reused for a while, and failures are never remembered.
Your page loads and three components all ask for user 7 at the same instant. Without help, that is three network requests for the same data. You want the first request to be the only one — the other two should ride along on it — and once it answers, you want to reuse that answer for a bit before asking again. That "reuse for a bit" is the TTL (time to live); the "ride along" is coalescing.
Think of each key as owning a little clock. The first call starts the work and, when it finishes, stamps the answer with an expiry ttl milliseconds in the future. Any call that arrives while the clock is still valid gets the stored answer for free; once the clock runs out, the next call starts fresh work and re-stamps.
The obvious move is one Map from key to promise: if you have not seen the key, call fn and store the promise it returns; otherwise hand back the stored promise.
function asyncMemoizeTtl(fn, ttl) {
const cache = new Map(); // key -> promise
return function (...args) {
const key = JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, fn(...args)); // store the promise itself
}
return cache.get(key);
};
}
This actually coalesces correctly — concurrent callers for the same key all receive the one stored promise, so fn runs once. But it breaks the other two rules. It never looks at ttl, so a value is cached forever and goes stale. And because it stores the promise, a rejected call is remembered as a rejected promise: every later call for that key replays the same failure instead of retrying.
The fix is to split that single promise-cache into two maps with different lifetimes. One holds the resolved value with an expiry; the other holds the shared promise only while the call is in flight.
function asyncMemoizeTtl(fn, ttl) {
// Resolved values live here: key -> { value, expiresAt }.
const cache = new Map();
// Calls still running live here: key -> the one shared Promise.
const inflight = new Map();
return function memoized(...args) {
const key = JSON.stringify(args);
// 1. Fresh cached value? Serve it without touching fn.
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return Promise.resolve(cached.value);
}
// 2. A call for this key is already running? Share its promise.
if (inflight.has(key)) {
return inflight.get(key);
}
// 3. Cold: run fn once. Cache the value on success; on failure
// cache nothing so the next call retries. Either way, stop
// treating the key as in-flight.
const shared = Promise.resolve(fn(...args)).then(
(value) => {
cache.set(key, { value, expiresAt: Date.now() + ttl });
inflight.delete(key);
return value;
},
(err) => {
inflight.delete(key);
throw err;
}
);
inflight.set(key, shared);
return shared;
};
}
module.exports = { asyncMemoizeTtl };
The key shift from the naive version is what you store and when you drop it. You cache the resolved value, not the promise, so a failure leaves the cache empty and the next call retries. You keep the shared promise in inflight only while it is pending and delete it in both settle handlers, so coalescing works during the request but nothing is remembered forever. Freshness is then a simple expiresAt > Date.now() check, stamped at the moment the value resolves.
Say ttl = 1000 and fn is a network fetch that takes 200ms. Watch key [7]:
load(7). cache is empty and inflight is empty, so this is the cold path. You call fn(7), wrap it, and store the shared promise in inflight under [7]. Return it.load(7) again, while the request is still pending. cache still has nothing, but inflight has [7], so you return the same promise. fn is not called a second time; both callers now await one request.fn(7) resolves with { name: 'Ada' }. The success handler runs: it writes cache['[7]'] = { value: { name: 'Ada' }, expiresAt: 1200 } and deletes inflight['[7]']. Both callers receive { name: 'Ada' }.load(7). cache has [7] with expiresAt 1200, and 1200 > 800, so it is a hit. You return Promise.resolve({ name: 'Ada' }) — no fn, no network.load(7). cache still has [7], but 1200 > 1300 is false, so the value is stale. inflight is empty, so you fall to the cold path and call fn(7) again, opening a fresh window.cache only inside the success handler, and leave it untouched when fn rejects.inflight.delete(key) when the call settles, the key looks "in flight" forever, so step 2 always short-circuits and fn never re-runs after expiry. Fix: delete it in both the success and failure handlers.Map keyed by the raw args array compares by reference, so load(7) and load(7) become two different keys that never coalesce or hit. Fix: serialise with JSON.stringify(args).expiresAt at call time — computing the expiry when the call starts (not when it resolves) means a slow fn eats into the window. Fix: set expiresAt to Date.now() + ttl inside the success handler, after the value arrives.keyFn(args) so non-serialisable arguments (a DOM node, a class instance) can be keyed by an id you choose instead of JSON.stringify.Map grows without limit; add a max size with least-recently-used eviction, or a periodic sweep that drops expired entries.Promise.resolve().then(() => fn(...args)) so a fn that throws synchronously becomes a rejected promise instead of throwing out of the memoized function.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.