●SONNET5 — Claude Sonnet 5 is now the default model in Claude Code, with a native 1M-token context and promo pricing through Aug 31●MCPTUNNEL — MCP tunnels arrives as a Research Preview, letting you reach MCP servers inside private networks●IDP — Admins can now provision MCP connectors via their IdP (starting with Okta); Asana, Figma, Linear and more support managed auth●SANDBOX — Claude Managed Agents can now run tool execution in your own self-hosted sandbox instead of Anthropic's infrastructure●FAST — Fast mode for Opus 4.7 is deprecated and will be removed on July 24; migrate to fast mode for Opus 4.8●FIXES — Recent fixes cover public gateway endpoints, a confirm prompt for external git worktrees, and MCP request timeouts●SONNET5 — Claude Sonnet 5 is now the default model in Claude Code, with a native 1M-token context and promo pricing through Aug 31●MCPTUNNEL — MCP tunnels arrives as a Research Preview, letting you reach MCP servers inside private networks●IDP — Admins can now provision MCP connectors via their IdP (starting with Okta); Asana, Figma, Linear and more support managed auth●SANDBOX — Claude Managed Agents can now run tool execution in your own self-hosted sandbox instead of Anthropic's infrastructure●FAST — Fast mode for Opus 4.7 is deprecated and will be removed on July 24; migrate to fast mode for Opus 4.8●FIXES — Recent fixes cover public gateway endpoints, a confirm prompt for external git worktrees, and MCP request timeouts
Coalescing Concurrent Claude API Calls: Single-Flight Against Duplicate Inference and Cache Stampede
A design for collapsing identical prompts that fire at the same instant into a single upstream Claude call, using single-flight (request coalescing). In-process and distributed implementations, jittered retries, and negative caching, with measured results.
Top of the hour. The summarization tasks I run overnight across four sites wake up at almost the same instant. This morning I was following that one minute through the request logs, and my hand stopped. The exact same "summarize today's news" prompt had gone out to Claude four separate times, spaced only milliseconds apart.
All four answers were effectively identical. But I was billed for four. This is also the very day Claude Code's weekly-limit boost (+50%) ends and Sonnet 5 has just become the default model, so my rate-limit headroom is thinner than before. "Buying the same answer over and over" is a small waste — until the headroom shrinks, and then it starts to sting.
What I needed was not a new cache. It was a way to fold the requests that fire at the same moment into one. This article records that folding — the design and implementation of single-flight (request coalescing), from in-process to distributed, with measurements attached.
When Does Duplicate Inference Happen? The Anatomy of a Stampede
Even with a result cache in place, the duplication does not disappear. In fact, it shows up precisely because you have a cache.
The key is concurrency. A cache hit only happens after someone has called upstream once and finished writing the result. But when several callers plunge into a cache miss at the same instant — as they do at a nightly peak — every one of them concludes "it's not in the cache, so I'll compute it myself," and they all rush upstream together. That is a cache stampede (thundering herd).
Mechanism
Solves
Does not solve
Exact-match cache
Reusing a request identical to a past one
Duplicate first-time misses running concurrently
Semantic cache
Reusing semantically similar requests
Concurrent misses; the rush right after expiry
Single-flight
Duplicate identical requests running concurrently
Reuse across time (you still need a cache)
So single-flight and caching do not compete — they play different roles. A cache handles "reuse across time"; single-flight handles "deduplication within the same instant." Only when you layer both does the waste disappear both at the moment of a miss and after a hit. For the result-reuse side of the design, see "Putting a Semantic Cache for the Claude API into Production." This article concentrates on the folding side.
The duplication I measured in my own setup, running several sites as an indie developer, averaged 4.2 calls per minute during the top-of-the-hour peak — the result of related tasks across four sites each firing the same summarization prompt independently.
In-Process Single-Flight: The Minimal Implementation
If everything lives in one Node/Worker process, the implementation is surprisingly small. The heart of it is "share the in-progress Promise by request key." Exactly one caller hits upstream; the rest await the same Promise.
// singleflight.tstype Entry<T> = { promise: Promise<T>; controller: AbortController };export class SingleFlight<T> { private inflight = new Map<string, Entry<T>>(); // fn receives an AbortSignal; if every waiter cancels, upstream stops too. async run(key: string, fn: (signal: AbortSignal) => Promise<T>): Promise<T> { const existing = this.inflight.get(key); if (existing) return existing.promise; // ride along with the identical in-flight request const controller = new AbortController(); const promise = fn(controller.signal).finally(() => { // Always delete, success or failure. Leaving it leaks memory and pins a stale result. this.inflight.delete(key); }); this.inflight.set(key, { promise, controller }); return promise; } get size() { return this.inflight.size; }}
On the calling side, building a stable key from the prompt is what matters. Unless you fold in the model name, temperature, max_tokens, and system prompt, you will accidentally coalesce requests that were meant to differ.
import { createHash } from "node:crypto";import Anthropic from "@anthropic-ai/sdk";const client = new Anthropic(); // ANTHROPIC_API_KEY comes from the environmentconst sf = new SingleFlight<string>();function requestKey(model: string, system: string, user: string, opts: Record<string, unknown>) { const norm = user.trim().replace(/\s+/g, " "); // minimal normalization of whitespace const payload = JSON.stringify({ model, system, user: norm, opts }); return createHash("sha256").update(payload).digest("hex");}async function summarize(newsText: string): Promise<string> { const model = "claude-sonnet-5"; const system = "You are a concise summarizer."; const key = requestKey(model, system, newsText, { max_tokens: 512 }); return sf.run(key, async (signal) => { const res = await client.messages.create( { model, max_tokens: 512, system, messages: [{ role: "user", content: newsText }] }, { signal }, // cancel upstream when every rider drops out ); return res.content.map((b) => (b.type === "text" ? b.text : "")).join(""); });}
With that small amount of code, identical requests running concurrently in the same process always converge to one. In my nightly batch, this alone dropped the peak duplication from 4.2 to 1.1 calls per minute. The remaining duplicates came from "another process / another worker." That is what we fold next.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A complete TypeScript single-flight implementation that folds simultaneous identical prompts into one upstream call, with cancellation propagation and a memory-leak guard
✦A distributed variant for multi-worker and edge runtimes: short-TTL lock plus negative caching plus jittered expiry, so the herd does not re-form the instant a result expires
✦Measured on real nightly automation: duplicate rate 76% to 3%, input tokens down roughly 60% per month, plus an adoption decision table and rollout checklist
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Folding Across a Distributed Runtime: Multi-Worker and Edge
In an environment like Cloudflare Workers, where callers are spread across many instances, an in-process Map is not shared. Here we "elect one representative" with a short-lived lock. Only the representative computes upstream; the others wait briefly for the result under a short TTL.
// distributed-singleflight.ts (backed by Cloudflare KV)interface Env { CACHE: KVNamespace }const RESULT_TTL = 60; // how long a shared result lives (seconds)const LOCK_TTL = 30; // lock that marks "computation in progress" (seconds)const NEG_TTL = 10; // short negative cache that remembers a failure (seconds)export async function coalesced( env: Env, key: string, compute: () => Promise<string>,): Promise<string> { const resKey = `res:${key}`; const lockKey = `lock:${key}`; // 1) If someone already finished computing, return immediately. const hit = await env.CACHE.get(resKey); if (hit !== null) return hit; // 2) Try to take the lock. If you can't, a representative is computing; wait and re-read. const gotLock = await tryAcquire(env, lockKey, LOCK_TTL); if (!gotLock) return await waitForResult(env, resKey); // 3) As the representative, compute -> share the result; remember failures briefly. try { const value = await compute(); await env.CACHE.put(resKey, value, { expirationTtl: withJitter(RESULT_TTL) }); return value; } catch (e) { await env.CACHE.put(`neg:${key}`, "1", { expirationTtl: NEG_TTL }); throw e; } finally { await env.CACHE.delete(lockKey); }}async function tryAcquire(env: Env, lockKey: string, ttl: number): Promise<boolean> { if ((await env.CACHE.get(lockKey)) !== null) return false; // approximate mutual exclusion await env.CACHE.put(lockKey, "1", { expirationTtl: ttl }); return true;}async function waitForResult(env: Env, resKey: string): Promise<string> { for (let i = 0; i < 15; i++) { await sleep(200 + Math.random() * 200); // jitter the polling too const v = await env.CACHE.get(resKey); if (v !== null) return v; } throw new Error("coalesced wait timeout"); // insurance if the representative dies}function withJitter(ttl: number) { return ttl + Math.floor(Math.random() * ttl * 0.2); }const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
The part that really earns its keep here is withJitter. If every entry expires on the same 60-second boundary, a fresh stampede fires the instant they expire. Adding a plus/minus 20% wobble to the TTL scatters the expiries and stops the herd from reassembling. KV is eventually consistent, so this is not a strict lock — but for uses like summarization, where "a little duplication is fine but a lot is painful," it is more than enough. If you truly need exactness, a Durable Object can give you a single serialization point.
Scattering the Retry Herd: Backoff with Jitter
The other herd is born from retries after failure. If several callers that hit a 429 at the same time all decide to "retry in one second," they all rush again one second later. The standard fix is to always mix jitter into exponential backoff.
async function withRetry<T>(fn: () => Promise<T>, max = 5): Promise<T> { for (let attempt = 0; ; attempt++) { try { return await fn(); } catch (e: any) { const status = e?.status ?? e?.response?.status; const retryable = status === 429 || status === 503 || status === 529; if (!retryable || attempt >= max) throw e; // Respect Retry-After when present; otherwise use full jitter (uniform 0..cap). const header = Number(e?.headers?.["retry-after"]); const base = Number.isFinite(header) ? header * 1000 : Math.min(1000 * 2 ** attempt, 20_000); const delay = Math.random() * base; // full jitter await new Promise((r) => setTimeout(r, delay)); } }}
The Math.random() * base full-jitter form spreads the wait uniformly across 0 to the cap. It is known to converge faster than waiting for exactly the cap. For the fundamentals of handling 429s, "Handling Claude API Rate Limit (429) Errors" is a good companion.
Pitfalls the Official Docs Don't Cover
Here are the spots that quietly cause incidents after the code "works." These are ones I actually stepped on.
Pitfall
Symptom
Fix
Leaving parameters out of the key
Requests with different temperature or model get folded; one answer bleeds into the other
Include model, system, max_tokens, and temperature in the key
Caching exceptions for too long
A transient 529 is remembered for minutes; errors persist after recovery
Keep the negative cache to a short TTL, around 10 seconds
Not deleting in finally
The key lingers in the Map: memory leak plus a pinned stale result
Delete in finally regardless of success or failure
Riding along on streaming
You can't hand tokens to the later waiters
For streaming, resolve once as non-streaming then distribute, or exclude it from coalescing
Synchronized expiry
A fixed TTL expires everything at once and re-stampedes
Add jitter to the TTL
"Riding along on streaming" is especially easy to miss. Single-flight shines for uses that return one settled response — summarization, classification, extraction. If you want to re-broadcast incremental tokens to multiple waiters, either hand out the finished string after it settles, or decide not to coalesce at all.
Recommendations by Situation
Environment / load
Recommendation
Why
Single process (long-lived worker)
In-process Map single-flight
Zero external dependencies; riders join in milliseconds
Multi-worker / edge (some duplication OK)
KV short-TTL lock + negative cache + jitter
Eventual consistency is enough; light to implement
Strict single execution required
A single serialization point via Durable Objects
Eliminates the misses of an approximate lock
High-fanout batch
Single-flight + a concurrency cap (semaphore)
Fold first, then bound upstream parallelism too
Rollout Checklist
Build the key from model, system, normalized user text, temperature, and max_tokens — all of them.
In-process, always delete from the Map in finally (success or failure).
In a distributed runtime, elect a representative with a short-TTL lock; have waiters poll with jitter.
Separate the result TTL from the negative-cache TTL (keep the negative one short, around 10 seconds).
Add jitter to every TTL to break expiry synchronization.
Use full jitter for retries, and respect Retry-After when you can.
For streaming, decide whether coalescing is even appropriate before you fold.
Here are the numbers after rollout. The top-of-the-hour duplicate rate went from 76% to 3%, and upstream calls converged from an average of 4.2 to 1.1 per minute. Input tokens fell by roughly 60% per month. This is not a flashy optimization. It is the quiet accumulation of one obvious principle: never buy the same answer twice.
The thinner the headroom, the more this kind of trimming pays off. If this small way of folding requests brings a little more calm to your nightly runs, I would be glad. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.