Stop Burning Claude Tokens: Propagate SSE Disconnects in Spring Boot
If a user closes their browser tab mid-stream while receiving a long-form response from Claude 3.5 Sonnet, your Spring Boot backend keeps streaming from Anthropic in the background. You are literally burning enterprise dollars rendering invisible tokens to a dead TCP socket.
Why Most Developers Get This Wrong
-
Assuming Reactive Magic Just Works: Wrapping your API call in a
Flux<String>doesn't automagically abort the underlying HTTP/2 call to Anthropic when the client drops the SSE connection. -
Ignoring Virtual Thread Cancellation: Java Virtual Threads unblock I/O gracefully, but Spring's
SseEmitterwon't trigger thread interrupts upstream unless explicitly instructed to handle socket hangups. - Confusing Read Timeouts with Aborts: Setting standard socket timeouts won't kill an active stream—as long as Claude keeps generating tokens, your backend thinks the stream is healthy.
The Right Way
Bind your client transport lifecycle directly to the upstream Anthropic API streaming request lifecycle.
- Register lifecycle hooks on
SseEmitter.onCompletion()andSseEmitter.onTimeout()to trigger abort signals. - Bind the client
Disposabledirectly to these emitter callbacks to instantly cancel the underlying WebClient subscription. - Configure Spring AI's
ChatClientwith a custom transport layer that cancels HTTP body requests downstream upon subscriber teardown. - Track zombie stream counts in Micrometer to audit how many tokens you save daily.
I built javalld.com while prepping for senior roles — complete LLD problems with execution traces, not just theory.
Show Me The Code
@GetMapping(value = "/claude-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamClaudeResponse(@RequestParam String prompt) {
SseEmitter emitter = new SseEmitter(60_000L);
Disposable subscription = chatClient.prompt(prompt)
.stream()
.content()
.subscribe(
token -> trySend(emitter, token),
error -> emitter.completeWithError(error),
() -> emitter.complete()
);
// Explicitly cancel upstream Claude API call on client SSE disconnect
emitter.onCompletion(subscription::dispose);
emitter.onTimeout(subscription::dispose);
return emitter;
}
Key Takeaways
- Unbounded streams bleed money: Never stream LLM outputs without an explicit teardown trigger mapped to client disconnect events.
-
Always dispose reactive handles: Tie
SseEmitterlifecycle callbacks (onCompletion/onTimeout) directly to your upstreamDisposablehandles. -
Audit zombie stream metrics: Treat unhandled
ClientAbortExceptioninstances as high-priority bugs before your cloud bill explodes.
Top comments (0)