Reactive Programming with Project Reactor
Project Reactor for AI developers: Mono, Flux, back-pressure and WebFlux — and the one place they are genuinely the right tool, streaming LLM tokens to a browser.
On this page
Virtual threads have made reactive programming optional for a lot of what it used to be mandatory for. But there is one place in AI work where Reactor is still exactly the right tool: streaming. This tutorial focuses on that, and is honest about when to skip reactive entirely.
Key Takeaways
Mono= one async result (a model response).Flux= a stream over time (tokens).- The killer use case is streaming LLM tokens to a browser via Server-Sent Events.
- Back-pressure is why Flux beats a naive callback stream for pipelines.
- On Java 21, use reactive for streaming and pipelines; use plain blocking code on virtual threads for everything else.
Mono and Flux in one minute
// Mono: zero or one item, then done.
Mono<String> answer = Mono.fromCallable(() -> chatClient.prompt().user(q).call().content());
// Flux: zero to many items, over time.
Flux<String> tokens = chatClient.prompt().user(q).stream().content();Nothing runs until something subscribes. A Mono or Flux is a recipe for work, not the work
itself — a common early source of confusion.
Mono<String> lazy = Mono.fromCallable(this::expensiveCall); // nothing happened yet
lazy.subscribe(System.out::println); // now it runsThe use case that justifies Reactor: streaming responses
A ten-second wait for a full answer feels broken. Streaming the tokens as they generate feels
instant. This is Flux's home:
@RestController
public class StreamingController {
private final ChatClient chatClient;
public StreamingController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping(value = "/api/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam String q) {
return chatClient.prompt()
.user(q)
.stream()
.content()
.timeout(Duration.ofSeconds(60))
.onErrorResume(e -> Flux.just("Sorry, something went wrong."));
}
}Spring turns the Flux into Server-Sent Events. The browser reads them incrementally with
EventSource. See building AI-powered REST APIs
for the full client side.
Operators you will actually use
Reactor has hundreds of operators. For AI work you need a handful.
Flux.fromIterable(documents)
// Transform each item.
.map(doc -> doc.getText())
// Transform each item into a stream, and flatten — with bounded concurrency.
.flatMap(text -> embedAsync(text), 4) // at most 4 embeddings in flight
// Batch into groups, e.g. for bulk vector-store writes.
.buffer(100)
.flatMap(batch -> vectorStore.addAsync(batch))
// Handle errors without killing the whole stream.
.onErrorContinue((err, item) -> log.warn("skipped {}", item, err))
.subscribe();Back-pressure, concretely
Back-pressure is a slow consumer telling a fast producer to wait. It matters in two AI scenarios:
- Streaming to a slow client. The model produces tokens faster than a mobile connection drains them. Reactor buffers and signals demand so you do not run out of memory.
- Ingestion outrunning embedding. You can read documents from disk far faster than you can
embed them via an API. Bounded
flatMapthrottles the fast stage to the slow one.
// Explicit strategy when a consumer cannot keep up.
someFastFlux
.onBackpressureBuffer(1000) // buffer up to 1000, then apply the overflow policy
.subscribe(slowConsumer);Combining streams
// Merge two token streams (e.g. two models) as they arrive.
Flux<String> merged = Flux.merge(modelAStream, modelBStream);
// Zip: pair items positionally (question with its retrieved context).
Flux<Prompt> prompts = Flux.zip(questions, contexts, (q, c) -> buildPrompt(q, c));Bridging blocking and reactive
Most of your code is blocking. When you must call a blocking API inside a reactive pipeline, move it off the event-loop threads:
Mono.fromCallable(() -> blockingDatabaseCall())
.subscribeOn(Schedulers.boundedElastic()) // never block the event loop
.subscribe();When to skip reactive entirely
Be honest about this. Reactive code is harder to read, harder to debug (stack traces are famously unhelpful), and easy to get subtly wrong. On Java 21, prefer plain blocking code on virtual threads unless you specifically need:
- Streaming with back-pressure — token streams, SSE, WebSockets.
- Event pipelines — Kafka consumers with flow control.
- Composition of many async stages with fine-grained concurrency control.
For "call an LLM and wait for the answer", a blocking call on a virtual thread is simpler and just as scalable. See Java 17 to 21 features.
Next
- Building AI-powered REST APIs — streaming endpoints in depth
- The Spring AI ChatClient API —
stream()in context
Frequently Asked Questions
Do I still need reactive programming now that Java 21 has virtual threads?
What is the difference between Mono and Flux?
How do I stream an LLM response in Spring?
What is back-pressure?
Related tutorials
- Functional Programming in Java for AI PipelinesFunctional Java refreshed for AI work: streams for document pipelines, Optional for safe metadata access, and CompletableFuture for concurrent model calls — with practical examples.
- Microservices Architecture Deep DiveMicroservices patterns that matter for AI systems: API gateway, circuit breakers around model calls, the saga pattern for agent workflows, and where an AI service fits in the topology.
- Java 17 to 21 — What's New for AI DevelopersThe Java 17-to-21 features that matter most for AI work: records, sealed classes, pattern matching, text blocks and virtual threads — each shown with a concrete AI use case.
- Containerization with Docker & KubernetesContainerize and deploy a Spring Boot AI application: a production Dockerfile with layered JARs, Kubernetes deployment with secrets for API keys, health probes and resource limits.