Two widgets may request the same product at nearly the same time. A cache cannot help if neither request has completed yet. Single-flight shares pending work for one key, reducing duplicate calls while preserving a promise for each caller.
Before you start
You need promises, Map and function parameters. This example shares pending reads only. It does not retain completed results, provide retry policy or authorize access. Choose a key that includes every dimension affecting the result, such as tenant and query parameters.
Step-by-step walkthrough
Step 1: Describe the ownership rule
The coordinator owns one pending promise per key. Callers asking for the same key receive that promise. Different keys remain independent. A request for the same product from different authorization contexts must not share unless the returned representation is genuinely identical and safe to share.
Step 2: Register before loading
Create a promise that invokes the adapter in a microtask, then store it immediately. This converts an adapter’s synchronous throw into rejection and lets another same-turn call find the registered operation. Calling the adapter first can leave a registration gap, especially with unusual synchronous behavior.
Step 3: Remove only the owned entry
Cleanup runs after either fulfillment or rejection. Compare the map’s current promise with the settling promise before deleting. That check makes ownership explicit and protects future changes that allow replacement or invalidation while old work is pending.
Worked scenario
Copy this into an ES module and run it with Node.js. The adapter is controlled so you can see exactly when shared work finishes.
function singleFlight(load) {
const pending = new Map();
return function get(key) {
if (pending.has(key)) return pending.get(key);
const work = Promise.resolve().then(() => load(key));
const owned = work.finally(() => {
if (pending.get(key) === owned) pending.delete(key);
});
pending.set(key, owned);
return owned;
};
}
let calls = 0;
let release;
const get = singleFlight(() => {
calls++;
return new Promise(resolve => { release = resolve; });
});
const first = get('product-1');
const second = get('product-1');
console.log(first === second); // true
await Promise.resolve();
console.log(calls); // 1
release({ name: 'Notebook' });
console.log((await second).name); // Notebook
await first;Both calls return before the loader’s microtask runs. Once it runs, calls is one and release becomes available. Resolving the adapter fulfills the shared result, and cleanup removes the entry. A later call starts another read: pending coordination is not persistent caching. Returned objects are shared too, so consumers should not unexpectedly mutate them.
Common mistake
A global key containing only product ID can mix tenants or user-specific representations. Another mistake is allowing one caller’s abort signal to cancel work still needed by the other caller. Define cancellation ownership separately: a caller can stop waiting without necessarily cancelling the shared transport.
Verify the behavior
Assert identical keys start one adapter call and different keys start separate calls. Reject a request, then call again and verify a fresh adapter invocation. Also test synchronous adapter throws and success followed by another read. Bound distinct concurrent keys if input can grow without limit.
Interview exercise
Why remove rejected entries? What changes if completed data should be cached?
Answer and reasoning
Retaining a rejected promise makes every later caller receive the same failure without another attempt. Removing it permits a new operation. Completed caching needs a separate freshness and capacity contract; retaining fulfilled promises indefinitely can serve stale data and grow memory. Single-flight can sit beside such a cache, but their responsibilities should remain explicit.
Continue learning
Compare promise combinators and JavaScript interview questions. Review Promise behavior for the language contract.