Idempotency Key QueueLoading saved progress…

Idempotency Key Queue

An idempotency-key queue guarantees that a task tied to a caller-supplied key runs at most once within a time window, and that every duplicate request — one that overlaps the first call or one that arrives long after it finished — receives the same result without re-running the work. This is the client-side shape of Stripe-style idempotency keys: a user double-clicks Pay, or a flaky network silently retries the same request, and you must charge the card exactly once and hand the same charge back to every duplicate.

Implement idempotencyKeyQueue(options). The options object carries ttl — the number of milliseconds a completed result stays cached (default Infinity). It returns an object with a run(key, task) method plus has(key) and a size count. The first run for a key invokes task() and stores the in-flight promise; a duplicate while that promise is pending shares it; a duplicate after it resolved returns the cached value; and a rejected task is evicted so a retry runs the task again.

Signature

function idempotencyKeyQueue(options?: { ttl?: number }): IdempotencyQueue;

interface IdempotencyQueue {
  // Runs task() for `key` at most once per ttl window. A concurrent duplicate
  // shares the in-flight promise; a later duplicate returns the cached result;
  // a failure evicts the key so the next call retries.
  run<T>(key: string, task: () => Promise<T>): Promise<T>;
  has(key: string): boolean; // is the key in-flight or cached?
  size: number; // count of in-flight plus cached keys
}

Examples

const q = idempotencyKeyQueue({ ttl: 60000 });
const charge = () => api.charge(cart); // side effect: money moves

const a = q.run('checkout-42', charge);
const b = q.run('checkout-42', charge); // duplicate while in flight
a === b; // true — charge() ran once; both callers await the one call

await a;
q.run('checkout-42', charge); // duplicate after success → cached result, no charge
const q = idempotencyKeyQueue({ ttl: 30 });

await q.run('k', task); // miss → runs task
await q.run('k', task); // hit  → cached, task not run
// ...30ms later...
await q.run('k', task); // window closed → runs task again

// failures are never cached:
await q.run('save', failing).catch(() => {});
await q.run('save', save); // key was evicted → runs the task again

Notes

  • At most once — While a key is in flight or cached, run(key, task) never invokes task again; every duplicate resolves from the shared work.
  • Concurrent versus later — A duplicate that overlaps the first call shares its pending promise; a duplicate after it finished reads the cached value.
  • TTL covers resolved values — A completed result is cached for ttl milliseconds (default Infinity); after it expires, the next call re-runs the task.
  • Failures are not cached — A rejected task evicts its key, so a retry with the same key runs the task again and can succeed.
  • You choose the key — Deduplication happens only for calls that pass the same key string; different keys run and cache independently.
  • Do not worry about — Persistence across reloads, cancellation, or a concurrency limit; an in-memory Map per queue is enough here.

FAQ

How is this different from singleflight or request de-duplication?
Singleflight only coalesces calls that overlap in time and forgets the result the moment the promise settles, so a duplicate arriving after completion re-runs the work. An idempotency-key queue also caches the completed result for a ttl window, so a later duplicate returns the stored result without running the task again.
Why are failures not cached?
A failed operation is retryable. If you cached the rejection, every retry with the same key would replay the same failure forever. Evicting the key on failure lets the next call run the task again and possibly succeed.
What is the default ttl?
Infinity, so a completed result is cached until you pass a finite ttl in milliseconds. With a finite ttl a real timer drops the entry when the window closes, and the next call runs the task fresh.
Loading editor…