Skip to content

Spring Boot · Async & Messaging

Spring Boot Messaging Basics: JMS, RabbitMQ, and Kafka Explained

5 min read Updated 2026-06-26 Share:

Practice Messaging Basics interview questions

Why a broker instead of a REST call

A direct REST call between two services is synchronous and tightly coupled: service A waits for service B, B has to be up right now, and a traffic spike on A becomes an overload on B. A message broker breaks all three constraints. The producer drops a message and moves on; the broker buffers it; the consumer reads it whenever it's ready. You get temporal decoupling (B can be down), buffering (spikes become a growing queue, not a crash), and durability (messages survive a consumer restart). The trade-off is you give up the immediate synchronous answer — so use REST when you need a reply, and messaging when you can fire and forget.

The three families: JMS, AMQP, Kafka

These three names get thrown around interchangeably, but they're different kinds of things:

JMS    → a Java API standard (an interface); implemented by ActiveMQ/Artemis
AMQP   → a wire protocol (language-agnostic); implemented by RabbitMQ
Kafka  → a distributed, replayable commit log; partitions and offsets

JMS is just a Java API — code against the interface, plug in a broker. AMQP/RabbitMQ is a wire protocol with a rich routing model. Kafka isn't really a queue at all; it's a durable log you read by offset, built for high-throughput streaming and replay. Choosing between them is mostly about routing flexibility (RabbitMQ) versus throughput and replayability (Kafka) versus Java-standard simplicity (JMS).

Underneath them all sits the queue-vs-topic distinction. A queue is point-to-point — each message goes to exactly one consumer, so competing consumers share work. A topic is publish/subscribe — each message goes to every subscriber, so it broadcasts. Kafka blends them with consumer groups: every group gets every message, but members within a group split the partitions.

Send and receive: the same shape everywhere

Spring Boot's starters auto-configure a connection and a template per technology. Add the starter, set connection properties, inject the template. The send/receive shape repeats across all three:

spring.rabbitmq.host=localhost
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=orders
// JMS
jmsTemplate.convertAndSend("orders", order);

@JmsListener(destination = "orders")
void receive(Order o) { process(o); }

// Kafka
kafkaTemplate.send("orders", order.id(), order);   // key drives partition + ordering

@KafkaListener(topics = "orders", groupId = "billing")
void consume(Order o) { charge(o); }

RabbitMQ adds one twist worth knowing: a producer never publishes directly to a queue. It publishes to an exchange with a routing key, and bindings decide which queues receive it. Exchange types — direct, topic (wildcards like order.*), fanout, and headers — give you flexible routing you can rewire without touching code.

Kafka's twist is partitioning. The message key decides the partition, and Kafka guarantees order only within a partition. Put everything that must stay ordered under the same key (say, a customer id) and those events stay in sequence; different keys may interleave. More partitions buys parallelism at the cost of global ordering.

Delivery guarantees: the part that actually matters

The hardest part of messaging isn't sending — it's making sure messages aren't lost or mishandled. It starts with acknowledgements. An ack tells the broker a message was successfully processed so it can be removed. With auto-ack, the broker marks a message done the moment it's delivered — if your handler then crashes, the message is gone. With manual ack, you acknowledge only after successful processing, so a crash leaves the message to be redelivered. That choice determines your delivery semantics:

at-least-once: redeliver until acked → never lost, but may arrive twice
at-most-once:  deliver at most once  → no duplicates, but may be lost

Almost every real system runs at-least-once, which means duplicates will happen — a redelivery after a crash, a consumer-group rebalance, a retry. So consumers must be idempotent: processing the same message twice has the same effect as once. The usual trick is to track a stable business id and skip what you've already handled:

@KafkaListener(topics = "payments")
void charge(Payment p) {
    if (processedIds.putIfAbsent(p.id(), true) != null) return;  // already done
    chargeCard(p);
}

"Exactly-once" is mostly a marketing phrase — in practice it's at-least-once plus idempotency.

Poison messages and dead-letter queues

Some message will always fail — bad data, a bug, a missing dependency. Left alone, it gets redelivered forever, blocking or looping the consumer. That's a poison message, and the cure is a bounded retry policy that, after exhausting attempts, shunts the message to a dead-letter queue so the rest of the traffic keeps flowing:

@RetryableTopic(attempts = "3", dltStrategy = DltStrategy.FAIL_ON_ERROR)
@KafkaListener(topics = "orders")
void consume(Order o) { process(o); }     // 3 tries, then routed to orders.DLT

You inspect and replay the DLQ later. Never retry forever.

The dual-write problem

One last trap that comes up constantly: you need to update the database and publish a message, but they're two systems with no shared transaction. If the DB commits and the publish fails (or the reverse), your state is inconsistent. Trying to wrap both in one transaction doesn't work. The standard fix is the transactional outbox: write the message into an outbox table inside the same DB transaction as your data, then a separate relay reads outbox rows and publishes them:

tx { save(order); insert into outbox(event) }   ← atomic, one transaction
relay: read outbox → publish to broker → mark sent

The database becomes the single source of truth, and publishing becomes a reliable follow-up rather than a risky second write.

Rule of thumb: Reach for a broker when you want decoupling and durability; pick JMS, RabbitMQ, or Kafka by routing and throughput needs; and design every consumer around at-least-once delivery — idempotent, with a DLQ, and an outbox when a DB write and a publish must stay in sync.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel