Batching & Coalescing
When two clients ask the model the same question at the same time, one upstream call should be enough. Build a singleflight that proves it.
Why coalesce identical in-flight prompts
Coalescing (aka singleflight, aka request deduplication) is the cheapest reliability and cost win you'll find. When N concurrent requests share an identical prompt + model + sampling params, you can serve all N from a single upstream call by having N–1 of them subscribe to the first one's output stream.
Where it pays off:
- Agent loops that retry the same prompt with the same seed.
- RAG fan-outs where dozens of users hit the same top-1 retrieved document.
- Cache stampedes after a deployment when a hot prompt suddenly has no cached completion.
- Health checks that probe with a canary prompt.
True batching (combining different prompts into a single forward pass) is something vLLM does internally via continuous batching — we don't need to redo it at the gateway. What the gateway uniquely can do is dedupe identical work before it ever reaches the model.
If the prompt or any sampling parameter (temperature, seed, top_p, max_tokens) differs between requests, they MUST NOT share a response. A request that asks for a different max_tokens than the in-flight call will get the wrong number of tokens. Our cache key must include every parameter that affects output.
Choosing the cache key
The simplest correct key: hash the entire parsed request body. If two requests parse to identical JSON, they're truly identical. If they differ in any field value, they get different keys, so we conservatively call the upstream twice. That's fine: we want false negatives, not false positives.
Add to infergw/Cargo.toml:
sha2 = "0.10"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "time", "sync"] } # already there; sync is required
(The sync feature on tokio is already included from Chapter 01 — listing here only as a reminder.)
The singleflight design
The core data structure is a map from request-hash → tokio::sync::broadcast::Sender<Bytes>. The first request to arrive with a given hash becomes the leader: it makes the upstream call and broadcasts each chunk. Followers find the broadcast in the map, subscribe(), and receive every subsequent chunk.
client A ──┐
├─ same hash ──▶ [in-flight map] ─── 1 upstream call ──▶ vLLM
client B ──┘ │ │
leader makes leader pushes
the call, chunks to
followers broadcast
subscribe channel
Three details matter:
- broadcast::channel — followers must receive every chunk, in order, and start from where the leader is now. Broadcast channels have a buffer; if a slow follower lags more than buffer-size chunks, it gets a
RecvError::Lagged. Set the buffer generously (we use 256). - Late arrivals miss early chunks. A request that arrives after the leader has already sent 5 chunks will only get chunks 6+. That's wrong. We solve it by having the leader also buffer emitted chunks in a Vec until the response completes, so late followers can replay.
- Clean up after completion. When the upstream stream ends, remove the entry from the map so new requests start a fresh upstream call.
Implementation
Create infergw/src/coalesce.rs:
use bytes::Bytes;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use tokio::sync::{broadcast, Mutex as AsyncMutex};
/// A frame coming from the upstream stream. We model end-of-stream explicitly
/// so subscribers know when the leader is done (vs. just slow).
#[derive(Clone, Debug)]
pub enum Frame {
Chunk(Bytes),
Done,
Error(String),
}
/// State shared by the leader and any followers for one in-flight request.
pub struct InFlight {
pub tx: broadcast::Sender<Frame>,
/// Frames the leader has already emitted, for late joiners to replay.
pub replay: Arc<AsyncMutex<Vec<Frame>>>,
}
#[derive(Default, Clone)]
pub struct Coalescer {
inner: Arc<Mutex<HashMap<String, Arc<InFlight>>>>,
}
pub enum JoinResult {
/// Caller is the leader and must drive the upstream call.
Leader(Arc<InFlight>),
/// Caller is a follower; subscribe and replay prior frames.
Follower(Arc<InFlight>),
}
impl Coalescer {
pub fn key_for(body: &serde_json::Value) -> String {
// Canonicalize by serializing through serde_json. Its default map type is
// sorted, so reordered keys and extra whitespace give the same key. Any
// difference in a value gives a different key, which is the safe direction.
let s = serde_json::to_string(body).unwrap_or_default();
let mut h = Sha256::new();
h.update(s.as_bytes());
format!("{:x}", h.finalize())
}
pub fn join(&self, key: &str) -> JoinResult {
let mut map = self.inner.lock().expect("coalesce map poisoned");
if let Some(existing) = map.get(key) {
JoinResult::Follower(existing.clone())
} else {
let (tx, _rx) = broadcast::channel::<Frame>(256);
let inflight = Arc::new(InFlight {
tx,
replay: Arc::new(AsyncMutex::new(Vec::new())),
});
map.insert(key.to_string(), inflight.clone());
JoinResult::Leader(inflight)
}
}
pub fn remove(&self, key: &str) {
let mut map = self.inner.lock().expect("coalesce map poisoned");
map.remove(key);
}
pub fn in_flight_count(&self) -> usize {
self.inner.lock().map(|m| m.len()).unwrap_or(0)
}
}
Now the proxy handler needs to branch on leader vs. follower. Update infergw/src/proxy.rs:
use crate::{
coalesce::{Coalescer, Frame, InFlight, JoinResult},
routes::AppState,
};
use axum::{
body::Body,
extract::State,
http::{header, StatusCode},
response::{IntoResponse, Response},
Json,
};
use bytes::Bytes;
use futures::{stream, StreamExt};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::broadcast::error::RecvError;
pub async fn chat_completions(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> Result<Response, ProxyError> {
let stream_requested = body.get("stream").and_then(|v| v.as_bool()).unwrap_or(false);
if !stream_requested {
// Non-streaming path stays simple — no coalescing for now. We could add
// it, but the win is small and the bookkeeping is fussier.
return passthrough_nonstreaming(&state, &body).await;
}
let key = Coalescer::key_for(&body);
match state.coalescer.join(&key) {
JoinResult::Leader(inflight) => {
tracing::info!(key = %&key[..12], "coalesce: leader");
spawn_leader(state.clone(), key.clone(), body, inflight.clone());
let body_stream = subscribe_stream(inflight, None);
Ok(sse_response(body_stream))
}
JoinResult::Follower(inflight) => {
tracing::info!(key = %&key[..12], "coalesce: follower");
let replay = inflight.replay.lock().await.clone();
let body_stream = subscribe_stream(inflight, Some(replay));
Ok(sse_response(body_stream))
}
}
}
fn sse_response<S>(s: S) -> Response
where
S: futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
{
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream")
.header(header::CACHE_CONTROL, "no-cache")
.header("x-accel-buffering", "no")
.body(Body::from_stream(s))
.expect("response builder")
}
fn subscribe_stream(
inflight: Arc<InFlight>,
replay: Option<Vec<Frame>>,
) -> impl futures::Stream<Item = Result<Bytes, std::io::Error>> {
let mut rx = inflight.tx.subscribe();
let replay_iter = replay.unwrap_or_default().into_iter();
let replay_stream = stream::iter(replay_iter).map(Ok);
let live = async_stream::stream! {
loop {
match rx.recv().await {
Ok(Frame::Chunk(_)) | Ok(Frame::Done) | Ok(Frame::Error(_)) => {}
Err(RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "follower lagged broadcast buffer");
continue;
}
Err(RecvError::Closed) => break,
}
// re-borrow on each iteration to make the borrow checker happy:
// we already matched above just to detect Lagged; redo the recv
// to actually yield the frame.
}
};
// Simpler shape using async_stream over a fresh subscriber:
let mut rx2 = inflight.tx.subscribe();
drop(live);
let live = async_stream::stream! {
loop {
match rx2.recv().await {
Ok(Frame::Chunk(b)) => yield Ok(b),
Ok(Frame::Done) => {
yield Ok(Bytes::from_static(b"data: [DONE]\n\n"));
break;
}
Ok(Frame::Error(e)) => {
yield Err(std::io::Error::other(e));
break;
}
Err(RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "follower lagged broadcast buffer");
// We can't recover the missed chunks; bail.
yield Err(std::io::Error::other("coalesce follower lagged"));
break;
}
Err(RecvError::Closed) => break,
}
}
};
let replay_bytes = replay_stream.filter_map(|f| async {
match f {
Ok(Frame::Chunk(b)) => Some(Ok(b)),
Ok(Frame::Done) => Some(Ok(Bytes::from_static(b"data: [DONE]\n\n"))),
Ok(Frame::Error(e)) => Some(Err(std::io::Error::other(e))),
Err(_) => None,
}
});
replay_bytes.chain(live)
}
fn spawn_leader(state: AppState, key: String, body: Value, inflight: Arc<InFlight>) {
tokio::spawn(async move {
let url = state.upstream.chat_completions_url();
let resp = match state.upstream.client.post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
let _ = inflight.tx.send(Frame::Error(format!("upstream send: {e}")));
state.coalescer.remove(&key);
return;
}
};
if !resp.status().is_success() {
let s = resp.status();
let text = resp.text().await.unwrap_or_default();
let _ = inflight.tx.send(Frame::Error(format!("upstream {s}: {text}")));
state.coalescer.remove(&key);
return;
}
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(b) => {
inflight.replay.lock().await.push(Frame::Chunk(b.clone()));
let _ = inflight.tx.send(Frame::Chunk(b));
}
Err(e) => {
let _ = inflight.tx.send(Frame::Error(format!("upstream chunk: {e}")));
state.coalescer.remove(&key);
return;
}
}
}
inflight.replay.lock().await.push(Frame::Done);
let _ = inflight.tx.send(Frame::Done);
state.coalescer.remove(&key);
});
}
async fn passthrough_nonstreaming(state: &AppState, body: &Value) -> Result<Response, ProxyError> {
let url = state.upstream.chat_completions_url();
let resp = state.upstream.client.post(&url).json(body).send().await
.map_err(ProxyError::Upstream)?;
let status = resp.status();
let bytes = resp.bytes().await.map_err(ProxyError::Upstream)?;
Ok(Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(bytes))
.expect("response builder"))
}
#[derive(thiserror::Error, Debug)]
pub enum ProxyError {
#[error("upstream transport error: {0}")]
Upstream(#[from] reqwest::Error),
}
impl IntoResponse for ProxyError {
fn into_response(self) -> Response {
let msg = self.to_string();
tracing::error!(%msg, "proxy error");
(StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error": msg}))).into_response()
}
}
Add async-stream to the deps:
async-stream = "0.3"
The leader forwards the raw bytes from the upstream — which already contain data: {...}\n\n framing. The [DONE] sentinel we synthesize at end-of-stream is a defensive belt-and-suspenders so followers always see a clean terminator, even if the upstream already sent one. Some clients are picky; doubling it is harmless.
Wire the coalescer into AppState
Update infergw/src/routes.rs to carry the coalescer:
use crate::{coalesce::Coalescer, upstream::Upstream};
use axum::{http::StatusCode, response::IntoResponse, routing::{get, post}, Router};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
#[derive(Clone)]
pub struct AppState {
pub ready: Arc<AtomicBool>,
pub upstream: Upstream,
pub coalescer: Coalescer,
}
impl AppState {
pub fn new(upstream: Upstream) -> Self {
Self {
ready: Arc::new(AtomicBool::new(true)),
upstream,
coalescer: Coalescer::default(),
}
}
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/readyz", get(readyz))
.route("/v1/chat/completions", post(crate::proxy::chat_completions))
.with_state(state)
}
async fn healthz() -> impl IntoResponse { (StatusCode::OK, "ok") }
async fn readyz(
axum::extract::State(state): axum::extract::State<AppState>,
) -> impl IntoResponse {
if state.ready.load(Ordering::Relaxed) {
(StatusCode::OK, "ready")
} else {
(StatusCode::SERVICE_UNAVAILABLE, "draining")
}
}
Register the module in main.rs:
mod coalesce;
Prove it works
Rebuild and run:
cargo run -p infergw
Open three terminals and fire three identical requests at the same time:
for i in 1 2 3; do
curl -sN -H 'content-type: application/json' \
-d '{"model":"llama-3-8b","messages":[{"role":"user","content":"identical"}],"stream":true}' \
http://127.0.0.1:8080/v1/chat/completions \
| head -c 200 &
done
wait
Look at the gateway logs. You should see:
INFO infergw::proxy: coalesce: leader key=a4f1c2...
INFO infergw::proxy: coalesce: follower key=a4f1c2...
INFO infergw::proxy: coalesce: follower key=a4f1c2...
And the mock-upstream's logs will show one incoming request, not three. That's the saving.
Now send three different prompts at the same time and verify the mock sees three calls:
for i in 1 2 3; do
curl -sN -H 'content-type: application/json' \
-d "{\"model\":\"llama-3-8b\",\"messages\":[{\"role\":\"user\",\"content\":\"prompt $i\"}],\"stream\":true}" \
http://127.0.0.1:8080/v1/chat/completions \
| head -c 200 &
done
wait
Three different prompts, three leaders, three upstream calls. The coalescer correctly refuses to dedupe non-identical work.
Stretch: load test the coalescer
Install oha with cargo install oha. Then:
oha -z 10s -c 50 -m POST \
-H 'content-type: application/json' \
-d '{"model":"llama-3-8b","messages":[{"role":"user","content":"identical"}],"stream":true}' \
http://127.0.0.1:8080/v1/chat/completions
50 concurrent identical requests for 10 seconds. Without coalescing, the mock would log about 600 incoming requests (50 workers, each finishing about 1.25 requests a second). With coalescing, you'll see roughly one every ~800ms (the duration of a complete mocked response), so ~12 total — about a 50x reduction in upstream load. We'll measure this properly in Chapter 07.
When NOT to coalesce
Three cases to be aware of:
- Any sampling temperature > 0. Two identical prompts with
temperature: 0.7are supposed to produce different outputs. Coalescing them collapses the distribution and breaks user expectations. The safe default is to disable coalescing whentemperature > 0unless a fixedseedis also set. A nice exercise: extendCoalescer::key_forto returnNonein that case and skip the dedupe. - Per-user personalization in the prompt. If the prompt is templated with user-specific context, identical-looking requests across users are rare anyway, and you probably don't want to leak one user's completion to another even if it would be technically identical. Use a per-tenant prefix in the key.
- Audit / billing implications. If each request is independently billed to a tenant, coalescing means two tenants share one upstream call but get charged twice (or you charge once and lose money). Track this explicitly — the coalescer can emit a counter so billing knows the call was a follower.
The "singleflight" name comes from Go's golang.org/x/sync/singleflight, and the pattern itself predates that by decades — it's how every well-engineered cache (memcached, Varnish, Cloudflare) handles thundering-herd cache fills. The gateway is just doing the same dance at the LLM layer.
Checkpoint
- Three concurrent identical requests produce ONE upstream call and three streams to clients.
- Three different prompts produce three upstream calls (no false coalescing).
- Followers see the same chunks as the leader, in order.
- The in-flight map cleans up when the leader's stream ends.
Next we make the upstream call itself more resilient.
→ Chapter 04: Circuit breaker, retry budgets, and request hedging
✓ Check yourself
Answer from memory before opening each one.
-
Fire three identical streaming requests at once, then three requests with different prompts. What should the gateway log and how many requests should the mock see each time? If the mock sees three for the identical set, what do you check?
Show answer
Identical: one
coalesce: leaderline, twocoalesce: followerlines, one request at the mock. Different: three leaders and three requests. If identical requests reach the mock three times, some field differs, since the key hashes the whole parsed body, or the requests did not overlap in time. See prove it works. -
Two tenants send the same prompt with
temperature: 0.7and overlap in time. What does coalescing do to them, and how do you fix the key?Show answer
Both receive the same sampled completion, which collapses the randomness each asked for, and one tenant sees text generated for the other. Skip coalescing when temperature is above zero and no seed is set, and put the tenant in the key for anything personalised or billed. See when not to coalesce.
-
A follower arrives after the leader has already sent five chunks. How does it get chunks one to five, and why does a broadcast channel alone not do it?
Show answer
A broadcast receiver sees only messages sent after it subscribes. The leader therefore also appends every frame to a replay buffer, and a late follower plays that back before switching to the live channel. See the singleflight design.
-
A follower on a bad mobile link falls more than 256 frames behind. What happens to it, and to the leader and the other followers?
Show answer
That follower's receive returns
Lagged, the code ends its stream with an error, and nobody else is affected. The channel is bounded, so a slow reader cannot slow the leader or hold unbounded memory in the channel. The replay buffer still grows with the response. See the implementation. -
In chapter 02 a client disconnect stopped the upstream call. With a single client and no followers here, does it still?
Show answer
No. The leader runs in its own spawned task and the client is only a subscriber, so dropping the client does not cancel it. That is right when followers are listening and wasteful when nobody is. The fix is to check the channel's receiver count and abort the task when it reaches zero. See the implementation.
-
A colleague wants to raise the hit rate by ignoring the
userfield when building the key. How do you decide?Show answer
Key order and whitespace are already normalised, because the key is built from parsed JSON. Ignoring a field is safe only if you can show it never changes the output, since a wrong merge hands one caller another caller's answer. Hashing everything can only miss a duplicate. Measure the follower ratio first and widen the key one proven-irrelevant field at a time. See choosing the cache key.
Takeaway
- Coalescing shares one upstream call among concurrent identical requests. The model server already batches different prompts, so the gateway only removes duplicate work.
- It is a correctness decision. The key must cover every field that affects the output, and hashing the whole parsed body can miss a duplicate but never merge two different requests.
- A broadcast channel delivers only what is sent after you subscribe, so the leader keeps a replay buffer for late joiners.
- A follower that lags past the channel buffer is cut off with an error, and the leader and the other followers carry on.
- Do not coalesce sampled requests without a seed, and scope the key by tenant when output is personalised or billed.
- Moving the call into a spawned task means a client disconnect no longer cancels the upstream call, so you need a receiver count to stop it.
Coalescing removes duplicate load but does nothing when the upstream fails or stalls. → Circuit breaker, retry budget and hedging
Further reading
- tokio::sync::broadcast — docs.rs, accessed 2026. Lagging, capacity and closed-channel behaviour that the follower code depends on.
- singleflight package — Go Packages, accessed 2026. The original pattern this chapter's name comes from, with its contract.
- Efficient Memory Management for Large Language Model Serving with PagedAttention — Kwon et al., 2023. Why the model server batches different prompts itself, and how its KV cache works.
- Orca: A Distributed Serving System for Transformer-Based Generative Models — Yu et al., OSDI 2022. The paper that introduced iteration-level, continuous batching.
- Automatic Prefix Caching — vLLM docs, accessed 2026. The cache that prefix-aware routing in chapter 08 tries to hit.