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 Uni/Multi pipeline that does not explicitly hop elsewhere. Each event loop thread interleaves many concurrent requests by never waiting on any single one.

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, @Blocking-annotated code, and @RunOnVirtualThread code (covered on the dedicated virtual threads page) all run here (or on a virtual thread instead of a pooled worker thread, for the last case).

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:

Diagram of the Vert.x event loop and worker thread pool underneath Quarkus: an incoming HTTP request arrives at a small fixed pool of event-loop threads sized to the number of CPU cores; non-blocking work — Quarkus REST and reactive routes returning Uni/Multi

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

Uni<T>

A single asynchronous result: either one item of type T, or a failure. Equivalent in shape to a CompletionStage<T> or a single-element Mono<T> (see the Reactor comparison below).

Multi<T>

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 Publisher<T> or a Flux<T>.

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:

flowchart LR A["Uni.createFrom()\nitem(bookId)"] --> B["onItem().transformToUni()\nfindBookById(bookId)"] B -->|success| C["onItem().transform()\nmap to BookDto"] C --> D["onItem().call()\nrecord audit (side effect)"] D --> E([Subscriber receives BookDto]) B -->|failure: BookNotFoundException| F["onFailure(BookNotFoundException.class)\n.recoverWithUni()"] F --> G["fetchFromBackupCatalog(bookId)"] G -->|success| C G -->|failure| H["onFailure().retry()\n.withBackOff(...).atMost(3)"] H -->|exhausted| I([Subscriber receives failure]) B -->|other failure| H

@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=true at 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.