Reactive Programming and Mutiny
|
This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against. Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change. This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Every Quarkus application is built on Vert.x and reactive foundations, whether its own code is written in an
imperative, blocking style or composed entirely as Uni/Multi pipelines. This page covers that underlying
model and Mutiny, the reactive programming library Quarkus uses throughout its own reactive extensions — reactive data access, REST,
reactive routes, gRPC,
and messaging all return or consume Uni/Multi and build on the concepts
introduced here rather than re-explaining them.
Why Quarkus is reactive underneath, even for imperative code
Quarkus’s HTTP layer, its reactive SQL/NoSQL clients, its messaging connectors, and its core I/O all sit on top of
Eclipse Vert.x, a toolkit built around a small number of event loop threads that never block.
This is true regardless of whether a given endpoint is written as a plain, blocking @GET resource method or as a
Uni-returning reactive one: Quarkus REST always receives the request on an event loop first, and only then
decides — from the method’s return type and any @Blocking/@NonBlocking/@RunOnVirtualThread annotation — whether to keep processing it there or hand it off to a different thread pool. Imperative code is not "non-reactive
Quarkus"; it is reactive Quarkus with an automatic hand-off to a worker thread before the blocking code runs. See
the Quarkus Reactive Architecture guide for the full
rationale behind this design.
Event loop threads vs. worker threads
| Thread pool | Role |
|---|---|
Event loop threads |
A small, fixed pool (by default, sized to the number of CPU cores) that Vert.x uses to drive all non-blocking
I/O — accepting connections, reading/writing sockets, driving reactive SQL/NoSQL client calls, and running
every stage of a |
Worker threads |
A larger, bounded pool intended for work that genuinely blocks — JDBC calls, synchronous HTTP clients,
CPU-bound computation, or any third-party library with no non-blocking API. A plain-return-type Quarkus REST
resource method, |
The cardinal rule: never block an event loop
Because there are only a handful of event loop threads and every one of them is shared across many concurrent requests, a single blocking call on an event loop thread stalls every other request currently assigned to that same thread — not just the request that made the blocking call. This is the one rule every other topic on this page (and on Reactive Data Access, Quarkus REST, and Messaging with SmallRye Reactive Messaging) exists to help a developer avoid breaking:
package com.example.book;
import jakarta.enterprise.context.ApplicationScoped;
import io.smallrye.mutiny.Uni;
@ApplicationScoped
public class BrokenEventLoopExample {
// WRONG: this Uni-returning method stays on the event loop, but Thread.sleep()
// blocks that event loop thread -- and every other request sharing it -- for 2 seconds.
public Uni<String> brokenDelay() {
return Uni.createFrom().item(() -> {
try {
Thread.sleep(2000); // never do this in a Uni pipeline
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "done";
});
}
}
Vert.x’s BlockedThreadChecker watches every event loop thread and logs a WARN with a full stack trace once a
thread has been occupied past a configurable threshold (quarkus.vertx.max-event-loop-execute-time, covered in
depth on the reactive data access page) — the practical way to
catch a violation like the one above before it reaches production.
The diagram below shows the event loop / worker thread split this rule protects:
Mutiny: Uni and Multi
SmallRye Mutiny is the reactive programming library Quarkus builds on throughout its reactive extensions. It exposes exactly two types:
| Type | Represents |
|---|---|
|
A single asynchronous result: either one item of type |
|
A stream of zero, one, or many asynchronous items, terminated by either a completion signal or a failure.
Equivalent in shape to a Reactive Streams |
Event-driven API design and subscription semantics
Mutiny’s defining design choice is that nothing runs until something subscribes. Building a Uni/Multi
pipeline only describes a recipe of asynchronous steps — it does not start any work:
package com.example.book;
import io.smallrye.mutiny.Uni;
public class SubscriptionExample {
public static void main(String[] args) {
Uni<String> pipeline = Uni.createFrom().item("hello")
.onItem().transform(String::toUpperCase);
// Nothing has run yet -- pipeline is just a description.
// Only subscribing actually triggers execution:
pipeline.subscribe().with(
item -> System.out.println("Item: " + item),
failure -> System.err.println("Failure: " + failure));
}
}
Inside CDI beans, a Uni/Multi returned from a method is almost always handed to a framework caller (Quarkus
REST, a messaging connector, a reactive SQL client caller) that subscribes on the application’s behalf — explicit .subscribe() calls are mostly reserved for tests and low-level bootstrapping code, since subscribing
twice re-runs the entire pipeline from scratch (each subscription is an independent execution) rather than
sharing one in-flight result.
Core operator families
Mutiny’s operators are grouped by lifecycle event — what happens onItem(), onFailure(), onSubscribe(),
onCancellation(), or onCompletion() (for Multi) — rather than exposed as flat top-level methods. The
families used most often:
Transform
Uni<Integer> length = Uni.createFrom().item("hello")
.onItem().transform(String::length); // Uni<Integer> emitting 5
Uni<String> transformed = Uni.createFrom().item("hello")
.onItem().transformToUni(s -> Uni.createFrom().item(s + " world")); // flatMap equivalent
Chain
Uni<Book> chained = findBookById(1L)
.chain(book -> enrichWithReviews(book)); // chain() is an alias for transformToUni() that reads better
// when the next stage doesn't need the current item's type
Combine
Uni<BookWithAuthor> combined = Uni.combine().all()
.unis(findBookById(1L), findAuthorById(42L))
.asTuple()
.onItem().transform(tuple -> new BookWithAuthor(tuple.getItem1(), tuple.getItem2()));
Merge and concat
Multi<Book> merged = Multi.createBy().merging()
.streams(catalogA.streamBooks(), catalogB.streamBooks()); // interleaved, as items arrive
Multi<Book> concatenated = Multi.createBy().concatenating()
.streams(catalogA.streamBooks(), catalogB.streamBooks()); // catalogA fully drained before catalogB starts
Failure handling and recovery
Uni<Book> recovered = findBookById(1L)
.onFailure(BookNotFoundException.class).recoverWithItem(new Book(1L, "Unknown", "Unknown"))
.onFailure().recoverWithUni(failure -> fetchFromBackupCatalog(1L));
Retry
import java.time.Duration;
Uni<Book> retried = findBookById(1L)
.onFailure(TransientDatabaseException.class).retry()
.withBackOff(Duration.ofMillis(100), Duration.ofSeconds(2))
.atMost(3);
Timeout
import java.time.Duration;
Uni<Book> withTimeout = findBookById(1L)
.ifNoItem().after(Duration.ofSeconds(2)).failWith(new TimeoutException("book lookup timed out"));
Grouping
import java.time.Duration;
Multi<java.util.List<Book>> grouped = streamAllBooks()
.group().intoLists().of(50); // fixed-size batches
Multi<java.util.List<Book>> windowed = streamAllBooks()
.group().intoLists().every(Duration.ofSeconds(1)); // time-based windows
Back-pressure strategies
Multi is back-pressure aware by default (a subscriber requests items, and the source respects that request
rate); when a source cannot honor requested demand — a fast producer feeding a slow consumer — an explicit
overflow strategy is required:
Multi<Event> dropped = eventSource()
.onOverflow().drop(); // silently discard items produced faster than the subscriber can consume
Multi<Event> buffered = eventSource()
.onOverflow().buffer(1000); // buffer up to 1000 items before applying a strategy for further overflow
Multi<Event> latestOnly = eventSource()
.onOverflow().dropPreviousItems(); // keep only the most recent unconsumed item
The diagram below shows a representative Uni pipeline with a failure-recovery branch, combining several of the
operator families above:
@Blocking/@NonBlocking, bridging, testing, and debugging
@Blocking and @NonBlocking
io.smallrye.common.annotation.Blocking and io.smallrye.common.annotation.NonBlocking force a method’s
dispatch explicitly, overriding whatever the framework would infer from its return type — the full decision
table for Quarkus REST resource methods is covered on the REST services
page; the same two annotations apply identically to @Incoming/@Outgoing
messaging methods and reactive routes.
Bridging to and from CompletionStage, Reactive Streams, and RxJava
A codebase migrating gradually, or depending on a library that only exposes one of the JDK/Reactive Streams/RxJava shapes, converts at the boundary rather than rewriting the whole call chain at once:
package com.example.book;
import java.util.concurrent.CompletionStage;
import org.reactivestreams.Publisher;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.converters.uni.UniRxConverters;
import io.reactivex.rxjava3.core.Single;
public class MutinyBridgingExample {
// Uni -> CompletionStage and back
Uni<Book> uni = fetchBook();
CompletionStage<Book> stage = uni.subscribeAsCompletionStage();
Uni<Book> backToUni = Uni.createFrom().completionStage(stage);
// Multi -> Reactive Streams Publisher and back
Multi<Book> multi = streamBooks();
Publisher<Book> publisher = multi.convert().toPublisher();
Multi<Book> backToMulti = Multi.createFrom().publisher(publisher);
// Uni -> RxJava Single and back (requires the smallrye-mutiny-rxjava artifact)
Single<Book> single = uni.convert().with(UniRxConverters.toSingle());
Uni<Book> fromSingle = Uni.createFrom().converter(io.smallrye.mutiny.converters.uni.UniRxConverters.fromSingle(), single);
private Uni<Book> fetchBook() {
return Uni.createFrom().item(new Book(1L, "Effective Java", "Joshua Bloch"));
}
private Multi<Book> streamBooks() {
return Multi.createFrom().item(new Book(1L, "Effective Java", "Joshua Bloch"));
}
}
Testing with UniAssertSubscriber and AssertSubscriber
io.smallrye.mutiny.helpers.test.UniAssertSubscriber and AssertSubscriber subscribe to a Uni/Multi and
expose assertion methods over the result, without needing a real CDI container or blocking .await() calls
scattered through test code:
package com.example.book;
import org.junit.jupiter.api.Test;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.helpers.test.UniAssertSubscriber;
import io.smallrye.mutiny.helpers.test.AssertSubscriber;
class MutinyPipelineTest {
@Test
void uniEmitsExpectedItem() {
Uni<String> uni = Uni.createFrom().item("hello").onItem().transform(String::toUpperCase);
UniAssertSubscriber<String> subscriber = uni.subscribe()
.withSubscriber(UniAssertSubscriber.create());
subscriber.awaitItem().assertItem("HELLO");
}
@Test
void multiEmitsExpectedSequenceThenCompletes() {
Multi<Integer> multi = Multi.createFrom().items(1, 2, 3);
AssertSubscriber<Integer> subscriber = multi.subscribe()
.withSubscriber(AssertSubscriber.create(10)); // requests up to 10 items
subscriber.awaitCompletion().assertItems(1, 2, 3);
}
@Test
void uniPropagatesFailure() {
Uni<String> failing = Uni.createFrom().failure(new IllegalStateException("boom"));
UniAssertSubscriber<String> subscriber = failing.subscribe()
.withSubscriber(UniAssertSubscriber.create());
subscriber.awaitFailure().assertFailedWith(IllegalStateException.class, "boom");
}
}
The debugging story
A stack trace captured inside a Uni/Multi callback often points into Mutiny’s own internal scheduling code
rather than the application code that scheduled it, since the callback runs later, on a different thread, than
where it was declared. Mutiny mitigates this with two mechanisms:
-
Operator names in stack traces — when trace propagation is enabled (
-Dmutiny.trace.operator-stack=trueat build/run time, mainly intended for local debugging rather than production, since it adds overhead per operator), a failure’s stack trace additionally shows the chain of Mutiny operators the failing pipeline passed through, not just the raw thread it failed on. -
.log()— inserting.log("stage-name")at any point in a pipeline logs every signal (item, failure, completion, cancellation) passing through that point, which is usually a faster way to localize which stage produced an unexpected value than working purely from a stack trace.
Uni<Book> traced = findBookById(1L)
.log("after-find")
.onItem().transform(this::toDto)
.log("after-map");
Context propagation and duplicated context
Why ThreadLocal does not work
A ThreadLocal set at the start of a request assumes the same thread runs the whole request. That assumption
breaks down for both reactive pipelines and virtual threads: a Uni/Multi pipeline can resume a later stage on
a different event loop thread than the one that started it (after a timer, an I/O callback, or an explicit
.emitOn()/.runSubscriptionOn() hop), so a value stashed in a ThreadLocal on thread A is simply invisible
once execution resumes on thread B — silently returning null/defaults rather than throwing, which makes the
bug easy to miss until it surfaces as a subtly wrong response under load.
Duplicated context
Quarkus and Vert.x solve this with a duplicated context: for each request, Vert.x creates a lightweight copy of
its context object that travels with the logical flow of execution across thread hops, rather than being pinned
to one physical thread. Request-scoped CDI beans, the current SecurityIdentity, and MDC-style logging context
all propagate this way automatically — code that injects a request-scoped bean or reads
io.quarkus.arc.Arc.container().requestContext() continues to see the correct instance regardless of which
event loop thread is currently executing a given pipeline stage.
package com.example.book;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.RequestScoped;
import io.smallrye.mutiny.Uni;
@RequestScoped
public class RequestContext {
private String correlationId;
public String getCorrelationId() {
return correlationId;
}
public void setCorrelationId(String correlationId) {
this.correlationId = correlationId;
}
}
@ApplicationScoped
public class CorrelationAwareService {
private final RequestContext requestContext;
public CorrelationAwareService(RequestContext requestContext) {
this.requestContext = requestContext;
}
public Uni<Book> findWithCorrelation(Long id) {
// requestContext resolves correctly here even if this stage
// runs on a different event loop thread than the one that
// received the original HTTP request -- the duplicated
// context, not a ThreadLocal, carries it across the hop.
return findBookById(id)
.onItem().invoke(book ->
LOG.infof("[%s] found %s", requestContext.getCorrelationId(), book));
}
private Uni<Book> findBookById(Long id) {
return Uni.createFrom().item(new Book(id, "Effective Java", "Joshua Bloch"));
}
private static final org.jboss.logging.Logger LOG = org.jboss.logging.Logger.getLogger(CorrelationAwareService.class);
}
For code that manages its own state across asynchronous boundaries rather than relying on CDI scopes, SmallRye
Context Propagation’s ThreadContext/ManagedExecutor (io.smallrye.context) is the supported, portable
replacement for a ThreadLocal — it explicitly captures and restores context around an asynchronous hop instead
of assuming thread affinity.
Comparison with Reactor’s context
Spring’s reactive stack faces the identical problem and solves it with an analogous, but API-visible, mechanism — Reactor’s Context, which is explicitly threaded through the reactive chain rather than propagated transparently
by the runtime. See Reactive Programming with Reactor for that
side of the comparison; the underlying problem (why ThreadLocal fails once execution can resume on a different
thread) is the same one described above.