Tutorial

How to Dedupe In-flight Fetch by Key

Coalesce concurrent fetches that share a cache key into one request, so a hundred callers for the same resource produce one network round trip.

On this page

  1. Introduction
  2. Build the in-flight map
  3. Share the promise by key
  4. Clean up on settle
  5. Handle key serialization
  6. Measure coalesced requests
  7. Test concurrent callers
  8. Common pitfalls
  9. Conclusion

Introduction

The hardest part of deduplicate concurrent fetches is not writing the code — it is deciding what the code is allowed to reject. A single-flight setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in single-flight, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.

single-flight earns its place when deduplicate concurrent fetches is the bottleneck the user feels. Before that, it is overhead that slows the common case to protect a rare one. Measure the rejection rate and the tail latency, then decide whether the guard is worth its cost. A guard that never fires is a guard you can remove; a guard that fires constantly is a signal that the producer contract is wrong, not that the guard is too strict.

The sections below walk through the concrete decisions: where to put the single-flight check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.

Build the in-flight map

Build the in-flight map is the first decision because it determines what the rest of the single-flight pipeline can assume. A wrong choice here turns into deduplicate concurrent fetches under load, and the invariant you thought single-flight guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Build the in-flight map is the one most teams skip: drive single-flight to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

const inflight = new Map();
function dedupe(key, fn) {
  if (inflight.has(key)) return inflight.get(key);
  const p = fn().finally(() => inflight.delete(key));
  inflight.set(key, p); return p;
}

The typescript above is the shape to ship for deduplicate concurrent fetches. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.

Share the promise by key

The mechanism behind Share the promise by key is straightforward in single-flight, but the consequences ripple. A wrong choice here turns into deduplicate concurrent fetches under load, and the invariant you thought single-flight guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Share the promise by key is the one most teams skip: drive single-flight to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Clean up on settle

Clean up on settle is where most single-flight implementations leak performance. A wrong choice here turns into deduplicate concurrent fetches under load, and the invariant you thought single-flight guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Clean up on settle is the one most teams skip: drive single-flight to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

test('coalesces identical keys', async () => {
  let calls = 0; const fn = () => Promise.resolve(++calls);
  await Promise.all([dedupe('a', fn), dedupe('a', fn)]);
  expect(calls).toBe(1);
});

Pin the behavior with this test. A single-flight setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.

Handle key serialization

The hard part of Handle key serialization is not the happy path — it is what happens when single-flight fails halfway. A wrong choice here turns into deduplicate concurrent fetches under load, and the invariant you thought single-flight guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Handle key serialization is the one most teams skip: drive single-flight to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Measure coalesced requests

Measure coalesced requests should be observable without a debugger. A wrong choice here turns into deduplicate concurrent fetches under load, and the invariant you thought single-flight guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Measure coalesced requests is the one most teams skip: drive single-flight to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Test concurrent callers

Test concurrent callers is a trade, not a winner. A wrong choice here turns into deduplicate concurrent fetches under load, and the invariant you thought single-flight guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Test concurrent callers is the one most teams skip: drive single-flight to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Common pitfalls

The common pitfalls in deduplicate concurrent fetches all reduce to one mistake: treating single-flight as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.

  • Skipping the single-flight boundary so deduplicate concurrent fetches enters unvalidated.
  • Assuming single-flight is fast without measuring the tail latency under load.
  • Letting deduplicate concurrent fetches fail silently instead of failing loudly with a metric.
  • Testing only the happy path so the single-flight failure surfaces in production.
  • Sharing mutable state across requests so one bad input poisons the next.

Conclusion

Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: single-flight fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.

Self-check: write a test that drives single-flight to the failing condition and assert the system degrades gracefully. Then deduplicate concurrent fetches is a property you maintain, not a feature you hope for.

Sources: SWR, RxJS share

FAQ

Should the key include body?
Yes if body changes response. Serialize relevant parts deterministically.
Memory growth?
Map clears on settle. Add TTL for long-running polls.

Related Examples