Getting Started with Messaging

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Messaging is a style of communication where a sender hands a message to an intermediary — a broker, or in some designs another process entirely — instead of calling the receiver directly and waiting for it to answer. This page introduces why systems reach for messaging in the first place, how it differs from a direct request/response call, the vocabulary Apache Kafka and RabbitMQ each use for the same underlying ideas, and one complete first send/receive example against each broker’s plain Java client — no framework, no Spring Boot, just kafka-clients and amqp-client. The rest of the Messaging reference builds on the vocabulary and examples introduced here.

Why messaging instead of direct calls

A direct call — a REST request, a gRPC call, a plain method invocation across a network — ties the caller’s fate to the callee’s, for the whole duration of the call: the callee must be reachable right now, must answer fast enough, and if it is down, slow, or overloaded, the caller feels every bit of that immediately. Messaging exists to break that coupling, for five concrete, well-established reasons.

  • Guaranteed delivery. A message accepted by a durable broker survives the crash of either endpoint: if the consumer is down when the message arrives, the broker holds onto it and delivers it once the consumer comes back, instead of the message simply being lost the way an HTTP request to a downed server is lost unless the caller itself implements retries. A payment-confirmation email does not need to be sent within the same request that captured the payment — it needs to be sent eventually, reliably, even if the mail service is temporarily unreachable when the payment completes.

  • Decoupling. A producer does not need to know how many consumers exist, where they run, what language they are written in, or whether they are even online at the moment the message is sent. A new consumer can be added — a fraud-detection service starts listening to the same orders stream a fulfillment service already consumes — without a single line of the producer changing, because the producer was never calling the consumer to begin with.

  • Scalability and high availability. A broker absorbs bursts a downstream service could not handle inline: a flash sale that spikes order volume 50x for ten minutes fills a queue or a topic’s partitions instead of overwhelming a checkout service’s thread pool. Multiple consumer instances can then either share that backlog (each processing a slice of it, for throughput) or run redundantly (so one instance’s crash does not stall processing), and either way the producer’s code is unaffected.

  • Asynchrony. The producer does not block waiting for the message to be processed — it hands the message to the broker and moves on, and the actual processing happens on the consumer’s own schedule, which may be milliseconds or minutes later. This is what makes "fire an event and continue" a fundamentally different latency profile from "call and wait for the response".

  • Interoperability. A message is a self-contained, language-agnostic payload (bytes plus headers), so a Java service, a Python service, and a legacy system in a completely different stack can all produce to and consume from the same broker without agreeing on a shared library, a shared runtime, or even a shared deployment schedule — only a shared message format and a shared broker protocol.

Synchronous, asynchronous, brokered, and brokerless

These five motivations explain why messaging exists, but they do not mean messaging is always the right tool — the choice is a genuine trade-off against direct request/response calls, and a further, separate choice of whether a broker sits in the middle at all.

Synchronous request/response: REST and gRPC

In a synchronous call — a REST call over HTTP, a gRPC call over HTTP/2 — the caller sends a request and blocks (or, in a reactive stack, suspends) until a response arrives on the same logical round-trip. This is the natural fit whenever the caller genuinely needs an answer before it can proceed: "is this credit card valid?", "what is this user’s current balance?", "did this write succeed?". Latency-sensitive, request-shaped interactions — anything a user is actively waiting on in a UI, or anything a subsequent step in the same operation depends on — belong here. The cost is exactly the coupling messaging exists to avoid: the caller cannot proceed until the callee answers, so the callee’s availability and latency become the caller’s availability and latency too.

Asynchronous messaging

In asynchronous messaging, the producer hands a message to a broker and returns immediately — an acknowledgement that the broker has the message, not that anyone has processed it. This fits throughput- and decoupling-sensitive work: "notify every interested service that an order was placed" has no single answer to wait for, may have zero, one, or many interested consumers, and does not need to hold up whatever workflow produced the event. It is a poor fit for anything the caller needs an immediate answer to, and it moves failure handling from "the HTTP call failed, retry it" to a broker-specific vocabulary of acknowledgements, redelivery, and dead-letter handling that this reference covers on later pages.

Brokered vs. brokerless messaging

Kafka and RabbitMQ are both brokered: a dedicated broker process sits between every producer and every consumer, owns storage and delivery, and neither side ever opens a connection directly to the other. This buys durability, fan-out to consumers that do not yet exist, and a single place to operate, secure, and observe message flow — at the cost of running (and depending on) that broker. A brokerless library such as ZeroMQ takes the opposite trade: producers and consumers connect to each other directly (ZeroMQ is a socket library, not a server), which removes the broker as an operational dependency and a potential bottleneck, at the cost of pushing peer discovery, buffering, and most delivery guarantees back onto the application. This page, and the rest of this Messaging reference, covers the brokered model — Kafka and RabbitMQ — exclusively.

sequenceDiagram participant Client participant Server Client->>Server: HTTP POST /orders (place order) activate Server Server->>Server: validate, persist, charge payment Server-->>Client: 201 Created, order confirmed deactivate Server Note over Client,Server: Client blocks for the whole round-trip -- a slow or unreachable Server directly slows or breaks the Client.
sequenceDiagram participant Producer participant Broker participant Consumer Producer->>Broker: publish order placed event Broker-->>Producer: ack, event durably stored Note over Producer: Producer continues immediately -- it does not wait for the event to be processed. Broker->>Consumer: deliver order placed event Consumer-->>Broker: ack, event processed

Shared vocabulary: Kafka and RabbitMQ side by side

Kafka and RabbitMQ solve overlapping problems with genuinely different models underneath, so the same generic idea often has no exact equivalent on one side or the other. The table below is deliberately honest about that rather than forcing a false one-to-one mapping — later pages in this reference use each broker’s own column term, not the generic one, once the broker in question is clear from context.

Generic term Kafka RabbitMQ

Message / event / record

Record — a key, a value, a timestamp, and headers, appended to a topic’s log. Often still called a "message" in prose.

Message — a byte payload plus properties/headers, published to an exchange.

Producer

Producer — writes records to a topic.

Publisher — publishes messages to an exchange. Often still called a "producer" in prose; the client API’s own name is basicPublish.

Consumer

Consumer — reads records from a topic, tracking its own offset.

Consumer — registered against a queue with basicConsume; the term is the same on both sides.

Broker

Broker — a single Kafka server process; a cluster is many brokers.

Broker — a single RabbitMQ node; a cluster is many nodes.

Channel

No first-class concept — a client multiplexes requests over a small number of raw TCP connections managed internally by kafka-clients.

Channel — a lightweight virtual connection multiplexed over one real TCP connection; almost every AMQP client operation (basicPublish, basicConsume, queueDeclare) is a method call on a Channel.

Queue

No queue concept — see Topic below; the closest analogue is a topic’s partition, which is an ordered log, not a queue that removes items once read.

Queue — the actual buffer a message sits in until a consumer acknowledges it; this is where messages are stored and where competing consumers pull from.

Topic

Topic — a named, partitioned, append-only log. Consuming from a topic does not remove records from it; many independent consumer groups can each read the same topic at their own pace and offset.

Topic exchange — a specific exchange type that routes a message to bound queues by matching its routing key against each binding’s pattern (*/# wildcards). It stores nothing itself and has no relation to a Kafka topic beyond sharing the English word.

Acknowledgement

Two distinct acks: a producer ack (acks config) confirms the broker durably wrote a record; a consumer ack is implicit — committing an offset says "I have consumed up to here."

An explicit basicAck (or basicNack) from the consumer for a specific delivery tag; a publisher can separately opt into publisher confirms for its own durability acknowledgement.

Offset

Offset — a per-partition, monotonically increasing position a consumer tracks and commits explicitly; a consumer can rewind to an earlier offset and re-read history.

No offset concept — once a message is acknowledged and removed from a queue it is gone; there is no position to rewind to and replay from.

Routing key

No routing key — a producer picks a partition directly, or lets one be chosen by hashing the record key.

Routing key — a value published alongside the message that an exchange matches against each queue’s binding key to decide which queue(s), if any, receive a copy.

Running a broker locally

Both brokers below can be started with a single docker run command and no further configuration, which is the fastest way to try the client examples that follow (the command’s options are explained in Running Containers).

Apache Kafka

Since Kafka 3.3, a broker can run entirely on KRaft (Kafka’s own Raft-based metadata quorum), with no separate ZooKeeper process to run alongside it. The official apache/kafka image is pre-configured to start a single-node KRaft broker (acting as both broker and controller) the moment the container starts:

docker run -p 9092:9092 apache/kafka:latest

Pin apache/kafka:latest to the specific released version tag shown on the page below for a reproducible setup; the Kafka quickstart walks this same command and the topic-creation and console-producer/consumer steps that follow it. See Apache Kafka — Quickstart for the full walkthrough this command is taken from.

RabbitMQ

The rabbitmq:4-management image bundles the community-supported management plugin, which serves a web UI (and an HTTP API) for inspecting exchanges, queues, and connections — useful the first time through, even though the Java examples below only need the plain AMQP port:

docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:4-management

Port 5672 is the AMQP 0-9-1 protocol port the Java client below connects to; port 15672 serves the management UI at http://localhost:15672 (default credentials guest/guest, valid only for connections from localhost). See RabbitMQ — Downloading and installing RabbitMQ for the supported image tags and installation options this command is drawn from.

A first producer and consumer

Both examples below send and receive the same "order placed" event using nothing but each broker’s plain Java client — org.apache.kafka:kafka-clients for Kafka, com.rabbitmq.client:amqp-client for RabbitMQ. Neither example depends on Spring or any other framework; that integration is covered separately, on Messaging with SmallRye Reactive Messaging for Quarkus.

Kafka: KafkaProducer and KafkaConsumer

package com.example.messaging.kafka;

import java.util.Properties;

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;

public class OrderPlacedProducer {

    public static void main(String[] args) {
        Properties props = new Properties();
        props.put("bootstrap.servers", "localhost:9092");
        props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

        try (Producer<String, String> producer = new KafkaProducer<>(props)) {
            String orderId = "order-42";
            producer.send(new ProducerRecord<>("orders", orderId, "order placed: " + orderId));
        }
    }
}

bootstrap.servers is the only address the client needs — it discovers the rest of the cluster from there; the key/value serializers convert the typed record into bytes before the record is sent. See Apache Kafka documentation — Producer API for the full configuration reference and Producer/ProducerRecord API this example is built from.

package com.example.messaging.kafka;

import java.time.Duration;
import java.util.Collections;
import java.util.Properties;

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;

public class OrderPlacedConsumer {

    public static void main(String[] args) {
        Properties props = new Properties();
        props.put("bootstrap.servers", "localhost:9092");
        props.put("group.id", "billing-service");
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");

        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
            consumer.subscribe(Collections.singletonList("orders"));

            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
                for (ConsumerRecord<String, String> record : records) {
                    System.out.printf("offset = %d, key = %s, value = %s%n",
                            record.offset(), record.key(), record.value());
                }
            }
        }
    }
}

group.id is what makes this a member of the billing-service consumer group — every consumer sharing that group id splits the orders topic’s partitions among themselves, while a different group id started elsewhere would independently receive every record again from its own offset. poll is the call that actually fetches records and must be called repeatedly; nothing arrives on its own. See Apache Kafka documentation — Consumer API for the full configuration reference and KafkaConsumer/ConsumerRecords API this example is built from.

RabbitMQ: Channel, basicPublish, and basicConsume

package com.example.messaging.rabbitmq;

import java.util.Map;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

public class OrderPlacedPublisher {

    private static final String QUEUE_NAME = "orders";

    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");

        try (Connection connection = factory.newConnection();
             Channel channel = connection.createChannel()) {
            Map<String, Object> args2 = Map.of("x-queue-type", "quorum");
            channel.queueDeclare(QUEUE_NAME, true, false, false, args2);

            String message = "order placed: order-42";
            channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
            System.out.println(" [x] Sent '" + message + "'");
        }
    }
}

queueDeclare is idempotent — it creates the queue the first time and is a harmless no-op on every later run, as long as the arguments match; x-queue-type=quorum requests a quorum queue, RabbitMQ’s currently recommended replicated queue type for durability. basicPublish with an empty exchange name ("") publishes directly to the queue named by the routing key, using the AMQP default exchange. See RabbitMQ tutorials — "Hello World!" in Java for the full walkthrough this example is adapted from.

package com.example.messaging.rabbitmq;

import java.util.Map;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;
import com.rabbitmq.client.DeliverCallback;

public class OrderPlacedConsumer {

    private static final String QUEUE_NAME = "orders";

    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("localhost");

        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();
        Map<String, Object> args2 = Map.of("x-queue-type", "quorum");
        channel.queueDeclare(QUEUE_NAME, true, false, false, args2);

        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
        DeliverCallback deliverCallback = (consumerTag, delivery) -> {
            String message = new String(delivery.getBody(), "UTF-8");
            System.out.println(" [x] Received '" + message + "'");
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, consumerTag -> { });
    }
}

basicConsume’s third boolean argument is `autoAck — true here acknowledges each delivery automatically the moment it is handed to the callback, which is the simplest option and matches the Kafka example’s default enable.auto.commit; manual acknowledgement (autoAck=false plus an explicit channel.basicAck(…​) inside the callback) is what a consumer that must not lose a message on a mid-processing crash uses instead, and is covered on a later page in this reference. See RabbitMQ tutorials — "Hello World!" in Java for the full walkthrough this example is adapted from.

One event, two brokers, two consumers

The figure below draws the same "order placed" event traveling from a producer, through a broker, to two consumers — once for Kafka, once for RabbitMQ — to make the vocabulary table above concrete. The two halves also make the sharing-model difference explicit: Kafka’s two consumer groups each receive their own independent copy of every event, while RabbitMQ’s two queues each receive their own copy only because the exchange is configured to route to both — if both consumers instead shared a single queue, they would compete for each message rather than both seeing it.

One "order placed" event traveling from a producer to a broker to two consumers; drawn once for Kafka — producer; topic with one partition; and two independent consumer groups — and once for RabbitMQ — publisher; topic exchange; and two queues each with their own consumer