{"slug": "coalescing-the-stream", "title": "Coalescing the Stream", "summary": "A developer fixed a streaming stall in Claude Code by writing a middleware for the claude-code-router proxy that merges SSE deltas into larger chunks. The middleware combines consecutive content_block_delta events only when they share the same block index and delta type, flushing on any other event to preserve protocol semantics. The fix, controlled by environment variables like CCR_SSE_COALESCE_MS, also handles content-length and compression issues.", "body_md": "Claude Code, on my box, doesn't talk to a model provider directly. It goes through claude-code-router (ccr) — a self-hosted proxy that lets the same CLI point at the official Anthropic API or at third-party providers — and I watch the whole thing through the VS Code extension over Remote-SSH. The previous piece, *Two Clocks, Neither Lying*, worked out why that path once let an approval prompt sit for an hour: one streaming event per token, an extension that couldn't burn them down fast enough, and the CLI's own control messages queued in behind six thousand deltas. This piece is about the fix, which turned out to be two problems in sequence — writing a middleware that merges those events back into chunks, and then getting the middleware to actually run. The first took an evening. The second took five attempts.\n\nThe fault had three addresses. The provider's granularity — theirs, not mine to change. The extension — closed source, not mine to patch. And between them, the router: my container, my compose file, my rules. The official Anthropic API already streams in merged chunks, and the extension handles that fine — so the goal was never to make the extension faster. It was to make my stream look like the one the extension already digests.\n\nWhich sounds like a small thing: a transformer that re-chunks deltas on the way out. But the constraint that governs every other decision is nastier than \"merge events.\" The middleware must merge without changing what the stream means. A merger that corrupts semantics is worse than the stall — the stall costs an hour, a lying stream costs the session.\n\nThe rule, in one sentence: merge consecutive `content_block_delta`\n\nevents only when they carry string payloads for the same block index and the same delta type — text into text, thinking into thinking, partial JSON into partial JSON — and flush the moment any other event appears.\n\nEvery clause exists because its absence corrupts something. *Same index*: one assistant message interleaves blocks — text, then a tool call, then more text — and block boundaries are how the client tells them apart; merge across an index change and you fuse two blocks into one that never existed. *Any other event flushes*: `content_block_start`\n\n, `content_block_stop`\n\n, `message_delta`\n\n, `message_stop`\n\nall carry protocol meaning, so the window empties and they pass through untouched, in order. The middleware deliberately understands nothing about the conversation — it only combines things that are provably interchangeable and defers to everything else. That ignorance is the safety property.\n\nThe window is the one knob: longer windows merge more and delay more. `CCR_SSE_COALESCE_MS`\n\nsets it globally, and zero is the kill switch that disables the middleware outright. It later grew per-type overrides — `CCR_SSE_COALESCE_THINKING_MS`\n\n, `_TEXT_MS`\n\n, `_INPUT_JSON_MS`\n\n— because one number can't serve two populations: thinking deltas can afford to wait, and text is the thing a human watches appear. A per-type value of zero or less doesn't mean \"off for this type\"; it means fall back to the global — a zero-width window has no timer to flush it and would strand data until the stream ended. Keep-alive pings are dropped by default (`CCR_SSE_DROP_PINGS=0`\n\nrestores them) — not for the bytes, but because a ping landing mid-thinking-run would flush the window exactly where the stream is densest; merging that continues across keep-alives is worth more than the pings.\n\nMerging bodies invalidates two things the transport layer believes. Content-length: the merged body is no longer the length anyone declared, and a client honoring the stale header will truncate or hang — SSE travels chunked, so the header is simply removed. Compression: the middleware asks for `accept-encoding: identity`\n\non the way in, and if a response comes back compressed anyway, it bypasses itself — passes the stream through untouched rather than decompress, merge, and re-encode a body it can't vouch for. The rule underneath both: merge what you fully understand, decline what you don't.\n\nThe original bug was a slow reader backing up a pipe. A naive merger just relocates that disease one floor up: buffer eagerly, and the middleware becomes a balloon that grows until something else stalls. So the pressure passes through — when the downstream consumer signals stop, the queue pauses; when it signals go, delivery resumes. The middleware can smooth the stream's shape. It can't repeal its economics.\n\nDesign done, the remaining problem looked small: get the file into the router's process. Four configurations failed before one worked, and each failure was its own lesson.\n\nAttempt one patched `globalThis.fetch`\n\n— the famous door. Nothing. The router's gateway doesn't call fetch; it `require`\n\ns undici and dispatches through `getGlobalDispatcher()`\n\n. Patch the API actually in use, not the one with the brand recognition.\n\nAttempt two moved to the dispatcher layer — intercept undici's headers/data/completion handlers — and it worked, in exactly one process. The container runs more than one node process: a core server, and the gateway it spawns. Some provider traffic (DeepSeek's, it turned out) is fetched by the core server, which never loaded the patch — only the gateway had. Right layer, wrong process: a patch deployed into one process does nothing for its siblings, and when the target is \"the container,\" the unit of deployment is the process tree.\n\nAttempt three made loading universal: `NODE_OPTIONS`\n\nwith `--require`\n\n, so every node process in the container loads the module at birth. The module loaded. Nothing happened. `--require`\n\nloads a file; it doesn't call anything in it — my module exported an `install()`\n\nand waited politely for a caller who never came. Loading is not running. The fix was to make the module install itself at load time, idempotently, so that being loaded *is* being deployed.\n\nAttempt four targeted a file that already existed: the gateway's preload. Also a trap — the core server re-creates that file from an embedded copy via `writeFileSync`\n\non every startup, so an edit to it has a lifespan of one restart. Never patch the generated artifact. Bring your own file, and get it invited.\n\nThe fifth configuration is the one running now:\n\n```\nNODE_OPTIONS: \"--require /data/.claude-code-router/sse-coalesce.cjs\"\n```\n\nMy file, kept in the ops repo and bind-mounted read-only into the container at a path the core doesn't know about — which is precisely why nothing ever overwrites it — loaded into every node process, installing itself the moment it lands. The repo is the single authority; the container is just where it runs.\n\nThat placement carries one more trap, discovered when the middleware itself first needed changing: a bind mount's contents aren't part of what compose hashes, so editing the file leaves the running container untouched — and `--require`\n\nloads only at process birth anyway. Changed is not reloaded. Every edit to the middleware means `docker compose up -d --force-recreate`\n\n, or it isn't an edit at all.\n\nBefore touching real traffic, the middleware got nine unit tests — the merge itself, content-length removal, backpressure passthrough, compression bypass, the refusal to merge across block indexes, ping dropping among them. Live verification ran through DeepSeek, because Zhipu's five-hour quota ceiling (`[1308]`\n\n) interrupted the plan mid-check — an accident that doubled as proof the middleware doesn't care which provider it fronts. A 200-token response arrived as 85 events and left as 11, counted by the middleware's own stats log — which logs every merge and truncates itself at 256 KB, so the observability doesn't become its own incident.\n\nOne hygiene change rode along: the router's request log had been capturing full bodies — 156 MB of them — and capture went from `all`\n\nto `errors`\n\nthat night, with a vacuum bringing the file down to 12 MB. Which is why the granularity numbers in the previous piece are a record of one night, not a rerun.\n\nDeployed, the middleware killed the hour-level hang — and left a residue. The next afternoon my phone and the extension disagreed again, only smaller: the notification arrived on time, and the extension took another three minutes to show the question Claude was asking. The transcript settled which one was honest, same as before — the question landed in it the same second the notification went out. The three minutes were all display queue: the turn's last two responses had been through the merger and still arrived as 534 events, at an estimated 250 to 400 milliseconds of rendering each. That last number deserves a pause. It's the extension's per-event cost, obtained by division, and it's the ceiling everything else in this story pushes against.\n\nThe stats log also explained why the merger hadn't merged more. Across the day, the forty-millisecond window was buying only three-to-five-fold reduction: the provider emits a delta every 25 to 50 milliseconds, and a window that narrow catches one or two before it expires. The knob had been set before anyone knew the stream's tempo.\n\nSo the knob learned the tempo, per type: two hundred milliseconds globally, five hundred for thinking — ninety-nine percent of the traffic, where nobody is watching smoothness — and a hundred and twenty for text, where latency is the thing a human feels. On live traffic: 719 events in, 31 out. Then 337 in, 14 out. Twenty-three-fold and better, against a floor I'd set at fifteen. The ceiling itself is upstream and untouched — a renderer that costs a quarter-second per event on a long session — so the whole game on my side of the pipe is how many events there are to render. Fewer is the strategy. On long sessions, so is `/compact`\n\n. That renderer is filed upstream now, at [anthropics/claude-code#86854](https://github.com/anthropics/claude-code/issues/86854), where the per-event cost numbers above are recorded for whoever fixes it.\n\nThe middleware is sixteen kilobytes of code. Writing it was the short half of the job. The long half was learning where code has to sit before it counts as deployed — in the API actually in use, in every process that matters, invoked rather than merely loaded, in a file nothing regenerates, recreated by force whenever it changes. A fix doesn't exist when it's written. It exists when it's running.\n\nThe story wasn't over, though. The next morning, the same box nearly went down under a load average of 38.7 — a separate-looking incident that turned out to be this one's own aftermath, twenty hours downstream. That's [The Twenty-Hour Fuse](https://dev.to/jeromefromhk/TODO-twenty-hour-fuse).", "url": "https://wpnews.pro/news/coalescing-the-stream", "canonical_source": "https://dev.to/jeromefromhk/coalescing-the-stream-145h", "published_at": "2026-08-15 13:52:32+00:00", "updated_at": "2026-08-15 14:12:38.939011+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "artificial-intelligence"], "entities": ["Claude Code", "Anthropic", "claude-code-router", "VS Code"], "alternates": {"html": "https://wpnews.pro/news/coalescing-the-stream", "markdown": "https://wpnews.pro/news/coalescing-the-stream.md", "text": "https://wpnews.pro/news/coalescing-the-stream.txt", "jsonld": "https://wpnews.pro/news/coalescing-the-stream.jsonld"}}