07-production-observability

Streaming Responses: Fixing the Spinner Problem

Stop making customers stare at a spinner for a 6-second reply. Stream tokens as they're generated with ChatClient.stream(), a Server-Sent Events endpoint, and a browser EventSource.

August 13, 2026
spring-aistreamingfluxsseserver-sent-events

Six Seconds of Nothing

Every ChatClient call across this whole roadmap has used .call() — wait for the entire response, then return it. That's fine for a triage endpoint returning one word. It's a genuinely bad experience for SupportChatService.reply(...) from this roadmap's second phase, drafting a multi-paragraph explanation: the customer sends a message and stares at a loading spinner for four to six seconds while the model generates the whole thing before a single character reaches them.

The fix isn't making the model faster — it's not waiting for it to finish before showing anything. Streaming sends tokens to the browser as the model generates them, the same way ChatGPT's UI visibly "types" a response instead of pasting it all at once. The total time to the last token doesn't change; the time to the first visible word drops from seconds to a couple hundred milliseconds, which is what "feels fast" actually means to a user.


1. Three Ways to Stream

.stream() replaces .call() on the same ChatClient chain — everything else about the call (advisors, tools, system prompt) stays identical:

java
Flux<String> tokens = chatClient.prompt()
    .user("Draft a reply explaining why order 4521's refund was denied.")
    .stream()
    .content();

Three terminal methods, each returning progressively more detail as a Flux:

java
Flux<String> content = chatClient.prompt().user(msg).stream().content();
// Just the text, token by token — what you want 90% of the time.
 
Flux<ChatResponse> responses = chatClient.prompt().user(msg).stream().chatResponse();
// Each chunk wrapped with metadata — token usage, finish reason, model info.
 
Flux<ChatClientResponse> clientResponses = chatClient.prompt().user(msg).stream().chatClientResponse();
// Also exposes advisor execution context — e.g. the documents QuestionAnswerAdvisor
// retrieved for this call, useful if the UI wants to show citations as they resolve.

Reach for .chatClientResponse() specifically when you're streaming a RAG-backed answer and want to show which documents it's grounded in alongside the streamed text — the retrieved-document metadata this roadmap's third phase used for evaluation is available here too, not just after the call completes.


2. Exposing It as Server-Sent Events

Flux<String> returned from a Spring MVC or WebFlux controller, with the right content type, is a Server-Sent Events stream — no separate SSE library needed:

java
@RestController
@RequestMapping("/api/support")
public class SupportStreamController {
 
    private final ChatClient chatClient;
 
    public SupportStreamController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }
 
    @PostMapping(value = "/{ticketId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> stream(@PathVariable String ticketId, @RequestBody String message) {
        return chatClient.prompt()
            .user(message)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, ticketId))
            .stream()
            .content();
    }
}

produces = MediaType.TEXT_EVENT_STREAM_VALUE is the entire integration point — Spring's reactive web stack takes care of framing each emitted String as an SSE data: event and keeping the HTTP connection open until the Flux completes.


3. Consuming It in the Browser

The native EventSource API is built for exactly this — no extra frontend dependency for a basic case:

javascript
const eventSource = new EventSource(`/api/support/${ticketId}/stream`);
let fullReply = "";
 
eventSource.onmessage = (event) => {
    fullReply += event.data;
    replyElement.textContent = fullReply; // append, don't replace
};
 
eventSource.onerror = () => {
    eventSource.close();
};
⚠️

EventSource only supports GET requests natively — the controller above uses @PostMapping because it needs a request body. In practice, either switch the endpoint to accept the message as a query parameter for GET, or use fetch() with a ReadableStream reader instead of EventSource when a POST body is a hard requirement. Both are common; pick based on whether your message payload comfortably fits in a query string.


4. Backpressure and Disconnection

A customer closing their laptop mid-response doesn't stop the model from generating tokens unless your code notices the connection is gone:

java
@PostMapping(value = "/{ticketId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@PathVariable String ticketId, @RequestBody String message) {
    return chatClient.prompt()
        .user(message)
        .stream()
        .content()
        .doOnCancel(() -> log.info("Client disconnected mid-stream for ticket {}", ticketId))
        .timeout(Duration.ofSeconds(30));
}

Flux cancellation propagates automatically when the client disconnects — Spring's reactive stack stops pulling tokens, and the underlying provider call is cancelled rather than continuing to burn tokens for a response nobody will see. .doOnCancel(...) is there for observability (log it, decrement an in-flight-requests gauge — this roadmap's next guide adds exactly that metric), and .timeout(...) protects against a provider that hangs without ever completing or erroring.


5. What Doesn't Compose With Streaming

Everything from this roadmap composes with .stream() except one thing:

🚨

.entity(SomeRecord.class) requires the complete response before it can parse anything — structured output and token-by-token streaming are fundamentally incompatible. A triage endpoint using TicketTriage (this roadmap's second guide) should stay on .call(); a customer-facing chat reply that's plain text is exactly where .stream() belongs. Route the two use cases to different ChatClient calls rather than trying to force one into the other's shape.

Tools, RAG advisors, and memory advisors all work identically under .stream() — the model still calls tools and retrieves documents before it starts generating the streamed reply; only the final text generation is what streams token by token.


What's Next

Streaming fixes what the customer experiences. The next guide covers what you need to see — instrumenting every one of these calls with Micrometer so token usage, latency, and error rates are visible before a support-ticket-shaped disaster is the first time you hear about a problem.

Frequently asked questions

Does streaming reduce the total token cost of a response?

No — cost is driven by total tokens generated, not by whether they're delivered all at once or incrementally. Streaming improves perceived latency (time to first token), not total cost; the cost-control guide later in this phase covers what actually reduces spend.

Can I show a typing indicator before the first token arrives?

Yes, and you should — there's still a real gap (typically a few hundred milliseconds to a couple seconds) between the request and the first streamed token while the model processes the prompt, tool calls resolve, or RAG retrieval runs. Show a lightweight typing indicator for that window, then switch to appending streamed text as event.data arrives.

What happens to advisor logic like ChatMemory when a stream is cancelled partway through?

This depends on the specific advisor's implementation, and it's worth verifying rather than assuming for anything you depend on — MessageChatMemoryAdvisor's default behavior is to persist the exchange after the call completes, so a client disconnecting before the model finishes generating may mean that turn is never saved to memory. If your application needs a saved partial record either way, handle it explicitly rather than relying on advisor defaults.

Is WebFlux required to use .stream(), or does it work in a plain Spring MVC (Servlet) app?

Flux<String> as a controller return type works in Spring MVC too, not just WebFlux — Spring MVC has supported reactive return types for exactly this kind of streaming response for years. You don't need to migrate an entire application to WebFlux just to add one streaming endpoint.