Designing Asynchronous Pipelines with Quarkus Reactive Messaging
Learn how to implement non-blocking asynchronous pipelines in Quarkus using SmallRye Reactive Messaging and Mutiny, while avoiding common event loop pitfalls.
08 Nov 2025, 20:20 UTC

The Challenge: Avoiding Event Loop Blockage in Event-Driven Systems
When building event-driven architectures in Quarkus, the primary risk is the "frozen event loop." Because Quarkus uses a non-blocking I/O model, a single synchronous call—such as a legacy database query or a heavy computation—inside a message handler can stop the entire application from processing any other events. The goal is to build a pipeline that handles high-throughput streams without saturating memory or locking the CPU.
The Minimal Reactive Design
The smallest suitable design for an asynchronous pipeline uses SmallRye Reactive Messaging. This framework decouples the business logic from the transport layer (like Apache Kafka or RabbitMQ) using channels. Instead of writing broker-specific code, you define @Incoming and @Outgoing annotations to create a data flow.
To keep the system responsive, the design must rely on Mutiny, the reactive programming library used by Quarkus. Mutiny provides Uni (for a single asynchronous result) and Multi (for a stream of results), ensuring that threads are released while waiting for I/O operations to complete.
Trust and Data Boundaries
Data boundaries occur at the Connector level. The connector is the bridge between the external broker and the internal Java application. Trust is established here through deserialization:
- External Boundary: The broker delivers a raw byte array. The connector uses a configured deserializer (e.g., JSON or Avro) to convert this into a Java POJO.
- Internal Boundary: Once the message enters the
@Incomingmethod, it is treated as a typed object. Validation should occur immediately upon entry to prevent malformed data from propagating through the reactive pipeline.
Implementation Example: Non-Blocking Processing
Below is a configuration for a pipeline that consumes a "orders" topic and produces to a "notifications" topic. This example assumes Quarkus 3.x and the SmallRye Kafka extension.
// application.properties
mp.messaging.incoming.orders.connector=smallrye-kafka
mp.messaging.incoming.orders.topic=orders-topic
mp.messaging.incoming.orders.auto.offset.reset=earliest
mp.messaging.outgoing.notifications.connector=smallrye-kafka
mp.messaging.outgoing.notifications.topic=notifications-topic
import org.eclipse.microprofile.reactive.messaging.*;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.Duration;
@ApplicationScoped
public class OrderProcessor {
@Incoming("orders")
@Outgoing("notifications")
public Uni<String> processOrder(Order order) {
// Use Mutiny to handle the process asynchronously
return Uni.createFrom().item(order)
.onItem().transform(o -> "Processed order: " + o.getId())
.onFailure().recoverWithItem("Error processing order");
}
// Example of handling a blocking call safely
@Incoming("legacy-data")
@Blocking
public void handleLegacy(String data) {
// @Blocking tells Quarkus to run this on a worker thread,
// preventing the event loop from freezing.
legacySystemCall(data);
}
}
Operational Checks and Diagnostics
To verify the health of the messaging pipeline, use the SmallRye Health extension. This provides liveness and readiness probes that can be monitored via /q/health.
Diagnostic Decision Matrix:
| Symptom | Likely Cause | Verification Step |
|---|---|---|
| High CPU, no messages processed | Event loop blockage | Check for missing @Blocking on synchronous calls. |
| Memory leak / OOM | Unbounded buffering | Check mp.messaging.incoming.[channel].max-uncommitted-messages. |
| Duplicate messages | Ack strategy mismatch | Verify if acknowledgment-strategy is set to manual vs automatic. |
Failure Modes and Design Shifts
The current design assumes a steady flow of data. However, certain conditions require a change in architecture:
- Consumer Saturation: If the consumer cannot keep up with the producer, the Reactive Streams back-pressure mechanism will signal the broker to slow down. If the broker does not support back-pressure, you must implement a Dead Letter Queue (DLQ) to offload failed or timed-out messages.
- Stateful Processing: If the pipeline requires state (e.g., aggregating orders over 10 minutes), a simple
@Incomingmethod is insufficient. You would need to shift to Kafka Streams or a stateful windowing operation using Mutiny'sgroup().into(). - Strict Ordering: If message order is critical, you must ensure that the partition key is consistent and avoid using
@Blockingwith multiple worker threads, as this can lead to out-of-order processing.
Verification and Rollback
To verify the implementation, deploy the application and use a Kafka producer tool to send a burst of messages. Monitor the logs for io.smallrye.reactive.messaging to ensure messages are acknowledged.
Rollback: Since this configuration primarily changes application.properties and Java annotations, rollback involves reverting the properties file to the previous version and redeploying the last stable build. No database migrations are typically required for messaging changes unless a schema registry (like Avro) was updated.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.