Spring Boot Async Messaging Patterns: Kafka, RabbitMQ & Beyond
Master async messaging in Spring Boot: request-reply with correlation IDs, fire-and-forget, pub-sub, Kafka vs RabbitMQ trade-offs, and backpressure handling..
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Fire-and-forget sends a message and moves on — no response expected, highest throughput
- Request-reply adds a correlation ID and a reply topic/queue so the sender can match the response
- Pub-sub decouples publishers from subscribers — multiple consumers receive the same event
- Kafka excels at ordered, high-throughput, replayable event streams; RabbitMQ excels at flexible routing and low-latency RPC
- Backpressure must be handled explicitly: bounded queues, consumer rate limiting, and dead-letter queues
Async messaging is like dropping a letter in a post box instead of making a phone call. Fire-and-forget is a flyer in the letterbox — you walk away immediately. Request-reply is a registered letter with a return address — you wait for the postman to bring an answer. Pub-sub is a newspaper — one publisher, many readers, each getting their own copy on their own schedule.
Synchronous HTTP calls are the instinctive choice when building microservices APIs. They are easy to reason about, easy to test, and return results immediately. But in production, synchronous calls become fragility vectors: a slow downstream service holds a thread, a timeout cascade can take down an entire call chain, and a service restart loses all in-flight requests. Async messaging inverts this dynamic — the caller deposits work into a broker and continues processing immediately, decoupled from the receiver's availability.
Spring Boot's messaging ecosystem is mature and rich. Spring Kafka wraps the Kafka client with @KafkaListener, @KafkaTemplate, and deep integration with Spring transactions and the application context lifecycle. Spring AMQP provides the same for RabbitMQ with additional routing primitives that Kafka's topic model does not offer. Spring Cloud Stream adds an abstraction layer that lets you switch brokers by changing a dependency.
The choice of pattern — fire-and-forget, request-reply, or pub-sub — shapes the entire architecture of a feature. Fire-and-forget is appropriate when the caller does not need a result: sending a welcome email, logging an audit event, triggering a background report. Request-reply is appropriate when the caller does need a result but can tolerate higher latency than a synchronous call: an order enrichment service that calls a pricing service asynchronously while computing other fields in parallel. Pub-sub is appropriate when multiple downstream systems need to react to the same event: an OrderPlaced event consumed by inventory, billing, analytics, and fulfilment.
The choice of broker shapes throughput, ordering guarantees, and routing capabilities. Kafka's partitioned log is the choice for high-throughput event streaming (millions of events per second), ordered processing within a partition, and event replay (new consumers can read from the beginning of a topic). RabbitMQ's exchange/queue model is the choice for flexible routing (topic exchanges, fanout exchanges, header routing), low-latency RPC patterns, and priority queues. Both are mature, production-proven, and have excellent Spring Boot support.
Backpressure is the often-overlooked consequence of async messaging. If producers publish faster than consumers can process, the broker queue grows without bound until it exhausts memory or disk. Explicit backpressure mechanisms — bounded queues, consumer concurrency limits, rate limiting, and dead-letter queues — are essential production requirements, not optional enhancements.
Fire-and-Forget: Maximising Throughput
Fire-and-forget is the simplest and highest-throughput async pattern. The producer publishes a message to the broker and immediately continues execution without waiting for processing confirmation beyond the broker's acknowledgement (acks=all for Kafka). It is appropriate when the business operation is complete once the event is durably recorded, regardless of downstream processing.
Common use cases: audit logging, welcome emails, analytics events, cache invalidation signals, search index updates. The key property these share is that the producer does not need to know whether or when the consumer processes the event, and a processing failure in the consumer can be handled independently (DLQ + alerting) without affecting the producer's success path.
In Spring Boot with Kafka, fire-and-forget uses KafkaTemplate.send(), which returns a CompletableFuture. For true fire-and-forget, you can ignore the future — the message is delivered to the broker regardless. For production resilience, add a callback to log send failures: listenableFuture.whenComplete((result, ex) -> { if (ex != null) log.error(...); }). This does not block the producer but gives visibility into broker-level failures.
With RabbitMQ, fire-and-forget uses RabbitTemplate.convertAndSend(). Publisher confirms should be enabled to detect broker-level rejections. Without publisher confirms, a message can be silently dropped if the exchange does not have a matching queue.
Critical producer configuration for Kafka reliability: acks=all (wait for all in-sync replicas), retries=Integer.MAX_VALUE with idempotent producer enabled, and max.in.flight.requests.per.connection=5 (safe with idempotent producer). This prevents message loss and duplicate production without sacrificing throughput.
Request-Reply with Correlation IDs
Request-reply over a message broker gives you the asynchronous decoupling benefit while still returning a result to the caller. The pattern works by attaching a correlation ID to the outgoing request message and a reply-to topic or queue. The consumer processes the request and publishes its response to the reply-to destination, including the original correlation ID. The caller matches the incoming response to the outstanding request using the correlation ID.
Spring Kafka provides ReplyingKafkaTemplate, which automates this pattern. It creates a reply consumer, generates correlation IDs, serialises/deserialises request and reply, and blocks (or uses a CompletableFuture) until the response arrives or a timeout expires. It is conceptually clean but carries a production warning: blocking on .get() consumes a thread for the duration of the wait. Under load, this can exhaust thread pools and cause cascading timeouts.
The production-safe approach is to use non-blocking callbacks: replyingTemplate.sendAndReceive(record).whenComplete((reply, ex) -> { ... }). This releases the calling thread immediately and processes the reply on a separate callback thread.
RabbitMQ's RabbitTemplate.convertSendAndReceive() implements the same pattern for AMQP. RabbitMQ is often preferred for request-reply because its exchange/queue model supports per-request reply queues naturally, and its low-latency routing makes round trips shorter.
When to use request-reply over Kafka vs direct HTTP: use async request-reply when the caller can proceed with other work while waiting, or when the downstream service needs to batch requests for efficiency. Use HTTP when you need the response synchronously in the same request context and latency must be minimised.
ReplyingKafkaTemplate.sendAndReceive().get() blocks a thread for up to the timeout duration. With 200 concurrent requests and a 10s timeout, you need 200 threads minimum. Use .thenApply() callbacks instead, or wrap in a virtual thread (Java 21+) to avoid blocking platform threads.Pub-Sub: Event-Driven Fan-Out
Pub-sub (publish-subscribe) is the pattern where a producer publishes an event to a topic or exchange, and any number of independent consumers receive and process their own copy of the event. The producer has no knowledge of its consumers — it simply publishes the fact that something happened. This is the core pattern of event-driven microservices.
In Kafka, pub-sub is implemented with consumer groups. Each consumer group gets its own copy of every message in the topic. Within a consumer group, each partition is processed by exactly one consumer — this provides horizontal scalability up to the number of partitions. To add a new subscriber (e.g., an analytics service wants to know about orders), simply create a new consumer group with a new groupId — no changes to the producer or other consumers required.
In RabbitMQ, pub-sub is implemented with fanout exchanges or topic exchanges. A fanout exchange routes every message to all bound queues. A topic exchange routes based on routing key patterns (orders.# routes all order events). Adding a new subscriber means binding a new queue to the exchange — again, no producer changes required.
The critical operational difference: Kafka retains messages for a configurable period (7 days by default), so a new consumer group can replay historical events. RabbitMQ does not — messages are deleted after acknowledgement, so a new queue only receives messages published after it was bound. This makes Kafka the choice for event sourcing and audit trails; RabbitMQ the choice for transient notifications.
Backpressure in pub-sub: if one consumer group is slow, it accumulates lag independently without affecting other consumer groups (Kafka) or other queues (RabbitMQ). Monitor per-consumer-group lag independently and scale slow consumers separately.