Coding interview: Implement per-key request coalescing (singleflight)
Prompt and context
Implement a concurrency-safe async helper, coalesce(key, task). At most one task may run for a given key at a time. Concurrent callers with the same key must await and receive the exact same value or error; different keys must run independently.
The task may throw synchronously or reject asynchronously. Callers may set their own waiting timeout. The entry must be removed after both success and failure so a later call can retry. Explain cancellation semantics, error propagation, and tests.
What the interviewer is testing
The core is turning deduplication into a provable concurrency invariant: install the shared promise before starting async work, delete only when the map still points to that entry, and keep keys independent. The interviewer also wants you to distinguish one caller abandoning its wait from cancelling shared work, and to address unbounded memory growth.
Clarifying questions
- Must keys be non-empty or normalized? I would reject an empty key so unrelated requests cannot collapse accidentally.
- Should a caller timeout cancel upstream work? By default it stops that caller's wait only and leaves shared work running for other waiters.
- Should errors be cached? No. Delete after settlement so the next call retries.
- Is cross-process coalescing required? No; this prompt is single-process memory. Cross-process coordination is a separate design.
30-second answer
I keep in-flight work in a Map<key, Entry>. On entry, return the existing promise for a hit. On a miss, create the promise, put it in the map before awaiting, and then run the task. In finally, delete only if the map still contains that same entry. Same-key callers share one execution, different keys do not block each other, and failures release state for retry. A caller timeout races its wait without cancelling shared work. Tests cover duplicate calls, independent keys, synchronous throws, rejection and retry, and cleanup races.
Step-by-step deep dive
Define an Entry that can hold the shared promise and, if needed, an internal controller. The ordering is the important part:
const inFlight = new Map<string, Promise<unknown>>();
function coalesce<T>(key: string, task: () => Promise<T>): Promise<T> {
if (!key) return Promise.reject(new Error("key must not be empty"));
const existing = inFlight.get(key);
if (existing) return existing as Promise<T>;
let shared: Promise<T>;
try {
shared = Promise.resolve().then(task);
} catch (error) {
shared = Promise.reject(error);
}
inFlight.set(key, shared);
shared.finally(() => {
if (inFlight.get(key) === shared) inFlight.delete(key);
}).catch(() => undefined);
return shared;
}An object entry can additionally record start time, waiter count, and an AbortController. Promise.resolve().then(task) makes synchronous throws and async rejections follow one path. The map insertion must happen before the first await; otherwise two event-loop turns can both observe a miss. The identity check prevents an old task's finally from deleting a newer entry.
Caller timeout is an outer policy:
function waitWithTimeout<T>(shared: Promise<T>, ms: number): Promise<T> {
return Promise.race([
shared,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("wait timeout")), ms),
),
]);
}The shared task still completes for other waiters. If the product requires cancellation when everyone leaves, add reference counting and define that race explicitly in the contract and tests.
Expected map operations are O(1). With K distinct in-flight keys, state is O(K); delivering one result costs proportional to the number of waiters. Production code should bound key cardinality and expose duration, timeout, waiter-count, and error metrics so the map does not become an unbounded cache.
High-quality sample answer
I would state the boundary first: single process, in-flight deduplication only, no result cache. The Map stores each entry. On a miss I create and register the promise immediately, then invoke the user task. Every caller receives the same promise, so values and errors are identical. Cleanup compares object identity, preventing an old completion from deleting a newer generation.
Cancellation means “cancel waiting, not shared work”: one timed-out caller does not send an AbortError to other waiters or interrupt the sole upstream operation. If true cancellation is required, I would use a shared controller plus waiter reference counting and cancel only when the count reaches zero.
For tests, I use a barrier to release simultaneous callers and assert one task invocation and one shared result. I also test independent keys in parallel, synchronous throw, asynchronous rejection, retry after failure, a new task after success, an old finally racing with a new entry, and one caller timing out while another still succeeds. I would finish with capacity limits and metrics for high-cardinality keys.
Common mistakes
- Awaiting
task()before inserting into the map, which permits duplicate execution. - Unconditionally deleting by key in
finally, allowing an old task to remove a newer entry. - Passing one caller's
AbortSignaldirectly to shared work and cancelling every waiter. - Keeping a rejected promise forever instead of deleting it for retry.
- Using one global lock and serializing unrelated keys.
- Testing only sequential calls instead of simultaneous arrival, synchronous throws, and cleanup races.
Follow-up questions and answers
How would you support true cancellation?
When is cross-process coalescing needed?
How do you prevent high-cardinality key leaks?
True cancellation needs a shared controller, reference counting, and an explicit zero-waiter policy. Cross-process cases require Redis, a gateway, or another coordinator plus leases, leader failure handling, and duplicate-execution tolerance. High-cardinality keys need capacity limits, TTL or eviction policy, rejection behavior, and metrics; these controls must preserve the core rule that only in-flight work is stored.