DEV Community

Machine coding Master
Machine coding Master

Posted on

Stop Burning Claude Tokens: Propagate SSE Disconnects in Spring Boot

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 SseEmitter won'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() and SseEmitter.onTimeout() to trigger abort signals.
  • Bind the client Disposable directly to these emitter callbacks to instantly cancel the underlying WebClient subscription.
  • Configure Spring AI's ChatClient with 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;
}
Enter fullscreen mode Exit fullscreen mode

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 SseEmitter lifecycle callbacks (onCompletion/onTimeout) directly to your upstream Disposable handles.
  • Audit zombie stream metrics: Treat unhandled ClientAbortException instances as high-priority bugs before your cloud bill explodes.

Top comments (0)