Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate4 min readUpdated
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 runs

The 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:

StreamingController.java
@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:

  1. 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.
  2. Ingestion outrunning embedding. You can read documents from disk far faster than you can embed them via an API. Bounded flatMap throttles 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

Frequently Asked Questions

Do I still need reactive programming now that Java 21 has virtual threads?
Less often, but yes for specific cases. Virtual threads make blocking I/O cheap, which removes the main old reason for going reactive. Reactive still wins where you need streaming with back-pressure — a token stream from an LLM, a Kafka pipeline, server-sent events — because those are genuinely about flowing data over time, which is exactly what Flux models.
What is the difference between Mono and Flux?
Mono emits zero or one item, then completes — the reactive equivalent of a single async result, like one model response. Flux emits zero to many items over time — a stream, like the tokens of a streaming completion arriving one chunk at a time. Choose based on cardinality: one result is a Mono, a stream is a Flux.
How do I stream an LLM response in Spring?
Call chatClient.prompt().user(question).stream().content(), which returns a Flux of String chunks, and return it from a controller method producing text/event-stream. Spring maps the Flux to Server-Sent Events automatically, and the browser reads them with EventSource.
What is back-pressure?
A mechanism where a slow consumer can signal a fast producer to slow down, so the producer does not overwhelm it with more data than it can handle. In an AI streaming context it matters when the model produces tokens faster than a client can consume them, or when an ingestion pipeline reads documents faster than it can embed them.

Related tutorials