Apache Kafka #
When a system needs to connect dozens of services exchanging data in real time, a point-to-point approach creates an unmanageable web of connections. Apache Kafka steps in as a distributed streaming platform that enables large-scale data exchange with low latency, without producers and consumers needing to know each other. Kafka isn’t just a message queue — it’s a distributed commit log that can store data for a configurable period, letting consumers re-read messages whenever needed. In the Java context, the Kafka ecosystem is very mature with a feature-rich official library, from simple producers to stream processing with Kafka Streams.
Kafka Basics #
Before writing a single line of code, it’s important to understand Kafka’s data model because it’s very different from traditional message brokers like RabbitMQ. Kafka uses an immutable log structure — messages that have been written can’t be modified or directly deleted.
Topics and Partitions #
A topic is a category or feed name where messages are published. Each topic is divided into one or more partitions. A partition is Kafka’s unit of parallelism — one partition can only be consumed by one consumer within the same consumer group at a time.
Topic: "order-events"
├── Partition 0: [msg-0] [msg-1] [msg-4] [msg-7]
├── Partition 1: [msg-2] [msg-5] [msg-8]
└── Partition 2: [msg-3] [msg-6] [msg-9]
Message order is only guaranteed within a single partition. If you need global ordering, use one partition — but that sacrifices parallelism. If ordering only needs to be guaranteed per entity (for example, all events for the same order ID must be in order), use a message key — Kafka will always send messages with the same key to the same partition.
Offsets #
Every message in a partition has an offset — an integer that increases monotonically. The offset is the consumer’s “bookmark” position. Consumers store the last processed offset, so they can resume from the correct position after a restart.
sequenceDiagram
participant Producer
participant Kafka as Kafka Broker
participant Consumer
Producer->>Kafka: publish("order-created", key="order-123")
Kafka-->>Producer: offset=42 (partition=1)
Consumer->>Kafka: poll() — starting from offset=40
Kafka-->>Consumer: [msg offset=40, offset=41, offset=42]
Consumer->>Kafka: commitOffset(partition=1, offset=43)Brokers, Leaders, and Replicas #
Kafka runs as a cluster of one or more servers called brokers. Each partition has one leader and zero or more replicas. All reads and writes for a given partition go to its leader. Replicas follow the leader to provide fault tolerance.
flowchart TD
P[Producer] --> B0[Broker 0\nLeader: P0, P2\nReplica: P1]
P --> B1[Broker 1\nLeader: P1\nReplica: P0, P2]
P --> B2[Broker 2\nLeader: —\nReplica: P0, P1, P2]
B0 --> C1[Consumer Group A\nMember 1 — P0]
B1 --> C2[Consumer Group A\nMember 2 — P1]
B0 --> C3[Consumer Group A\nMember 3 — P2]Setting Up Dependencies #
To use Kafka with Java, add the kafka-clients dependency to your project. With Maven:
<dependencies>
<!-- Official Kafka client -->
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.7.0</version>
</dependency>
<!-- SLF4J for logging (required by the Kafka client) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.12</version>
</dependency>
</dependencies>
For Gradle:
dependencies {
implementation 'org.apache.kafka:kafka-clients:3.7.0'
implementation 'org.slf4j:slf4j-simple:2.0.12'
}
If you’re running Kafka locally for development, the easiest way is Docker:
# Run Kafka in KRaft mode (without Zookeeper, available since Kafka 3.3+)
docker run -d \
--name kafka \
-p 9092:9092 \
-e KAFKA_NODE_ID=1 \
-e KAFKA_PROCESS_ROLES=broker,controller \
-e KAFKA_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093 \
-e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 \
-e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
-e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT \
-e KAFKA_CONTROLLER_QUORUM_VOTERS=1@localhost:9093 \
-e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
apache/kafka:3.7.0
Producers #
The producer is responsible for sending messages to Kafka. Producer configuration affects throughput, latency, and delivery durability.
Producer Configuration #
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
import java.util.concurrent.Future;
public class OrderProducer {
private final KafkaProducer<String, String> producer;
private static final String TOPIC = "order-events";
public OrderProducer() {
Properties props = new Properties();
// Bootstrap servers — the initial entry point to the cluster
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
// Serializers for key and value
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// acks=all — wait for confirmation from all in-sync replicas
// ANTI-PATTERN: acks=0 (fire-and-forget, messages can be lost)
// ANTI-PATTERN: acks=1 (leader only, can be lost if the leader crashes before replication)
props.put(ProducerConfig.ACKS_CONFIG, "all");
// Automatic retry on failure (transient errors)
props.put(ProducerConfig.RETRIES_CONFIG, 3);
props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 100);
// Idempotent producer — prevents duplicates during retries
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
// Batching for higher throughput
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384); // 16 KB per batch
props.put(ProducerConfig.LINGER_MS_CONFIG, 5); // wait 5ms for a fuller batch
this.producer = new KafkaProducer<>(props);
}
public void close() {
producer.close();
}
}
Sending Messages #
There are three ways to send messages: fire-and-forget, synchronous, and asynchronous. Choose based on your needs:
public class OrderProducer {
// ... (constructor above)
// ✗ ANTI-PATTERN: Fire-and-forget without a callback
// There's no way to know whether the message was sent successfully
public void sendFireAndForget(String orderId, String payload) {
producer.send(new ProducerRecord<>(TOPIC, orderId, payload));
}
// ✓ CORRECT: Asynchronous with a callback — high throughput, still know the result
public void sendAsync(String orderId, String payload) {
ProducerRecord<String, String> record = new ProducerRecord<>(TOPIC, orderId, payload);
producer.send(record, (RecordMetadata metadata, Exception exception) -> {
if (exception != null) {
System.err.println("Failed to send message for order " + orderId + ": " + exception.getMessage());
// Here: send to a dead letter queue, alert monitoring, or retry logic
return;
}
System.out.printf(
"Message sent — topic=%s, partition=%d, offset=%d%n",
metadata.topic(),
metadata.partition(),
metadata.offset()
);
});
}
// ✓ CORRECT: Synchronous — used when you need confirmation before continuing
// Slower (blocks per message), use only when truly necessary
public RecordMetadata sendSync(String orderId, String payload) throws Exception {
ProducerRecord<String, String> record = new ProducerRecord<>(TOPIC, orderId, payload);
Future<RecordMetadata> future = producer.send(record);
return future.get(); // blocks until confirmation
}
}
Sending to a Specific Partition #
By default, Kafka determines the partition based on a hash of the message key. But sometimes you need to send to a specific partition explicitly:
// Kafka determines the partition from the key hash — enough for most cases
ProducerRecord<String, String> byKey = new ProducerRecord<>(
"order-events",
"order-123", // key — all messages with this key go to the same partition
"{\"status\": \"created\"}"
);
// Specify the partition explicitly
ProducerRecord<String, String> toPartition = new ProducerRecord<>(
"order-events",
0, // partition index
"order-456",
"{\"status\": \"created\"}"
);
Don’t specify partitions explicitly unless you have a very strong reason. This complicates rebalancing and reduces load distribution flexibility. Use a message key and let Kafka decide the partition based on the hash.
Consumers #
Consumers read messages from Kafka. Unlike traditional message brokers, reading a message from Kafka doesn’t delete it — the message stays until the retention period expires.
Consumer Configuration #
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
public class OrderConsumer {
private final KafkaConsumer<String, String> consumer;
private static final String TOPIC = "order-events";
private volatile boolean running = true;
public OrderConsumer(String groupId) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
// Group ID — consumers with the same group ID share partitions
props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
// Auto offset reset — what to do when no offset is stored?
// "earliest" = start from the beginning, "latest" = only new messages
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
// ANTI-PATTERN: enable.auto.commit=true — offsets are committed automatically
// This can cause missed messages if the consumer crashes after commit but before processing finishes
// CORRECT: disable auto commit, commit manually after successful processing
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
// Heartbeat interval — how often the consumer sends a liveness signal to the broker
props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 3000);
// Session timeout — if there's no heartbeat for this long, the consumer is considered dead
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 30000);
// Max poll records — how many records per poll()
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 100);
this.consumer = new KafkaConsumer<>(props);
}
}
The Poll Loop #
Consumers work in a poll loop — they keep asking the broker whether new messages have arrived:
public void start() {
// Subscribe to one or more topics
consumer.subscribe(Collections.singletonList(TOPIC));
try {
while (running) {
// Poll with a timeout — how long to wait if there are no new messages
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (var record : records) {
try {
processRecord(record.key(), record.value());
// ✓ Commit the offset after successful processing
// commitSync() — slower but safer (blocks until confirmation)
consumer.commitSync();
} catch (Exception e) {
System.err.printf(
"Failed to process record at partition=%d offset=%d: %s%n",
record.partition(),
record.offset(),
e.getMessage()
);
// Don't commit the offset — the message will be reprocessed after restart
}
}
}
} finally {
consumer.close();
}
}
private void processRecord(String key, String value) {
System.out.printf("Processing order %s: %s%n", key, value);
// business logic implementation
}
public void stop() {
running = false;
}
Committing Offsets: Sync vs Async #
Your choice of offset commit method affects throughput and reliability:
// commitSync() — blocks until the broker confirms the commit
// WHEN: at the end of a batch, when the app is shutting down, when you need full guarantees
consumer.commitSync();
// commitAsync() — non-blocking, suitable for high throughput
// WHEN: when processing large batches and you can tolerate at-least-once
consumer.commitAsync((offsets, exception) -> {
if (exception != null) {
System.err.println("Commit failed: " + exception.getMessage());
// commitAsync doesn't retry automatically to avoid out-of-order commits
// retry manually here if needed
}
});
// ✓ BEST PATTERN: combination — async during processing, sync at the end or on shutdown
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (var record : records) {
processRecord(record.key(), record.value());
}
consumer.commitAsync(); // async for throughput
}
consumer.commitSync(); // sync on shutdown for safety
Consumer Groups #
The consumer group is Kafka’s main mechanism for horizontal scalability. Several consumers in the same group collectively consume all partitions of a topic.
flowchart TD
T[Topic: order-events\nPartition 0, 1, 2, 3]
subgraph CGA[Consumer Group A — order-processor]
C1[Consumer 1\nP0, P1]
C2[Consumer 2\nP2, P3]
end
subgraph CGB[Consumer Group B — order-analytics]
C3[Consumer 3\nP0, P1, P2, P3]
end
T --> C1
T --> C2
T --> C3Key points about consumer groups:
- One partition can only be consumed by one consumer within the same group — this guarantees per-partition ordering.
- Multiple consumer groups can read the same topic independently — Group B above receives all the same messages as Group A, but each has its own offsets.
- If the number of consumers > the number of partitions, the extra consumers sit idle — not an error, but wasted resources.
Rebalancing #
Rebalancing happens when:
- A new consumer joins the group
- A consumer leaves the group (crash or shutdown)
- New partitions are added to a topic
- The subscribe pattern changes
During rebalancing, all consumers in the group stop consuming. This pause is commonly called “stop the world”. To minimize its impact, implement a ConsumerRebalanceListener:
import org.apache.kafka.clients.consumer.ConsumerRebalanceListener;
import org.apache.kafka.common.TopicPartition;
import java.util.Collection;
public class OrderConsumerRebalanceListener implements ConsumerRebalanceListener {
private final KafkaConsumer<String, String> consumer;
public OrderConsumerRebalanceListener(KafkaConsumer<String, String> consumer) {
this.consumer = consumer;
}
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// Called BEFORE rebalancing — commit offsets for all partitions being taken away
System.out.println("Partitions being revoked, committing offsets first...");
consumer.commitSync(); // make sure no messages are missed
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
// Called AFTER rebalancing — new partitions have been assigned
System.out.println("New partitions assigned: " + partitions);
}
}
// Use the listener when subscribing
consumer.subscribe(
Collections.singletonList("order-events"),
new OrderConsumerRebalanceListener(consumer)
);
JSON Serialization #
In production, Kafka messages are almost always JSON or Avro. Here’s how to use Jackson for JSON serialization:
<!-- Add to pom.xml -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
import com.fasterxml.jackson.databind.ObjectMapper;
// Model for the event
public record OrderEvent(
String orderId,
String status,
double totalAmount,
long timestamp
) {}
// Custom serializer for the producer
public class JsonSerializer<T> implements org.apache.kafka.common.serialization.Serializer<T> {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public byte[] serialize(String topic, T data) {
if (data == null) return null;
try {
return objectMapper.writeValueAsBytes(data);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize to JSON: " + e.getMessage(), e);
}
}
}
// Custom deserializer for the consumer
public class JsonDeserializer<T> implements org.apache.kafka.common.serialization.Deserializer<T> {
private final ObjectMapper objectMapper = new ObjectMapper();
private final Class<T> targetClass;
public JsonDeserializer(Class<T> targetClass) {
this.targetClass = targetClass;
}
@Override
public T deserialize(String topic, byte[] data) {
if (data == null) return null;
try {
return objectMapper.readValue(data, targetClass);
} catch (Exception e) {
throw new RuntimeException("Failed to deserialize from JSON: " + e.getMessage(), e);
}
}
}
Use this custom serializer in the producer and consumer configuration:
// In the producer
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class.getName());
// In the consumer
// For generic classes, a different approach is needed due to Java type erasure
KafkaConsumer<String, OrderEvent> consumer = new KafkaConsumer<>(props,
new StringDeserializer(),
new JsonDeserializer<>(OrderEvent.class)
);
Error Handling and Retries #
Kafka doesn’t have a built-in dead letter queue (DLQ), but you can build your own. A common pattern is sending failed messages to a separate topic:
public class ResilientConsumer {
private final KafkaConsumer<String, String> consumer;
private final KafkaProducer<String, String> dlqProducer;
private static final String TOPIC = "order-events";
private static final String DLQ_TOPIC = "order-events.DLQ";
private static final int MAX_RETRY = 3;
// ... constructor
public void startWithDLQ() {
consumer.subscribe(Collections.singletonList(TOPIC));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (var record : records) {
boolean processed = false;
for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
processRecord(record.key(), record.value());
processed = true;
break; // exit the retry loop on success
} catch (Exception e) {
System.err.printf(
"Attempt %d/%d failed for key=%s: %s%n",
attempt, MAX_RETRY, record.key(), e.getMessage()
);
if (attempt < MAX_RETRY) {
try {
Thread.sleep(100L * attempt); // exponential-like backoff
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
}
if (!processed) {
// Send to the DLQ for manual investigation
sendToDLQ(record.key(), record.value(), "Failed after " + MAX_RETRY + " attempts");
}
}
consumer.commitSync();
}
}
private void sendToDLQ(String key, String value, String reason) {
// Add error context as a header or value prefix
String dlqPayload = String.format("{\"original\": %s, \"dlqReason\": \"%s\", \"timestamp\": %d}",
value, reason, System.currentTimeMillis());
dlqProducer.send(
new ProducerRecord<>(DLQ_TOPIC, key, dlqPayload),
(metadata, ex) -> {
if (ex != null) {
System.err.println("CRITICAL: Failed to send to DLQ! Key=" + key);
}
}
);
}
private void processRecord(String key, String value) {
// business logic
}
}
flowchart TD
A[Consumer poll\nnew message] --> B{Processed successfully?}
B -- Yes --> C[Commit offset]
B -- No --> D{Max retries\nreached?}
D -- Not yet --> E[Wait backoff] --> B
D -- Reached --> F[Send to DLQ topic]
F --> G[Commit offset\ndespite failure]
G --> H[Alert ops team]Don’t let a consumer crash endlessly without a DLQ. A consumer that keeps failing and never commits offsets will read the same messages repeatedly, blocking the entire partition from processing new messages.
Managing Topics Programmatically #
Besides using the command line, you can create and manage topics from Java code using AdminClient:
import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.admin.CreateTopicsResult;
import java.util.Collections;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
public class KafkaTopicManager {
private final AdminClient adminClient;
public KafkaTopicManager() {
Properties props = new Properties();
props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
this.adminClient = AdminClient.create(props);
}
public void createTopic(String topicName, int partitions, short replicationFactor) {
NewTopic newTopic = new NewTopic(topicName, partitions, replicationFactor);
// Retention configuration: 7 days (in milliseconds)
newTopic.configs(Map.of(
"retention.ms", String.valueOf(7 * 24 * 60 * 60 * 1000L),
"compression.type", "snappy",
"cleanup.policy", "delete"
));
CreateTopicsResult result = adminClient.createTopics(Collections.singletonList(newTopic));
try {
result.all().get(); // wait until finished
System.out.println("Topic created successfully: " + topicName);
} catch (ExecutionException e) {
if (e.getCause() instanceof org.apache.kafka.common.errors.TopicExistsException) {
System.out.println("Topic already exists: " + topicName);
} else {
throw new RuntimeException("Failed to create topic: " + e.getMessage(), e);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public void listTopics() throws Exception {
var topics = adminClient.listTopics().names().get();
topics.forEach(System.out::println);
}
public void close() {
adminClient.close();
}
}
Running Together — Complete Example #
Here’s a complete example of a producer and consumer working together in one application:
public class KafkaDemo {
public static void main(String[] args) throws Exception {
String topic = "demo-orders";
String groupId = "order-processor";
// Create the topic first
KafkaTopicManager topicManager = new KafkaTopicManager();
topicManager.createTopic(topic, 3, (short) 1);
topicManager.close();
// Run the consumer on a separate thread
Thread consumerThread = new Thread(() -> {
OrderConsumer consumer = new OrderConsumer(groupId);
consumer.start();
});
consumerThread.setDaemon(true);
consumerThread.start();
// The producer sends a few messages
OrderProducer producer = new OrderProducer();
for (int i = 1; i <= 10; i++) {
String orderId = "order-" + i;
String payload = String.format("{\"id\": \"%s\", \"status\": \"created\", \"amount\": %.2f}",
orderId, Math.random() * 1000);
producer.sendAsync(orderId, payload);
}
// Wait a moment so the consumer has time to process
Thread.sleep(5000);
producer.close();
}
}
When to Use Kafka and When Not To #
Kafka isn’t the solution for every messaging need. Choosing Kafka in the wrong situation adds operational complexity without proportionate benefits.
USE KAFKA WHEN:
✓ Message volume is very high (hundreds of thousands per second)
✓ You need replay — consumers need to re-read old messages
✓ Many independent consumers need to read the same data
✓ Event sourcing — events as the system's source of truth
✓ Stream processing — real-time analytics, aggregation, transformation
✓ Audit logs — you need a history of all events in the system
✓ Decoupling between many services in a microservices architecture
CONSIDER ALTERNATIVES WHEN:
✗ Low volume and high routing complexity → RabbitMQ fits better
✗ You need request-reply (RPC pattern) → gRPC or REST is more natural
✗ A simple task queue (jobs that must execute once) → RabbitMQ or Amazon SQS
✗ A small team without ops capacity to maintain a Kafka cluster → manage operational costs first
✗ Sub-millisecond latency is critical → Kafka isn't the best choice
flowchart TD
A{Message volume\nvery high?} -- Yes --> B{Need\nreplay?}
A -- No --> C{Many independent\nconsumers?}
B -- Yes --> KAFKA[Kafka]
B -- No --> D{Need durability\nand ordering?}
C -- Yes --> KAFKA
C -- No --> E{Simple task\nqueue?}
D -- Yes --> KAFKA
D -- No --> RABBIT[RabbitMQ or SQS]
E -- Yes --> RABBIT
E -- No --> REST[REST or gRPC]Summary #
- Topics and partitions are Kafka’s foundation — a partition is the unit of parallelism, and ordering is only guaranteed within one partition. Use a message key for deterministic routing to the same partition.
- Async producers with a callback are the best choice for high throughput — use
acks=allandenable.idempotence=truefor durability without duplicates.- Disable automatic offset commits (
enable.auto.commit=false) and commit manually after successful processing to avoid missed messages.- Consumer groups enable horizontal scalability — add consumers to increase throughput, but the effective consumer count is capped by the number of partitions.
- Implement a Dead Letter Queue (DLQ) as a separate topic to handle failed messages — don’t let a consumer loop forever on a broken message.
- ConsumerRebalanceListener is important for committing offsets before rebalancing starts, preventing messages from being processed twice after partitions move between consumers.
- AdminClient enables topic management from Java code — ideal for ensuring a topic exists before a producer starts sending.
- Kafka fits best for high volume, event sourcing, stream processing, and many independent consumers — not for simple task queues or request-reply patterns.