Animated guide · Apache Kafka 4.3 internals

Kafka, from the inside out

From "what is a log?" to leader epochs, KIP-848 rebalances, transactions, tiered storage and share groups — with analogies, clickable diagrams you can break, hands-on exercises and a troubleshooting playbook. Scroll — the diagrams start by themselves.

Kafka 4.3 · zero → expert 🧠
scroll ↓

How to read this guide

a ledger office, a city council, and a lot of clerks

Most Kafka tutorials teach you the API: create a producer, call send(), write a poll loop, done. That gets you to "it works on my laptop". It does not tell you why a consumer group freezes for thirty seconds, why a broker that lost its disk did not lose your data, or why acks=all is still not enough on its own. Those answers live one layer down, in the mechanisms. This guide is about that layer.

We will keep one picture in our heads the whole way through. Imagine a city's ledger archive. Every event in the city is written, in order, as a numbered line in a bound ledger book. Nobody ever erases or rewrites a line; they only add new lines at the end. The archive has several branches (buildings), each holding some of the books, and every important book has photocopies in other branches in case one burns down. A city council keeps the official minutes of which branch holds which book. Teams of clerks read the books and keep a bookmark of how far they got. That is Kafka: records, partitions, brokers, replicas, the KRaft controller quorum, consumer groups and committed offsets.

The guide has levels. Chapters and blocks carry a chip — L1 essentials, L2 practitioner, L3 expert internals, L4 mastery (operations and application design). Choose your depth with the switch in the bottom-left corner; deeper material hides until you want it. The map below jumps anywhere and ticks off what you've read.

▶ The 30-second tour follow one record through the whole system
Press ▶ to follow one record — every station links to the chapter that explains it.
A map, not a simulation: each station is a chapter. Real systems overlap these steps (batching, pipelining, many partitions at once); the failover path is shown in one step here and is explained in chapters on replication, KRaft and leader epochs.
🤓 What "newest" means here

Facts are checked against Apache Kafka 4.3.1 (released 25 June 2026, the newest release at the time of writing) and Spring for Apache Kafka 4.1.1 (August 2026). The 4.x line changed a lot of "classic" Kafka knowledge, so older blog posts can mislead you:

ReleaseWhat changed in the internals
4.0 (Mar 2025)ZooKeeper mode removed — KRaft only. New group coordinator and the next-generation consumer rebalance protocol (KIP-848) GA. Transactions server-side defense (KIP-890). Eligible Leader Replicas (KIP-966 part 1). Producer linger.ms default 0 → 5. Old protocol API versions removed (brokers and clients must be ≥ 2.1).
4.1 (Sep 2025)Queues for Kafka (share groups, KIP-932) in preview. ELR enabled by default on new clusters. Streams rebalance protocol (KIP-1071) in early access.
4.2 (Feb 2026)Share groups production-ready. Streams rebalance protocol production-ready for its core feature set. Dynamic KRaft controllers can auto-join the voter set (controller.quorum.auto.join.enable).
4.3 (May 2026)Cordoning of log directories (KIP-1066). Newly added followers on tiered-storage clusters can skip already-uploaded data (opt-in follower.fetch.last.tiered.offset.enable, default false). New share-group limits (share.delivery.count.limit and friends).

Always read the upgrade notes of your exact version.

🧠 Brain power

Kafka only ever appends to files. It never updates a record in place and, by default, does not even force data to disk on each write. So how can it be faster than a database, survive a broker crash without losing writes it acknowledged, and offer exactly-once processing? Keep the question in mind — the finale answers it.

Part I

Foundations

Zero Kafka knowledge assumed. You get the mental model everything else builds on: the log, the cluster, how a producer writes, how consumers read, why copies exist and when old data disappears. We'll use one analogy, the city ledger archive, for the whole course.

The log: Kafka's one big idea

append-only, numbered, never edited: that's the whole trick
🧠 Brain power

Your bank shows you a balance, but underneath it keeps the list of every transaction, in order, forever. If someone wiped the balance you could rebuild it from that list. If someone wiped the list, the balance would mean nothing. Which of the two is the real data?

Kafka's answer is the list. Everything in Kafka is built on one simple data structure: the log. Here "log" doesn't mean a debug log. It means an append-only sequence of records. You can add records at the end and you can read from any position. You can't change or insert a record in the middle.

Meet the city ledger archive

We'll use one picture for the whole course, so let's set it up carefully. Imagine a city's ledger archive, an old-fashioned records office. Clerks write every event that happens in the city into bound ledger books. They never erase a line and never squeeze a line between two others. A new event always goes on the next empty line.

In the archiveIn KafkaWhere you meet it
One entry: "Alice paid Bob $200"Record (event, message)this chapter
A ledger series, e.g. "Payments"Topicthis chapter
One bound ledger book in that seriesPartitionthis chapter
The printed line numberOffsetthis chapter
An archive branch buildingBrokerch. 2
A courier who bundles letters into sacksProducerch. 3
A team of clerks sharing the books, each with a bookmarkConsumer group, committed offsetch. 4
Photocopies of a book kept in other branchesReplicasch. 5
Shredding the oldest volumes of a bookRetentionch. 6
The city council keeping the official registryKRaft controller quorumch. 2, 9

Records

The unit of data is a record. The docs also call it an event or a message. A record says that "something happened". It has a key, a value, a timestamp and optional headers (small key/value metadata). The official introduction uses this example: key "Alice", value "Made a payment of $200 to Bob", and a timestamp. To Kafka, the key and value are just bytes. Your application decides what they mean (JSON, Avro, a plain string…).

Topics

Records are organized and durably stored in topics, for example payments or page-views. That's our ledger series. A topic is multi-producer and multi-subscriber: zero, one or many applications can write to it, and zero, one or many can read from it. And, unlike a classic message queue, reading a record does not delete it. Records stay until the topic's retention rule throws them away (chapter 6), so the same data can be read again and again, by different readers, at different times.

Partitions and offsets

One ledger book would be a bottleneck: one book can only be written by one hand at a time. So a topic is split into partitions, several books in the same series. The partitions can live on different servers, so many clients can read and write the topic in parallel. That's how Kafka scales.

Inside a partition, every record gets a sequential number called the offset: 0, 1, 2, 3… It's the printed line number in the book. Offsets are per partition. Partition 0 and partition 1 both have a record at offset 5, and the two are unrelated. To name a record uniquely you need three things: topic, partition and offset.

Which book does a new record go into? The producer picks. With the default rules, records with the same key always go to the same partition because the partition is chosen from a hash of the key. All of Alice's payments land in one book, in the order they were written. Records without a key are spread over partitions (chapter 3 shows how).

Kafka guarantees order inside a partition. It does not promise any order across partitions.

That sentence is the most important one in this chapter. The docs put it this way: any consumer of a given topic-partition will always read that partition's events in exactly the same order as they were written. Across two partitions there is no such promise. Play with it below. Type a key, append it, and watch where it lands. Then press the break-it button.

▶ Append & read type a key and append, or run the sample; then break it
Topic payments has 3 partitions. Append a record with a key.
The partition is picked exactly like Kafka's default for keyed records: murmur2(keyBytes) made positive, modulo the partition count (ported from BuiltInPartitioner.partitionForKey). The "#n" badge shows the order in which records were produced. It's for illustration: Kafka doesn't store such a global number. The two readers are independent consumers of partition 0.
❓ There are no dumb questions
Q:
If records are never deleted when read, how is it different from a database table?
A:
A table stores the current state and lets you update rows in place. A log stores the history of changes and only ever appends. You can derive the current state from the log by replaying it. You can't recover the history from the table.
Q:
Can I change a record after it's written?
A:
No. You append a new record with the same key that says "this is the new value". Consumers see both, in order. The archive clerks never erase ink; they write a correction on the next line.
Q:
Does anyone need a global order across the whole topic?
A:
Usually what you need is order per entity: per account, per order ID, per device. Use that entity's ID as the key and Kafka gives you exactly that. If you truly need one total order, you need one partition, which also means one partition's worth of throughput.
⚠️ Watch it!

The key-to-partition mapping is hash(key) % numberOfPartitions. If you add partitions to an existing topic, the same key can start landing in a different partition. New records for Alice may go into a different book than her old ones, and a reader may see them out of order. The kafka-topics.sh help text warns about exactly this. Choose the partition count for keyed topics up front.

✏️ Sharpen your pencil

Start a local single-node Kafka 4.3 cluster as in the official quickstart (bin/kafka-storage.sh format --standalone … then bin/kafka-server-start.sh config/server.properties). Then:

bin/kafka-topics.sh --bootstrap-server localhost:9092 \
  --create --topic payments --partitions 3

bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic payments \
  --reader-property parse.key=true --reader-property key.separator=:
> alice:paid 10
> carol:paid 5
> alice:paid 7

bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic payments \
  --from-beginning --formatter-property print.key=true \
  --formatter-property print.partition=true --formatter-property print.offset=true

1) Do both of Alice's records show the same partition? 2) Which offsets do they have? 3) Run the consumer command again. Do you get the records a second time?

Answer

1) Yes. Same key, same partition. 2) Consecutive offsets within that partition (for example 0 and 1), whatever offsets Carol's record got in its own partition. 3) Yes. Without --group, the console consumer invents a fresh random group (console-consumer-NNNNN) and doesn't commit offsets, so there's no saved position to resume from, and --from-beginning starts it at the earliest offset. Reading didn't delete anything. (In chapter 4 you'll add --group and see a different result.)

🤓 Deep dive

What a partition looks like on disk. A partition is a directory on the broker. A topic my-topic with two partitions has the directories my-topic-0 and my-topic-1. Inside, the log is split into files called segments, each named after the first offset it contains. The first file is 00000000000000000000.log, and a new one starts when the current one reaches segment.bytes or gets older than segment.ms. New records are always appended to the last file. In our analogy, a "book" is really a shelf of volumes. The last volume is still being written; the older ones are closed. Chapter 6 (retention) and chapter 7 (storage engine) build on this.

🔬 Go deeper: why offsets instead of message IDs?

The implementation docs say Kafka's original idea was a producer-generated GUID per message, with a GUID→offset map on each broker. They dropped it. A consumer must track an ID per server anyway, so global uniqueness adds nothing. Mapping random IDs to positions would also need a heavy, disk-synchronized index. A simple per-partition atomic counter (the offset) is enough, and it doubles as the consumer's position.

An offset is a 64-bit integer. A consumer's position is "the offset of the next record I'll read", so a consumer at position 5 has consumed offsets 0–4. Offsets are not guaranteed to be consecutive from a reader's point of view. Compacted topics (ch. 14) remove records, and transactions (ch. 13) write control markers that take up offsets but are never returned to the application. That's why you should never compute "number of records = end − start".

On the broker, the partition's log is managed by UnifiedLog (package org.apache.kafka.storage.internals.log), which keeps its LogSegment files through a LocalLog (a LogSegments collection).

📌 Bullet points
  • A Kafka record has a key, value, timestamp and optional headers. Kafka treats key and value as bytes.
  • A topic is a named stream of records; it's split into partitions, each an append-only log.
  • Each record in a partition has an offset, a per-partition sequence number.
  • Order is guaranteed only within a partition. Same key → same partition (by default) → per-key order.
  • Reading doesn't delete. Many readers can read the same data independently, at their own pace.
  • Adding partitions later changes where keys land. Plan the count for keyed topics.

Brokers & the cluster

no switchboard: clients walk straight to the right branch

One archive building can't hold every ledger in a big city, and one fire would destroy everything. So the city runs several branches. In Kafka, a branch is a broker: a server process that stores partitions and serves reads and writes for them. A group of brokers working together is a cluster.

The partitions of a topic are spread across the brokers. Each partition also has copies (replicas, chapter 5) on several brokers, and exactly one of those copies is the leader. All writes for a partition go to its leader. There are normally many more partitions than brokers, and Kafka spreads leadership around so each broker leads its fair share.

Finding the right branch: bootstrap + metadata

A client (producer or consumer) needs to know which broker leads which partition. It learns this in two steps:

  1. Bootstrap. You configure bootstrap.servers, a list like host1:9092,host2:9092. It's used for the first connection (and again to re-bootstrap if every known broker becomes unreachable, the default metadata.recovery.strategy=rebootstrap). It doesn't need to list every broker, but the docs recommend listing more than one in case one is down.
  2. Metadata. The client asks any broker for metadata. Every broker can answer: which brokers exist, and for each partition which broker is the leader, which brokers hold replicas, and which replicas are in sync.

Then the client connects directly to the leader of each partition it needs. There's no router or load balancer in the middle. In archive terms: you walk into any branch and read the notice board ("Payments book 1 is kept at branch B2"), then you walk to B2 yourself. This is a deliberate design choice. No extra hop, and no central bottleneck in the data path.

Who keeps the notice board up to date? The controllers

Somebody has to decide which broker leads which partition and notice when a broker dies. In Kafka 4.x that's the job of the controllers. They form a small quorum and use a Raft-based protocol called KRaft. In the analogy, they're the city council that keeps the official registry. Brokers register with the controller and send it regular heartbeats. If no heartbeat arrives within broker.session.timeout.ms (default 9000 ms), the broker is considered offline, and the controller picks a new leader for its partitions from the replicas that are in sync.

For learning you can run one process that is both broker and controller. The quickstart's config/server.properties sets process.roles=broker,controller. In production they're usually separate machines. Kafka 4.x has no ZooKeeper at all. Chapter 9 covers KRaft in depth.

▶ Client routing fetch metadata, send to leaders; then kill a broker
Topic orders: 3 partitions, each with a leader on a different broker.
Simplified: one topic, one replica shown per broker line, controller drawn as a single box (really a quorum). Timing is illustrative. The error name and the refresh behavior are real: the producer treats NOT_LEADER_OR_FOLLOWER as a "metadata is stale" error and requests a metadata update before retrying.
🔥 Fireside chat
Client
Why can't I just talk to one broker and let it forward my request? Much simpler for me.
Broker
Simpler for you, but every byte would cross the network twice, and the forwarding broker would become everyone's bottleneck. I'd rather tell you where the leader is and let you go there yourself.
Client
And if the leader moves while I'm not looking?
Broker
Then the old leader says "not me anymore" and you refresh your metadata. You also refresh it every metadata.max.age.ms (default 5 minutes) anyway, so you notice new brokers and partitions.
✏️ Sharpen your pencil

Describe the topic you created in chapter 1:

bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic payments

Find the Leader:, Replicas: and Isr: columns for each partition. On a single-node cluster, what must they all say, and why?

Answer

Every partition has the same leader, replica list and ISR: the one node's ID (with the quickstart config, 1). With one broker there's nowhere else to put a leader or a copy. On a three-broker cluster with --replication-factor 3 you'd see three IDs under Replicas and the leaders spread across the brokers.

🤓 Deep dive

Staying in sync with reality. Clients refresh metadata in two ways: periodically (metadata.max.age.ms, default 300000 ms, to discover new brokers or partitions proactively) and on demand when a broker returns a "you're talking to the wrong node" error. Errors like NOT_LEADER_OR_FOLLOWER (error code 6) and UNKNOWN_TOPIC_OR_PARTITION extend InvalidMetadataException, which is a retriable exception. The producer retries the batch after the refresh, so your application normally never sees them.

🔬 Go deeper: what's inside a MetadataResponse

From MetadataResponse.json in the protocol definitions: a list of Brokers (NodeId, Host, Port, Rack), the ClusterId, a ControllerId, and per topic its Name, TopicId and Partitions. Each partition entry has PartitionIndex, LeaderId, LeaderEpoch, ReplicaNodes, IsrNodes and OfflineReplicas. On the broker, KafkaApis.handleTopicMetadataRequest answers from a local metadata cache (KRaftMetadataCache). That cache is fed by the controllers' metadata log, which is why any broker can answer.

Skipping a round-trip (KIP-951). Since ProduceResponse v10, a NOT_LEADER_OR_FOLLOWER response can carry tagged fields CurrentLeader (leader id + epoch) and NodeEndpoints. In the producer's Sender, that hint updates the partition's leader right away, and a normal metadata update is still requested. The LeaderEpoch number prevents confusion between an old and a new leader. Chapter 16 explains it.

📌 Bullet points
  • A broker stores partitions and serves requests; several brokers form a cluster.
  • Each partition has one leader broker; writes go to the leader. Leadership is spread across brokers.
  • bootstrap.servers is for first contact (and re-bootstrap); the client then gets metadata from any broker.
  • Clients talk directly to partition leaders. No proxy or router sits in the data path.
  • KRaft controllers track broker liveness (heartbeats, broker.session.timeout.ms 9 s) and pick new leaders.
  • A stale client gets NOT_LEADER_OR_FOLLOWER, refreshes metadata and retries automatically.

Producer basics

send() returns before anything leaves the building

A producer is the client that writes records to Kafka. In the archive, it's a courier. You hand it letters (records); it sorts them by destination book (partition) into sacks (batches), and a van (a background thread) drives full sacks to the right branch. Sending one letter per trip would be absurdly slow, so batching is at the heart of Kafka's performance.

What happens when you call send()

The Java KafkaProducer.send() is asynchronous. It puts the record into a buffer and returns right away with a Future. You can wait on that future or pass a callback to learn the outcome later. Inside, the record goes through a small pipeline:

  1. Metadata. The producer needs to know the topic's partitions (chapter 2). The first send to a topic may wait for metadata.
  2. Serializer. Your key and value objects become bytes via key.serializer and value.serializer, e.g. StringSerializer.
  3. Partitioner. Picks the partition. If you set one explicitly, it's used. Otherwise, with a key, it's murmur2(key bytes) made positive, mod the partition count. Without a key, the producer uses a sticky partition that changes after at least batch.size bytes have been sent to it.
  4. Record accumulator. The record is appended to the open batch for that partition, in a memory pool of size buffer.memory.
  5. Sender thread. A background I/O thread (named kafka-producer-network-thread…) collects ready batches, groups them per leader broker into produce requests, and sends them.

A batch is "ready" when it's full (batch.size, default 16384 bytes) or it has waited linger.ms. Since Kafka 4.0 the default linger.ms is 5 ms (it was 0 before). The source says the change was made because larger batches usually give similar or lower latency despite the extra wait.

▶ Producer pipeline pick a linger.ms, send a burst, count the requests
linger.ms:
send()
serialize → partitionkey hash, mod 3
Record accumulator
buffer.memory used
Sender thread → broker
Choose a linger.ms and press ▶. A batch leaves when it's full or its linger time is up.
Illustrative model: 12 records arrive 1 ms apart, then one straggler for P0 at 20 ms; a batch is "full" at 4 records, and each batch is shown as its own request (a real request carries one batch per partition for the same broker). Real producers also batch with linger.ms=0 whenever records pile up while a request is in flight. Only the rules are real: sent when full or when linger.ms expires, and send() blocks when the buffer is full.

acks: when is a write "done"?

The acks setting decides how much confirmation the producer waits for. acks=0: don't wait at all. acks=1: the leader wrote it to its own log. acks=all (same as -1, and the default): the leader waits until all in-sync replicas have it. Chapter 5 shows what each one can lose.

Producers also retry automatically (retries defaults to Integer.MAX_VALUE, bounded in time by delivery.timeout.ms, default 120000 ms). Retries raise a problem: if an ack is lost, the retry could write the record twice. That's why enable.idempotence is true by default. The broker then drops the duplicate retry (chapter 12 shows how). Idempotence needs acks=all. If you set a conflicting value like acks=1 without explicitly enabling idempotence, it is quietly turned off.

ConfigDefaultWhat it doesUse it when
bootstrap.servers(required)Initial brokers to contactAlways; list 2–3 brokers
key.serializer / value.serializer(required)Turn objects into bytesAlways; match your data format
acksallHow many replicas must confirmKeep all unless losing data is acceptable
linger.ms5Max wait to fill a batchRaise (10–100) for throughput; lower for latency at low volume
batch.size16384 bytesMax size of one partition's batchRaise with high volume or compression
buffer.memory33554432 (32 MiB)Total memory for unsent recordsRaise if send() blocks under bursts
max.block.ms60000How long send() may block (metadata, full buffer)Lower if your caller must never hang long
enable.idempotencetrueBroker de-duplicates producer retriesKeep on; needs acks=all
compression.typenoneCompress whole batchesBig or repetitive payloads (gzip, snappy, lz4, zstd)
⚠️ Watch it!

Fire-and-forget by accident. Calling producer.send(record) and ignoring the returned future means you never find out if the write failed. Always pass a callback, or at least log failures. And close the producer on shutdown: the Javadoc warns that not closing it leaks its buffer and I/O thread, and close() is also what waits for the sending of all incomplete requests to finish.

❓ There are no dumb questions
Q:
Doesn't waiting 5 ms make every message 5 ms slower?
A:
At most 5 ms, and only when traffic is light. Under load, batches fill up and leave before the linger time is up. Fewer, bigger requests also mean less work for the broker, which often makes the end-to-end latency lower.
Q:
Should I create a producer per thread or per message?
A:
Neither. KafkaProducer is thread-safe, and the Javadoc says sharing one instance across threads is generally faster than having several. One producer per message is a classic performance killer.
🔬 Go deeper: the classes behind send()

KafkaProducer.doSend() waits for metadata (waitOnMetadata), serializes key and value, computes the partition, checks the estimated size against max.request.size (default 1048576), then calls RecordAccumulator.append(). The accumulator keeps a deque of ProducerBatches per partition. If the append filled a batch or opened a new one, it wakes up the Sender. The sender runs on its own KafkaThread. It drains ready batches per node and sends produce requests, with at most max.in.flight.requests.per.connection (default 5) unacknowledged requests per connection.

Key hashing lives in BuiltInPartitioner.partitionForKey(): Utils.toPositive(Utils.murmur2(serializedKey)) % numPartitions. For keyless records the built-in partitioner is sticky, and with partitioner.adaptive.partitioning.enable=true (default) it sends more data to partitions on faster brokers. partitioner.ignore.keys=true makes it ignore keys completely. The only other shipped strategy is RoundRobinPartitioner; its docs note a known uneven-distribution issue (KAFKA-9965).

📌 Bullet points
  • send() is asynchronous: serialize → partition → accumulate in a per-partition batch → a background sender ships it.
  • A batch goes out when it's full (batch.size 16384) or after linger.ms (default 5 since 4.0).
  • Keyed records: murmur2 hash mod partition count. Keyless: sticky partition.
  • Defaults are safe: acks=all, enable.idempotence=true, retries bounded by delivery.timeout.ms (120 s).
  • A full buffer makes send() block up to max.block.ms (60 s), then fail.
  • Use one shared producer, check results via callback, and always close() it.

Consumer basics

the reader keeps the bookmark, not the library

A consumer reads records from topics. In the archive, a consumer is a clerk who reads a ledger book line by line and does something with each entry: updates a balance, sends an email, copies it into a data warehouse.

Pull, not push

Kafka consumers pull. The consumer sends fetch requests to the partition leaders and says "give me records starting at offset N". The broker never pushes data at a consumer. The design docs explain why: with push, a slow consumer gets flooded when production outpaces it. With pull, a slow consumer just falls behind and catches up when it can. Pull also batches naturally, because each fetch takes everything available after the position (up to a size limit).

What if there's nothing new? The consumer doesn't spin in a tight loop. A fetch can wait on the broker (a "long poll") until at least fetch.min.bytes (default 1 byte) is available or fetch.max.wait.ms (default 500 ms) has passed.

The poll loop

Your code calls consumer.poll(timeout) in a loop. Each call returns a batch of records (at most max.poll.records, default 500), you process them, then you poll again:

consumer.subscribe(List.of("payments"));
while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> r : records) {
        handle(r.key(), r.value());          // your business logic
    }
    // with enable.auto.commit=true (default), progress is committed in the background
}

Two rules to remember from the Javadoc. The KafkaConsumer is not thread-safe: use one instance per thread. And you must call poll() often enough. If more than max.poll.interval.ms (default 300000 ms, 5 minutes) passes between polls, the consumer is considered failed and its partitions are given to someone else.

Position vs committed offset: the bookmark

A consumer tracks two numbers per partition:

In the archive, the committed offset is a bookmark the clerk slips into the book and registers at the front desk. The position is just where the clerk's finger is right now. If the clerk faints, the finger's position is lost, but the bookmark stays.

Where do bookmarks live? In an internal, compacted Kafka topic called __consumer_offsets. Each group has one broker acting as its group coordinator. The consumer finds it with a FindCoordinator request and sends its offset commits there. The coordinator writes each commit to __consumer_offsets and replies only after all replicas of that topic have it. By default the topic has 50 partitions (offsets.topic.num.partitions) and replication factor 3 (offsets.topic.replication.factor).

By default (enable.auto.commit=true), the consumer commits its position automatically every auto.commit.interval.ms (default 5000 ms). You can switch that off and call commitSync() or commitAsync() yourself.

The order of process and commit decides your delivery guarantee. Commit then process: a crash in between skips records (at-most-once). Process then commit: a crash in between re-delivers records (at-least-once). At-least-once is Kafka's default promise. So write handlers that can safely see the same record twice.

Consumer groups: a team of clerks

One clerk can't keep up with a big series. So consumers join a consumer group by sharing the same group.id. Kafka splits the topic's partitions among the group's members so that each partition is read by exactly one consumer in the group. A topic with four partitions and two consumers gives each consumer two partitions. When a consumer joins, leaves or crashes, the partitions are redistributed. That's called a rebalance.

Different groups are completely independent. Each group gets all the records and keeps its own bookmarks. That covers both classic messaging styles. Queue-like: all workers in one group share the load. Pub-sub-like: each application uses its own group and sees everything.

▶ Consumer group add and remove clerks; watch partitions move with their bookmarks
PartitionOwnerCommittedEndLag
Topic payments has 4 partitions. Group billing has one consumer.
The assignment is a simple even split for illustration. The real assignor depends on group.protocol and assignor settings (chapter 11). Offsets and lag numbers are illustrative. The rule that each partition has exactly one owner within the group is real.
❓ There are no dumb questions
Q:
My brand-new group started and read nothing, even though the topic is full of data. Broken?
A:
No. That's auto.offset.reset. When a group has no committed offset for a partition (or the offset no longer exists), this setting decides where to start. The default is latest: only records produced from now on. Use earliest to start from the oldest available record, none to get an exception, or by_duration:<ISO-8601 duration> to start that far back in time.
Q:
Can I have more consumers than partitions to go faster?
A:
Not within one group. The extra consumers sit idle, because a partition can't be shared inside a group. The partition count is the ceiling on a group's parallelism. (Share groups, production-ready since Kafka 4.2, let several consumers share one partition queue-style. Chapter 18 covers them.)
✏️ Sharpen your pencil

Consume payments as group billing, stop it with Ctrl-C, then inspect the group:

bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
  --topic payments --group billing --from-beginning

bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group billing

1) What do CURRENT-OFFSET, LOG-END-OFFSET and LAG mean? 2) Run the console consumer again with the same group and --from-beginning. Why don't the old records come back? 3) How would you replay them?

Answer

1) Committed offset, the partition's latest readable offset (its high watermark), and their difference: how far behind the group is. 2) --from-beginning only applies when the consumer doesn't already have an established offset. The group committed one, so it resumes there. 3) With all group members stopped: kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group billing --topic payments --reset-offsets --to-earliest --execute. Without --execute it's a dry run that only shows the plan.

SymptomLikely causeFix
LAG keeps growingConsumers process slower than producers writeSpeed up handling, add consumers (up to the partition count), add partitions
One consumer gets nothingMore consumers than partitionsExpected. Remove it or add partitions
Constant rebalances, commits fail after themProcessing one poll's batch takes longer than max.poll.interval.msLower max.poll.records, speed up handling, or raise the interval
New group sees no old dataauto.offset.reset=latest (default)Set earliest before the group's first start, or reset offsets
Duplicates after a crashAt-least-once: processed but not yet committedMake processing idempotent (dedupe by key or ID)
🤓 Deep dive

Two group protocols. group.protocol defaults to classic. The next-generation protocol from KIP-848 (group.protocol=consumer) became generally available in Kafka 4.0. It moves partition assignment to the broker and replaces the stop-the-world rebalance with incremental, heartbeat-driven reconciliation. Chapter 11 races them against each other.

Bookmarks can expire. Committed offsets aren't kept forever. Once a group has no members, its offsets are discarded after offsets.retention.minutes (default 10080 minutes = 7 days). When it comes back, auto.offset.reset applies again.

🔬 Go deeper: where the numbers live in the client

In the Java client, KafkaConsumer delegates to one of two implementations: ClassicKafkaConsumer (classic protocol, with ConsumerCoordinator for group membership) or AsyncKafkaConsumer (the KIP-848 consumer protocol, with a background network thread). Both keep per-partition state (assigned partitions, fetch position, paused flags) in SubscriptionState.

Why can offset tracking be this cheap? Traditional brokers keep per-message state ("sent", "acknowledged", "consumed"), and they must solve lost-ack and double-delivery problems on the server. Kafka's design gives each partition to exactly one consumer per group, so all the state is one integer per partition per group, checkpointed periodically. The side effect is a feature: you can rewind (seek or reset offsets) and re-process after fixing a bug. With a classic queue that's impossible, because the messages are gone.

📌 Bullet points
  • Consumers pull with fetch requests from a given offset; long polling avoids busy loops.
  • Position = next offset to read (in memory). Committed offset = saved bookmark in __consumer_offsets.
  • Auto-commit is on by default (every 5 s). Process-then-commit gives at-least-once.
  • A consumer group splits partitions: each partition has exactly one owner per group. Extra consumers idle.
  • Different groups are independent and each sees every record.
  • auto.offset.reset defaults to latest. Keep polling within max.poll.interval.ms (5 min).

Replication basics

one book burns, the photocopies take over

Disks die. Machines reboot. A whole rack loses power. If each partition lived on only one broker, any of those would make data unavailable or lose it. So Kafka keeps replicas: copies of each partition on several brokers. In the archive, every ledger book has photocopies kept in other branches, and the copies are updated as new lines are written.

Leader and followers

The number of copies is the topic's replication factor (RF). You set it per topic, and it includes the original. One replica is the leader. It receives all writes (and normally all reads). The others are followers. They fetch new records from the leader, just like a consumer would, and append them to their own copy. All replicas have the same records at the same offsets in the same order. A follower may just be a few records behind at the tail.

The broker-wide default for new topics is default.replication.factor=1, which means no copies at all. The docs call RF 3 a common production setting. Set it explicitly (for example --replication-factor 3).

In-sync replicas (ISR)

Not every follower is always up to date. The leader tracks the in-sync replicas (ISR): the replicas that are caught up. A replica is in sync if its broker has a live session with the controller and it keeps up with the leader. A follower that can't catch up to the leader's log end within replica.lag.time.max.ms (default 30000 ms) is removed from the ISR. It rejoins after catching up fully.

A record is committed when all current ISR members have it and the ISR has at least min.insync.replicas members (more on that setting below). Only committed records are handed to consumers. If the leader dies, the controller picks the new leader from the ISR. Every ISR member has every committed record, so nothing committed is lost. With RF f+1, Kafka can survive f failed replicas without losing committed records.

acks and min.insync.replicas: your durability dial

The producer's acks decides when it hears "done":

There's a trap in acks=all. "All" means all current ISR members, and the ISR can shrink. If only the leader is left in the ISR, acks=all is effectively acks=1. The topic setting min.insync.replicas (default 1) closes the gap. With acks=all, if the ISR is smaller than this number, the write is rejected with NotEnoughReplicas (or NotEnoughReplicasAfterAppend) instead of being accepted with too few copies. The Kafka docs give the typical recipe: RF 3, min.insync.replicas=2, acks=all. A majority must persist each write, and you can still lose one broker and keep writing.

▶ Replica set choose acks, write, then kill the leader at the worst moment
acks:
Partition payments-0, RF 3, min.insync.replicas=2. Leader on B1.
Simplified: one partition, followers fetch in one visible step, and the "kill" happens at the exact moment that hurts most. Record values are illustrative. The acks semantics, the "new leader comes from the ISR" rule and the NotEnoughReplicas rejection follow the producer and topic config docs.
🔥 Fireside chat
acks=1
I'm fast. The leader writes, I say "done", everyone's happy.
acks=all
Until the leader's disk dies a millisecond later and the new leader has never heard of your record. Your producer was told "done" for data that no longer exists.
acks=1
You're slower, though.
acks=all
A little. I wait for the in-sync followers. With batching, that wait is paid once per batch, not once per record, so it's usually a small price.
⚠️ Watch it!

min.insync.replicas equal to RF (e.g. 3 with RF 3) sounds safe but means any single broker restart blocks all acks=all writes to that partition. Use RF − 1. The other trap is RF 1 in production: the default. One lost disk and the data is gone, whatever acks says.

✏️ Sharpen your pencil

A topic has RF 3 and min.insync.replicas=2. Producers use acks=all. For each case, can producers write, and can committed data be lost? a) One broker down. b) Two brokers down. c) Same as b) but the producer uses acks=1.

Answer

a) Writes succeed (ISR = 2 ≥ 2); every acknowledged record is on two brokers. b) acks=all writes are rejected with NotEnoughReplicas (ISR = 1 < 2). The partition stays readable from the leader. No acknowledged data at risk, but it's unavailable for writes. c) Writes are accepted because min.insync.replicas is only checked for acks=all. But they are not committed, so consumers can't see them until the ISR grows back to 2. Meanwhile each record lives on one broker. Lose it and those records are gone.

To try it for real, create a topic with the setting: bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic safe --partitions 3 --replication-factor 3 --config min.insync.replicas=2 (needs 3 brokers).

🤓 Deep dive

Why not majority voting? Many systems (Raft, ZooKeeper's Zab) commit on a majority: 2f+1 replicas to survive f failures. Kafka's ISR approach survives f failures with f+1 replicas, because it waits for all in-sync replicas and elects only from them. The design docs argue this matters for bulk data. Five copies of every byte for two-failure tolerance would cost 5× the disk and a fifth of the throughput. The cost is that a slow in-sync follower slows commits until it drops out of the ISR.

When every replica dies. Kafka then waits for an ISR member to come back instead of electing a stale replica. That's unclean.leader.election.enable=false, the default. Turning it on trades consistency (possible loss of committed data) for availability.

🔬 Go deeper: who does what on the broker

On each broker, ReplicaManager owns the local replicas and handles produce and fetch requests. Each partition is a kafka.cluster.Partition object that knows its leader, ISR and log (UnifiedLog). Followers replicate by sending ordinary fetch requests to the leader. That's how a follower's progress reaches the leader, and how the leader decides when a record is committed (the high watermark, chapter 10). ISR changes are persisted in the cluster metadata through the controller, so any ISR member can be elected leader.

The design docs also say the protocol doesn't require crashed replicas to keep all their data. A replica that lost unflushed data in a crash must fully re-sync before it rejoins the ISR. That's why Kafka doesn't need an fsync per write for correctness. And with the Eligible Leader Replicas feature (KIP-966, ch. 16), the meaning of min.insync.replicas changes a bit; the topic config docs point there.

📌 Bullet points
  • Each partition has RF replicas: one leader (takes writes) and followers that fetch from it.
  • The ISR is the set of caught-up replicas; lagging longer than replica.lag.time.max.ms (30 s) drops you out.
  • Committed = on all ISR members (with ISR ≥ min.insync.replicas). Consumers only see committed records; new leaders come from the ISR.
  • acks=0/1 can lose acknowledged writes; acks=all (default) can't while one ISR member survives.
  • min.insync.replicas (default 1) rejects acks=all writes when the ISR is too small.
  • Typical recipe: RF 3, min.insync.replicas 2, acks all. Default RF is 1, so set it explicitly.

Retention basics

the shredder works by the volume, not by the line

Reading doesn't delete, so something else must, or disks fill up forever. That's retention. The archive has a policy like "we keep ledger entries for seven days". Old records are removed based on time or size, never based on whether someone read them. A consumer that is days behind can still catch up, as long as it stays inside the retention window.

Two cleanup policies

Each topic has a cleanup.policy:

The defaults

ConfigDefaultWhat it doesUse it when
cleanup.policydeleteDelete old segments, or compact by keycompact for latest-value-per-key topics
retention.ms604800000 (7 days)Max age before a segment may be deleted; -1 = no time limitMatch how far back consumers must be able to re-read
retention.bytes-1 (no limit)Max size per partition before old segments goCap disk use on busy topics
segment.bytes1073741824 (1 GiB)Size at which a new segment file startsSmaller on low-volume topics so retention can act sooner
segment.ms604800000 (7 days)Age at which a new segment starts, even if not fullSame reason as above
log.retention.check.interval.ms (broker)300000 (5 min)How often the broker looks for deletable segmentsRarely changed

The broker-level equivalents (log.retention.hours = 168, log.retention.bytes, log.cleanup.policy…) set the default for every topic. The topic-level settings override them for one topic.

Deletion happens per segment

Remember from chapter 1 that a partition is a series of segment files (the book's volumes). Time and size retention never delete single records. They delete whole segments, oldest first. For time-based retention, the largest timestamp in the segment counts. A segment goes only when even its newest record is older than retention.ms. So data often lives longer than retention.ms: up to about one extra segment's worth of time, plus the time until the next retention check.

If both limits are set, a segment is deleted when either the time or the size rule says so.

▶ The shredder advance the calendar; then leave a consumer behind
retention.ms = 7 days. For the demo, a new segment is rolled every day.
Illustrative: one segment per day (really: a new segment at segment.bytes 1 GiB or segment.ms 7 days, whichever comes first), 100 records per segment, and deletion as soon as the day turns (really: at the next retention check). The rules are real: whole segments only, judged by their newest record's timestamp, and an out-of-range consumer falls back to auto.offset.reset.
⚠️ Watch it!

A slow consumer can lose data silently. If a consumer's committed offset points into a segment that has already been deleted, the offset no longer exists. The consumer then applies auto.offset.reset. With the default latest, it jumps to the end and skips everything in between, with only a log line to show for it. For consumers that must not skip data, alert on lag well before the retention window runs out. You can also set auto.offset.reset=earliest (re-read what's left) or none (fail loudly).

✏️ Sharpen your pencil

Size a topic. A topic receives 2 MB/s on average, has 6 partitions and RF 3, and must keep 3 days of data. 1) Roughly how much disk does it need across the cluster? 2) Set its retention to 3 days. 3) What retention.bytes would cap each partition at the same amount if traffic is spread evenly?

Answer

1) 2 MB/s × 86 400 s/day × 3 days ≈ 518 GB of data, × RF 3 ≈ 1.56 TB across the cluster, plus headroom for the extra segment per partition and traffic peaks.

bin/kafka-configs.sh --bootstrap-server localhost:9092 --alter \
  --entity-type topics --entity-name payments --add-config retention.ms=259200000

3) retention.bytes is per partition: 518 GB ÷ 6 ≈ 86 GB. Leaving it at -1 is also fine if the time limit is enough.

❓ There are no dumb questions
Q:
Isn't keeping a week of data expensive and slow?
A:
Expensive in disk, yes. Slow, no. Kafka only appends and reads sequentially, so the Kafka docs say its performance is effectively constant with respect to data size. Storing data for a long time is fine. (Tiered storage, chapter 17, moves old segments to cheap object storage.)
Q:
Can I set infinite retention?
A:
Yes: retention.ms=-1 with retention.bytes=-1. Some teams keep full event histories that way, and compacted topics do something similar without growing forever.
🤓 Deep dive

retention.bytes is a floor, not a ceiling. In UnifiedLog.deleteRetentionSizeBreachedSegments, the broker computes how far the partition is over the limit and deletes an old segment only if the partition is still at least retention.bytes after removing it. So a partition hovers somewhere between the limit and the limit plus one segment. With 1 GiB segments and a small retention.bytes, that difference is big.

Future timestamps block deletion. Time retention compares the broker clock with each segment's largest record timestamp. A producer with a clock set years ahead makes that segment "not old yet" for years, and the broker logs a warning about future timestamps.

🔬 Go deeper: how deletion stays safe for readers

A background task in the log manager runs every log.retention.check.interval.ms. For each log it calls UnifiedLog.deleteOldSegments(), which (for the delete policy) runs deleteRetentionSizeBreachedSegments and deleteRetentionMsBreachedSegments. These walk segments oldest-first and stop at the first one that isn't deletable, so deletion is always a prefix of the log. The log start offset moves forward before files are removed. A partition always keeps at least one segment: if every segment would go, a fresh empty one is rolled first.

Readers aren't blocked. The implementation docs describe a copy-on-write segment list, so a fetch doing a binary search over segments sees an immutable snapshot while deletions proceed. A consumer asking for an offset below the new log start offset gets an OFFSET_OUT_OF_RANGE error (code 1), and that's what triggers auto.offset.reset.

📌 Bullet points
  • Retention removes data by time or size, never because it was consumed.
  • cleanup.policy: delete (default), compact (latest per key), or both.
  • Defaults: retention.ms 7 days, retention.bytes -1 (unlimited, and per partition).
  • Deletion is by whole segment, judged by the segment's newest timestamp, so data can outlive retention.ms.
  • A consumer that falls behind retention gets out-of-range and follows auto.offset.reset. latest silently skips.
  • Size a topic: throughput × retention × RF, plus headroom.
Part II

How it really works

Open the hood: what a partition looks like on disk, what bytes travel on the wire, how the controllers keep the city registry, how the high watermark moves, how groups rebalance, and how the producer avoids duplicates. After this part the Part I rules stop being rules and start being consequences.

The storage engine: segments, indexes and the page cache

a ledger book is really a shelf of thin volumes

In Part I a partition was one bound ledger book. Time to confess: nobody binds a book that never ends. In the archive, each "book" is really a shelf of thin volumes. Clerks only ever write in the last volume; when it gets thick, they shelve it and start a fresh one. Kafka calls those volumes segments.

On disk, a topic called my-topic with two partitions is simply two directories, my-topic-0 and my-topic-1. Inside each directory the segment files are named after the offset of the first record they contain, zero-padded to 20 digits, so the first one is always 00000000000000000000.log. That file name is the segment's base offset.

A segment is not one file but a small family that shares the same base-offset name:

FileWhat it holdsWhy it exists
.logThe record batches, in the same binary format the producer sent (the broker stamps offsets and leader epoch; Chapter 8)The data. Appends only ever go to the newest (active) segment
.indexSparse map: offset → byte position in the .log. 8-byte entries (4-byte relative offset + 4-byte position)Jump close to an offset without scanning the whole file
.timeindexSparse map: timestamp → offset. 12-byte entries (8-byte timestamp + 4-byte relative offset)"Give me the first record after 09:00" (offsetsForTimes, time-based retention)
.txnindexAborted transactions that touch this segmentLets read_committed consumers skip aborted data (Part III)
.snapshotProducer-state snapshot (producer ids, epochs, last sequences), taken when a segment rolls and named after the new segment's base offsetRebuild idempotence state quickly after restart (Chapter 12)

Next to them live a couple of per-partition files such as leader-epoch-checkpoint and partition.metadata. You will meet the leader-epoch file again in Part III.

Rolling: when does a new volume start?

The active segment is rolled (closed, and a new one started) when it would grow past segment.bytes (default 1 GiB), when it is older than segment.ms (default 7 days), or when one of its index files is full. Why care? Because retention and compaction work one whole segment at a time. Time-based retention looks at the largest timestamp in a segment. If a topic has retention.ms of one hour but keeps writing into the same 1 GiB segment for a day, that segment's newest record is always fresh, so its oldest records stay on disk far longer than an hour. Records leave in whole volumes, never one line at a time.

Reading: three hops to any offset

A consumer says "give me offset 2 600". The broker does three cheap hops. 1. Pick the segment: segments are kept in a sorted map keyed by base offset, and the broker takes the floor entry (the largest base offset ≤ 2 600). 2. Look in that segment's .index (memory-mapped, binary-searched) for the largest indexed offset ≤ 2 600, which gives a byte position. 3. Read the .log forward from that position until it meets the batch containing 2 600. Try it:

▶ Finding an offset on disk type an offset, press Find · then break the index
.index of chosen segment (offset → position)
.log scan (batches read)
Type an offset between 0 and 3 699 and press Find.
Illustrative numbers: 4 segments, batches of 50 records, one index entry roughly every few batches (in reality: about one entry per index.interval.bytes = 4096 bytes of appended data). Real index entries store offsets relative to the segment's base offset; the figure shows absolute offsets for readability.

Notice what the index is not: it is not an entry per record. It is sparse. The broker adds an entry only after roughly index.interval.bytes (default 4096) of data has been appended since the last one. So the index stays tiny and hot in memory, and the forward scan is at most a few kilobytes. That is the whole trick: a binary search over a small array plus a short sequential read.

❓ There are no dumb questions
Q:
Why store offsets relative to the base offset in the index?
A:
So they fit in 4 bytes. Absolute offsets are 64-bit numbers; relative ones only need to span one segment. That shrinks each index entry from 12 bytes to 8. It is also why a segment rolls if a new offset can't be expressed relative to its base.
Q:
If the broker crashes, can a half-written index corrupt my data?
A:
No. The index files aren't checksummed at all. They're treated as disposable and rebuilt from the .log if needed. The .log itself is protected by a CRC on every batch, and recovery truncates at the last valid batch.
Q:
Does Kafka keep records in the JVM heap to be fast?
A:
Deliberately not. It writes into the OS page cache and lets the kernel do the caching. More on that below.

Why "just files" is fast: page cache, sequential I/O, zero-copy

Kafka's design notes are blunt about it: "don't fear the filesystem". Linear reads and writes are the pattern disks and operating systems handle best (the docs quote about 600 MB/s linear versus about 100 kB/s random writes on one old disk array). The OS already spends all free memory on the page cache, so Kafka appends into that cache and doesn't build its own heap cache. That avoids storing everything twice, avoids GC pressure, and the cache stays warm when the broker process restarts.

On the way out, the broker uses sendfile (Java's FileChannel.transferTo): bytes go from page cache to the socket without a round-trip through user space. The usual path is four copies and two system calls; with sendfile only the final copy to the NIC buffer remains. Because the on-disk format equals the wire format, there is nothing to convert. The docs point out that when consumers are caught up, you often see no disk reads at all, because everything is served from cache.

⚠️ Watch it!

TLS switches zero-copy off. Encryption happens in user space, so with SSL enabled Kafka can't use sendfile. Every byte is read into the JVM, encrypted and written out. That's the right trade for security, but budget CPU for it and don't compare plaintext benchmarks with TLS production.

Lagging consumers evict the cache. A consumer re-reading last week's data pulls cold segments from disk into page cache and can push out the hot tail that everyone else is reading. If "one replay job slowed down everyone", this is usually why.

The fsync question

By default Kafka never forces an fsync on a schedule: flush.messages and flush.ms both default to Long.MAX_VALUE. An acknowledged write may sit only in page cache. Is that reckless? No. Kafka gets durability from replication, not from the disk of one machine. A write acknowledged with acks=all is in the page cache of every in-sync replica, and a crashed replica must fully re-sync before it may rejoin the ISR (Chapter 10). The design notes put the cost of fsync-per-write at "two to three orders of magnitude" of throughput. The config docs literally say "we recommend you not set this".

Config (topic / broker)DefaultWhat it doesUse it when
segment.bytes / log.segment.bytes1073741824 (1 GiB)Max size of one segment fileSmaller for compacted or short-retention topics so old data becomes eligible sooner
segment.ms / log.roll.hours604800000 (7 d) / 168Force a roll after this age even if not fullLow-volume topics with short retention or compaction lag goals
segment.jitter.ms0Random jitter subtracted from the roll timeMany partitions created together that would all roll at once
index.interval.bytes4096Bytes appended between offset-index entriesAlmost never. Smaller means bigger index and shorter scans
segment.index.bytes10485760 (10 MiB)Preallocated max size of each index; full index forces a rollRarely. "You generally should not need to change this"
flush.messages / flush.msLong.MAX_VALUEForce fsync every N messages / N msSingle-replica setups that must survive power loss (accept the throughput hit)
preallocatefalsePreallocate the file on disk when creating a segmentSpecific filesystems where preallocation helps
✏️ Sharpen your pencil

On a local 4.3 quickstart node (its log.dirs is /tmp/kraft-combined-logs), create a topic with tiny 1 MiB segments, write some data and look at the files. How many segment families do you expect after ~3 MB of data? What does kafka-dump-log.sh print per batch?

bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic seg-demo \
  --partitions 1 --config segment.bytes=1048576
bin/kafka-producer-perf-test.sh --topic seg-demo --num-records 30000 --record-size 100 \
  --throughput -1 --bootstrap-server localhost:9092
ls -l /tmp/kraft-combined-logs/seg-demo-0/
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/seg-demo-0/00000000000000000000.log | head
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/seg-demo-0/00000000000000000000.index | head
Answer

About 3 MB of payload plus batch overhead means roughly three or four .log files, each with its own .index and .timeindex (1 MiB is the minimum allowed segment.bytes). Each file name is the base offset of the first record inside. The .log dump prints one line per batch, like baseOffset: … lastOffset: … count: … baseSequence: … lastSequence: … producerId: … producerEpoch: … partitionLeaderEpoch: … isTransactional: … isControl: … deleteHorizonMs: … position: … CreateTime: … size: … magic: 2 compresscodec: none crc: … isvalid: true. The .index dump prints offset: … position: … pairs. An entry is added once more than 4 096 bytes have accumulated since the last one, so with the perf test's full ~16 KiB batches (default batch.size 16384) you'll see roughly one entry per batch. That's the sparse index you just animated.

🔬 Go deeper: the classes behind a partition directory

UnifiedLog (storage module) is the per-partition log object that the broker's ReplicaManager/Partition talk to. It owns a LocalLog, which holds LogSegments: a ConcurrentSkipListMap<Long, LogSegment> keyed by base offset. The "pick a segment" hop is literally floorEntry(offset) on that map. Readers get a consistent view while retention deletes segments. The docs describe this as a copy-on-write style segment list so a binary search can run on a stable snapshot.

Each LogSegment owns a FileRecords (the .log), a lazily-loaded OffsetIndex and TimeIndex (both extend AbstractIndex and are memory-mapped with a MappedByteBuffer) and a TransactionIndex. In LogSegment.append, the segment keeps a bytesSinceLastIndexEntry counter. Once it exceeds indexIntervalBytes, it appends (batch.lastOffset, physicalPosition) to the offset index and maybe a time-index entry. Index entries point at batches, not individual records.

LogSegment.shouldRoll returns true when the segment would exceed maxSegmentBytes, when a non-empty segment has waited longer than segment.ms − rollJitterMs, when the offset or time index is full, or when the new batch's max offset can't be converted to a relative offset. On the network side, PlaintextTransportLayer.transferFrom calls fileChannel.transferTo(position, count, socketChannel). That's the zero-copy path.

On startup, recovery walks the newest segment(s) and validates every batch by size and CRC. On corruption, the log is truncated to the last valid offset. The docs explain why both truncation and "garbage appended" must be handled: the OS doesn't order inode-size updates and data-block writes.

📌 Bullet points
  • A partition is a directory of segments; each segment is .log + .index + .timeindex (+ .txnindex, .snapshot), all named by base offset.
  • Rolls happen at segment.bytes (1 GiB) or segment.ms (7 d) or when an index fills. Retention deletes whole segments, never single records.
  • Offset lookup = floor segment by base offset → binary search in the sparse, memory-mapped index → short forward scan.
  • Speed comes from sequential I/O, the OS page cache and sendfile zero-copy (not available with TLS).
  • No fsync by default. Durability comes from replication to the ISR, not from one disk.

The record batch format (v2) and compression

one envelope, many letters, one stamp

Kafka never really stores "a message". It stores record batches. Even a single send() becomes a batch of one. In archive terms, a batch is a sealed envelope delivered to the ledger office: the clerk stamps the envelope once (offset, checksum, producer id) instead of stamping every letter inside. This is the "message set" idea from the design docs. Group messages so that network round-trips, disk writes and checksums are paid per batch, not per record.

The same bytes travel producer → broker → disk → consumer unchanged whenever possible. That's why the format is a stable, versioned interface: the current magic value is 2 (introduced in Kafka 0.11; older formats are documented only for history). Here is the envelope, field by field. Click any field.

▶ Anatomy of a record batch click a field · tour the header · compress · flip a bit
batch size
661 B
Header = 61 bytes, then the records. Click a field, or press ▶.
Field names, types and the 61-byte header size come from the v2 format. Record sizes and the compression ratio are illustrative: real ratios depend entirely on your data and batch size.

Records inside the envelope: deltas and varints

Each record inside the batch is tiny on purpose. It has a varint length, one unused attributes byte, a timestampDelta (varlong) and an offsetDelta (varint), both relative to the batch's base values, then key, value and headers, each prefixed by a varint length. Varints use the same encoding as Protobuf, so small numbers cost one byte. A record whose offset is baseOffset + 3 and that was produced 2 ms after the first costs one byte for each delta instead of 8 + 8.

Headers (key/value pairs of metadata, like tracing ids) are part of every record. Their key is never null, the value may be null, and their order is preserved from producer to consumer.

🧠 Brain power

The producer doesn't know the final offsets, because the leader assigns them. Yet the batch has a baseOffset field and every record carries an offsetDelta. How can the broker assign offsets to a compressed batch without decompressing and recompressing it?

Think, then open

Only baseOffset (in the uncompressed header) changes. The deltas inside stay valid because they are relative. The broker writes a new base offset in the header and recomputes nothing inside. It doesn't even recompute the CRC for this, because the CRC covers the bytes from attributes onward, and baseOffset, batchLength and partitionLeaderEpoch sit before it. That's exactly why partitionLeaderEpoch was placed outside the CRC: the leader stamps it on every batch it receives.

Compression: end-to-end and batch-level

Compressing one message at a time compresses badly: the redundancy is between messages (the same JSON field names, the same user agents). So Kafka compresses the records section of a whole batch. The codec is recorded in bits 0–2 of attributes: 0 none, 1 gzip, 2 snappy, 3 lz4, 4 zstd. The compressed data follows directly after recordsCount.

And it stays compressed. The design docs describe the path: the producer compresses; the broker decompresses only to validate (for example, that the record count matches the header); the batch is written to disk still compressed; it is sent to consumers compressed; the consumer decompresses. Compression CPU is paid at the edges, and every broker hop, disk byte and replica fetch benefits.

ConfigDefaultWhat it doesUse it when
compression.type (producer)noneCodec for batches this producer creates: none, gzip, snappy, lz4, zstdAlmost always worth turning on for text/JSON; combine with batching (linger.ms, batch.size) for better ratios
compression.type (topic / broker)producerFinal codec on disk. producer keeps whatever the producer used; uncompressed or a codec name forces that oneLeave at producer unless you must enforce a codec on disk
compression.gzip.levelgzip default (−1)gzip level 1–9 (or −1 for gzip's default)Trading CPU for ratio with gzip
compression.lz4.level9lz4 level 1–17Tuning lz4
compression.zstd.level3zstd level (up to 22)Tuning zstd; higher = smaller, slower

The level configs exist both on the producer and as topic configs.

⚠️ Watch it!

A topic codec that differs from the producer's codec makes the broker recompress every batch. The broker can only assign offsets "in place" when the source and target codecs match. Otherwise it decompresses, validates and rebuilds the batch with the topic's codec, which costs CPU on the leader for every produce request. If you set compression.type=zstd on a topic, make the producers send zstd too, or keep the topic at producer.

🔥 Fireside chat
Single record
I'm small and simple. Why do I have to wait in an envelope with strangers?
Batch
Because you share my stamp. One CRC, one producer id, one base timestamp, one network round-trip for all of you. Alone, your header would be bigger than you.
Single record
And compression?
Batch
Compressed alone, you look like noise. Compressed with 500 siblings who share your field names, you shrink a lot. That's why linger.ms and batch.size affect disk usage too, not just latency.
✏️ Sharpen your pencil

Produce the same 20 000 records twice, once uncompressed and once with zstd, into two topics. Compare the directory sizes and look at the compresscodec field in the dump. Predict first: which one is smaller, and does the consumer need any config to read the compressed topic?

for c in none zstd; do
  bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic comp-$c --partitions 1
  bin/kafka-producer-perf-test.sh --topic comp-$c --num-records 20000 --record-size 200 \
    --throughput -1 --bootstrap-server localhost:9092 \
    --command-property compression.type=$c linger.ms=20
done
du -sh /tmp/kraft-combined-logs/comp-*-0
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/comp-zstd-0/00000000000000000000.log | head -3
Answer

The perf tool's default payload is random uppercase letters (A–Z), so zstd shrinks it but far less than real JSON with repeated field names. Measure your own data, don't assume. Whatever the ratio, compresscodec: zstd appears on each batch line, and the consumer needs no configuration: the codec travels in the batch attributes, and the client decompresses automatically. compression.type is a producer (and topic) setting only.

🔬 Go deeper: CRC-32C, control batches and what compaction keeps

CRC. The checksum is CRC-32C (Castagnoli) over everything from attributes to the end of the batch. Because it sits after magic, a reader must parse the magic byte before it knows how to interpret the bytes in between. Total on-disk size of a batch is batchLength + 12 (the 8-byte base offset plus the 4-byte length field itself).

Attributes bits. 0–2 codec, 3 timestamp type (CreateTime vs LogAppendTime), 4 isTransactional, 5 isControlBatch, 6 hasDeleteHorizonMs. A control batch holds a single control record whose key is (version, type) with type 0 = abort and 1 = commit. These are the transaction markers of Part III, filtered out before your application sees them.

Compaction keeps the envelope. When the cleaner removes records, it preserves the first and last offset and sequence numbers of the original batch, even if that leaves an empty batch. That's needed so a producer's last sequence number survives and idempotence (Chapter 12) keeps working after a leader change. The baseTimestamp is not preserved; with bit 6 set it is reused as the delete horizon for tombstones and aborted-transaction markers.

Broker side. LogValidator.validateMessagesAndAssignOffsetsCompressed decides between in-place offset assignment and buildRecordsAndAssignOffsets (rebuild). In-place is impossible when source and target codecs differ or when a magic conversion is needed. The header constants live in DefaultRecordBatch, where RECORD_BATCH_OVERHEAD = 61 bytes.

📌 Bullet points
  • Everything is a record batch (magic 2): a 61-byte header + records. Offsets, CRC, producer id/epoch/sequence live once per batch.
  • Records use varint lengths and deltas for offset and timestamp. Headers are ordered key/value pairs.
  • CRC-32C covers attributes → end, so the broker can set baseOffset and partitionLeaderEpoch without recomputing it.
  • Compression is per batch and end-to-end: producer compresses, broker validates and stores compressed, consumer decompresses.
  • Topic compression.type=producer (default) avoids broker recompression. A mismatched topic codec forces a rebuild on every produce.

KRaft: the cluster's metadata is itself a log

the city council keeps minutes, and everyone reads them

Who decides which branch holds which book, which photocopy is current, which branches are open today? In our city, the council does, and it doesn't send letters around. It keeps minutes: a numbered, append-only record of every decision. Branches read the minutes and update their own copy of the registry. That's KRaft. Since Kafka 4.0 it's the only mode; ZooKeeper is gone.

Concretely, cluster metadata (topics, partitions, leaders, ISRs, broker registrations, configs, ACLs, feature levels) lives in a single-partition internal topic called __cluster_metadata. Every change is a record in that log: TopicRecord, PartitionRecord, PartitionChangeRecord, RegisterBrokerRecord, BrokerRegistrationChangeRecord, ConfigRecord, FeatureLevelRecord and so on. The current state of the cluster is simply "replay the log from the start" (or from a snapshot).

Roles: controllers and brokers

Each server sets process.roles to broker, controller, or broker,controller ("combined", fine for development, not recommended for critical deployments). Controllers form the metadata quorum: one is the active controller (the Raft leader), the others are hot standbys that replicate the log. You typically run 3 or 5. A majority must be alive, so 3 tolerate 1 failure and 5 tolerate 2 (the docs' rule: 2N + 1 controllers survive N failures).

Brokers are observers: they replicate the metadata log too but never vote. In kafka-metadata-quorum.sh describe --status you literally see CurrentVoters (controllers) and CurrentObservers (brokers).

▶ The metadata log in action create a topic · then kill the active controller
Three controllers (C1 active) and three brokers, all at metadata offset 4. Press ➕.
Simplified: one record per step (a real CreateTopics writes a TopicRecord plus one PartitionRecord per partition, usually in one batch), no snapshots, and voters/observers fetch continuously rather than in visible rounds. The "commit" line is the metadata log's high watermark: an entry is committed once a majority of voters have it. Timings are illustrative; the relevant defaults are named in the text.

Raft, Kafka-style: pull, not push

Textbook Raft has the leader push entries to followers. KRaft's implementation (KafkaRaftClient) describes itself as a "Kafkaesque version of the Raft protocol". Leader election is more or less pure Raft (a Vote request carrying the candidate's last log offset), but replication is driven by followers fetching, using the same Fetch API that partition replicas use (Chapter 10). Because nobody pushes, a new leader sends BeginQuorumEpoch so voters learn who to fetch from. A voter that hasn't fetched successfully from the leader for controller.quorum.fetch.timeout.ms (default 2 000 ms) becomes prospective and runs a pre-vote (KIP-996: a Vote request with PreVote=true, which isn't persisted). Only if a majority grants it does it become a candidate and start a real election.

An entry is committed once a majority of voters have it. That's the metadata log's high watermark. The active controller only completes a request once its records are durable in the metadata log, so a decision it has acknowledged can't be "un-made" by a controller failover.

Broker liveness: heartbeats and fencing

When a broker starts, it registers with the active controller (a RegisterBrokerRecord lands in the log) and then sends a heartbeat every broker.heartbeat.interval.ms (default 2 000 ms). If the controller gets no heartbeat within broker.session.timeout.ms (default 9 000 ms), the broker's lease has expired. The controller fences it (a BrokerRegistrationChangeRecord with fenced = FENCE) and moves leadership of its partitions to other ISR members. The design docs define "alive" exactly like this: an active session with the controller, plus keeping up with the leader when acting as a follower.

❓ There are no dumb questions
Q:
Does the metadata log grow forever?
A:
No. Nodes periodically write a snapshot of the full state (…-epoch.checkpoint files in __cluster_metadata-0). A snapshot is generated after metadata.log.max.record.bytes.between.snapshots (default 20 MiB) of new records or metadata.log.max.snapshot.interval.ms (default 1 hour). A node that's far behind receives a snapshot via FetchSnapshot instead of replaying everything.
Q:
Do clients talk to controllers?
A:
Normal producers and consumers don't. They ask brokers for metadata, and brokers answer from their locally replayed copy. Admin tools can target controllers directly with --bootstrap-controller.
Q:
Why must I run kafka-storage.sh format before first start?
A:
Safety. If a majority of controllers could silently auto-format empty directories, a leader could be elected with missing committed metadata. Formatting explicitly stamps the cluster id (and, for dynamic quorums, the initial voter set) into the storage.

Static vs dynamic quorums (KIP-853)

Older clusters list every controller in controller.quorum.voters: a static quorum, where membership can't change without a full reconfiguration. With kraft.version 1 (release 4.1+ for upgrades of existing clusters), quorums are dynamic: nodes find the quorum through controller.quorum.bootstrap.servers (like bootstrap.servers for clients), and you add or remove controllers online with kafka-metadata-quorum.sh add-controller / remove-controller. The quorum is formatted as dynamic when controller.quorum.voters is absent and you format with --standalone, --initial-controllers or --no-initial-controllers.

ConfigDefaultWhat it doesUse it when
process.roles(required)broker, controller or bothSeparate roles in production; combined only for dev
controller.quorum.bootstrap.serversemptyWhere to find the quorum (dynamic quorums)All new clusters; set on every broker and controller
controller.quorum.votersemptyFixed voter list (static quorums)Only legacy static clusters (deprecated by kraft.version 1)
broker.heartbeat.interval.ms2000Broker → controller heartbeat periodRarely changed
broker.session.timeout.ms9000Broker lease length without heartbeats before fencingTrade faster failover vs false fencing on GC pauses/networks
controller.quorum.fetch.timeout.ms2000Voter without a successful fetch → becomes prospective (pre-vote), then candidate; leader without majority fetches → resignsRarely; flaky networks
controller.quorum.election.timeout.ms1000Max wait before triggering a new electionRarely
metadata.log.max.record.bytes.between.snapshots20971520New bytes since last snapshot before a new snapshotVery large clusters with heavy metadata churn
✏️ Sharpen your pencil

On your quickstart node, create a topic and then find its TopicRecord in the metadata log. Before running: what do you expect LeaderId and the number of CurrentVoters to be on a single combined node?

bin/kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe --status
bin/kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe --replication
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders --partitions 3
bin/kafka-dump-log.sh --cluster-metadata-decoder \
  --files /tmp/kraft-combined-logs/__cluster_metadata-0/00000000000000000000.log | grep -A2 orders
Answer

On a single combined node, LeaderId is that node's id (1 in the quickstart config), there is exactly one voter and no observers (the combined node is listed once, as the voter). The dump shows a TOPIC_RECORD payload with "name":"orders" and a topic id, followed by one PARTITION_RECORD per partition with its replicas, ISR, leader and epochs. That's the council minute for "create orders". HighWatermark in --status is the metadata log's commit point.

🔬 Go deeper: from minute to local registry

The active controller is a QuorumController (metadata module). It handles admin requests on a single event thread, turns them into metadata records, and appends them through the Raft client. Broker liveness is tracked by BrokerHeartbeatManager; the broker side of registration and heartbeats is BrokerLifecycleManager. The quorum's own state machine (unattached, prospective, candidate, leader, follower…) lives in QuorumState, and the protocol in KafkaRaftClient. Its APIs are Vote, BeginQuorumEpoch, EndQuorumEpoch (graceful resignation), Fetch and FetchSnapshot.

On every node, a MetadataLoader reads committed batches and builds a MetadataDelta on top of the previous immutable MetadataImage. Publishers then apply the new image, for example to make the local ReplicaManager become leader or follower for partitions. The leader's appends linger up to controller.quorum.append.linger.ms (default 25 ms) to batch writes.

Storage bootstrap: kafka-storage.sh format --standalone writes meta.properties and a bootstrap snapshot 00000000000000000000-0000000000.checkpoint containing KRaftVersionRecord and VotersRecord. With --initial-controllers, that VotersRecord lists all controllers, and the value must be identical on every controller.

📌 Bullet points
  • All cluster metadata is a replicated log, __cluster_metadata. State = replay records (or load a snapshot).
  • Controllers vote (Raft, majority quorum: 3 → 1 failure, 5 → 2). Brokers are observers that fetch and apply.
  • Kafka's Raft is pull-based: followers fetch. BeginQuorumEpoch announces a new leader. Commit = majority of voters.
  • Brokers heartbeat every 2 s; no heartbeat for broker.session.timeout.ms (9 s) → fenced, and leaders move.
  • Dynamic quorums (KIP-853, kraft.version ≥ 1) use controller.quorum.bootstrap.servers and support add/remove-controller.

The replication protocol: LEO, high watermark and the ISR

a line counts only when every in-sync photocopy has it

Part I gave you the rules: replicas, a leader, an ISR, and acks=all waits for the ISR. Now let's watch the machinery that enforces them. It runs on two numbers per replica and one simple loop.

Followers pull. A follower is basically a consumer of its leader. It sends Fetch requests with a FetchOffset ("give me everything from offset N"), appends what it gets to its own log, and asks again. The design docs point out the bonus: pulling lets the follower batch naturally. In the archive: each branch that keeps a photocopy sends a runner to the main branch saying "I have lines up to 41, give me 42 onward".

Two numbers you must know

The clever part: the leader learns each follower's LEO for free, because a follower's FetchOffset is its LEO. And followers learn the HW for free, because every fetch response carries the leader's HighWatermark. There's no separate "ack" message. The fetch loop is the replication protocol.

▶ Watch the high watermark move step through fetch rounds · then slow a follower down
ISR = {L, F1, F2}producer (acks=all): idleconsumer can read: offsets 0–3
All three replicas hold offsets 0–3 (LEO 4, HW 4). Press ▶.
Simplified: one fetch round per step, 1 record = 1 offset, no leader epochs. In reality followers fetch continuously (a follower fetch waits up to replica.fetch.wait.max.ms, default 500 ms, when there's no new data), and ISR removal happens after replica.lag.time.max.ms (default 30 s). The seconds shown are illustrative.

What the high watermark controls

Consumers only see committed records. A fetch from a normal consumer returns data only below the HW. Records above it exist on the leader but might vanish if the leader dies before followers copy them, so Kafka won't hand them out. The docs put it plainly: "only committed messages are ever given out to the consumer", regardless of the producer's acks. Records become visible only once they're on all in-sync replicas and the ISR has at least min.insync.replicas members.

acks=all waits for the HW. The leader appends the batch, then parks the produce request until the HW has passed the batch's last offset, i.e. until every ISR member has fetched it. Only then does the producer get its acknowledgement. acks=1 answers right after the leader's local append; acks=0 doesn't wait at all.

min.insync.replicas guards the door. With acks=all, if the ISR is smaller than min.insync.replicas, the leader rejects the write with NOT_ENOUGH_REPLICAS. The partition-level code also only advances the HW when the ISR is at least min.insync.replicas. Without this, "acks=all" would degrade silently to "acks=leader-only" when followers drop out. Writes with acks=0 or 1 are still accepted below the minimum, but they stay invisible to consumers until the ISR is back up to it.

ISR shrink and expand

The leader periodically checks each follower. A follower that hasn't caught up to the leader's LEO within replica.lag.time.max.ms (default 30 000 ms) is out of sync. This covers both a stuck follower (no fetches at all) and a slow one (fetching, but never reaching the end). The leader asks the controller to shrink the ISR (an AlterPartition request; the change becomes a metadata record). Once the ISR is smaller, the HW can move again, and acks=all producers unblock.

When the lagging follower catches up (its LEO reaches the HW within the current leader epoch), the leader asks to expand the ISR again. Note the time-based definition: Kafka doesn't count "messages behind". A follower 10 000 records behind that's catching up fast stays in; one that's only 5 behind but hasn't reached the end for 30 s goes out.

⚠️ Watch it!

RF = 3 with min.insync.replicas=1 + acks=all is a trap. When two followers fall out of the ISR, the ISR is just the leader, and acks=all means "the leader has it". Kafka's own docs describe this: with only one in-sync replica left, acks=all writes succeed, but they're lost if that replica then fails. The common production recipe is RF = 3, min.insync.replicas=2, acks=all: one broker can fail with no data loss and no write outage.

🧰 Workflow / use case

Diagnosing "producers are timing out, consumers are stuck".

  1. List partitions whose ISR is below min ISR: bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --under-min-isr-partitions. Those reject acks=all writes.
  2. List partitions whose ISR is smaller than their assigned replica set: --describe --under-replicated-partitions. The HW waits on the slowest in-sync follower until it's dropped.
  3. Look at which broker is common to those partitions. Check its disk, network and GC. A slow follower drags acks=all latency until replica.lag.time.max.ms expels it.
  4. Only then consider tuning num.replica.fetchers (default 1) to add parallel fetcher threads.
SymptomLikely causeFix
NotEnoughReplicasException on produceISR size < min.insync.replicas with acks=allBring the failed/lagging brokers back; don't "fix" it by lowering min ISR in production
Produce latency spikes with acks=all, then recoversA slow follower still in the ISR holds the HW back until it's removedFind the slow broker (disk/GC/network); consider replica fetcher tuning
ISR flaps (shrink/expand repeatedly)Follower hovers around the lag limitFix the follower's resources; raising replica.lag.time.max.ms only hides it
Consumer lag at 0 but producer (acks=0/1, or a send() not yet acknowledged) says "sent"Records above HW aren't visible yetExpected: they appear once followers replicate
🔬 Go deeper: Partition.maybeIncrementLeaderHW and friends

On the leader, kafka.cluster.Partition owns the ISR logic. maybeIncrementLeaderHW runs when the ISR changes or any replica's LEO changes. Its doc comment says the HW is "the smallest log end offset among all replicas that are in sync; or are considered caught-up and are allowed to join the ISR", and that "the HW can only advance if the ISR size is equal or larger than the min ISR". Replicas added by an in-flight AlterPartition already count, which the code calls the "maximal ISR" (KIP-497): being more restrictive is always safe.

getOutOfSyncReplicas(maxLagMs) uses each replica's lastCaughtUpTimeMs, the last time it was fully caught up, so stuck and slow followers are handled by one rule. maybeExpandIsr requires the follower's LEO ≥ HW and being caught up within the current leader epoch. Otherwise a follower could become leader before fetching committed data between the HW and LEO.

Followers run ReplicaFetcherThreads (num.replica.fetchers per source broker). Waiting acks=all produces sit in a purgatory as DelayedProduce operations inside ReplicaManager until the HW passes them or the request times out. Part III covers the purgatory and the leader-epoch-based truncation that makes failover safe.

📌 Bullet points
  • Followers pull with Fetch. Their FetchOffset tells the leader their LEO, and the response tells them the HW.
  • HW = smallest LEO in the ISR, frozen while the ISR is below min.insync.replicas. Consumers read only below it, and acks=all is acknowledged once the HW passes the batch.
  • A follower that hasn't caught up for replica.lag.time.max.ms (30 s) leaves the ISR, which unblocks the HW.
  • min.insync.replicas rejects acks=all writes when the ISR is too small. The usual recipe is RF 3 / min ISR 2.

Group coordination and rebalances: classic vs KIP-848

how a team of clerks hands books around without dropping one

In Part I a consumer group was a team of clerks sharing ledger books: each book goes to exactly one clerk, and each clerk keeps a bookmark (the committed offset). The hard part isn't reading. It's changing who reads what when a clerk arrives, leaves or collapses, without two clerks reading the same book and without a book left unattended. That handover is a rebalance, and Kafka has two very different protocols for it.

The group coordinator and __consumer_offsets

Every group has a group coordinator: a broker-side component on the broker that leads one partition of the internal topic __consumer_offsets. Which partition? abs(groupId.hashCode()) % offsets.topic.num.partitions (default 50 partitions, replication factor 3). A consumer finds its coordinator with a FindCoordinator request, then sends all group traffic (joins, heartbeats, offset commits) there. Commits and group state are written as records into that partition, so they're replicated like any other data, and a new coordinator can rebuild them by replaying the partition after a failover.

Classic protocol: JoinGroup / SyncGroup

The classic protocol (the client default in 4.3: group.protocol=classic) puts the brains in the client:

  1. Something changes (a member joins or leaves, a member misses heartbeats, subscriptions change). The coordinator moves the group to PreparingRebalance and every member must send JoinGroup.
  2. The coordinator waits for members to rejoin, picks one as the group leader and gives it everyone's subscriptions.
  3. The leader runs the client-side assignor (partition.assignment.strategy) and sends the plan in SyncGroup. The coordinator hands each member its share.

That's a global synchronization barrier: nobody gets a new assignment until everyone has joined. How painful it is depends on the rebalance flavour:

The default partition.assignment.strategy is [RangeAssignor, CooperativeStickyAssignor]. That means eager Range by default, but you can switch to cooperative with a single rolling bounce that removes RangeAssignor from the list.

The new consumer protocol (KIP-848)

KIP-848 is GA since Kafka 4.0 and enabled by default on the server. Clients opt in with group.protocol=consumer. It moves the brains to the broker and removes the barrier:

▶ Race lanes: consumer C3 joins a 2-consumer group run the join · then crash a member
processingpaused / revokedunowned (nobody reads it)
Six partitions p0–p5, C1 owns p0–p2, C2 owns p3–p5, in all three lanes. Press ➕.
Heuristic animation: the step timing is illustrative, not measured. Real durations depend on how long members take to finish poll(), heartbeat intervals and your rebalance listener code. The final assignments shown are one valid outcome; real assignors may pick different partitions.

Failure detection: two different clocks

A consumer can be "dead" in two ways. Crashed: heartbeats stop, and after the session timeout (session.timeout.ms, default 45 s, classic; group.consumer.session.timeout.ms, default 45 s, new protocol) the coordinator removes it. Until then, its partitions are not read by anyone. Stuck: the process heartbeats happily, but your code hasn't called poll() for more than max.poll.interval.ms (default 300 000 ms = 5 min). The consumer is considered failed, leaves the group, and a rebalance hands its partitions to others. When the slow code finally commits, it gets a commit failure because it no longer owns the partitions.

❓ There are no dumb questions
Q:
Rolling restarts trigger rebalances for every pod. Can I avoid that?
A:
Yes: static membership. Give each instance a stable group.instance.id. The coordinator then recognises a restarted member as the same one and doesn't reassign its partitions, as long as it comes back before the session timeout. Duplicate ids are fenced with FencedInstanceIdException. Streams apps set one id per instance.
Q:
Can I switch an existing group to the new protocol without downtime?
A:
Yes. Roll consumers with group.protocol=consumer. When the first one joins, the group converts from Classic to Consumer and interoperates with the remaining classic members, provided the classic assignor doesn't embed custom metadata. The docs map RangeAssignor → range and the sticky/round-robin ones → uniform.
Q:
Is classic going away?
A:
The documented plan (KIP-1274): in 5.0 KafkaConsumer defaults to the consumer protocol, and in 6.0 it only supports it, while brokers keep supporting classic for older clients.
ConfigDefaultWhat it doesUse it when
group.protocol (client)classicChoose classic or KIP-848 consumer protocolSet consumer for new apps on 4.x brokers
partition.assignment.strategy[Range, CooperativeSticky]Client-side assignors (classic only), by preferenceDrop Range to get cooperative rebalancing
session.timeout.ms45000No heartbeat for this long → member removed (classic)Faster failure detection vs false positives
heartbeat.interval.ms3000Heartbeat period (classic)Keep well below the session timeout
max.poll.interval.ms300000Max gap between poll() calls before the member is considered failedRaise for slow batch processing, or lower max.poll.records
group.instance.idnullStatic membership idRolling deploys, stateful consumers
group.consumer.heartbeat.interval.ms (broker)5000Heartbeat period dictated to KIP-848 membersRarely
group.consumer.session.timeout.ms (broker)45000Session timeout for KIP-848 membersFaster failure detection cluster-wide
group.consumer.assignors (broker)uniform, rangeServer-side assignors; first is defaultCustom server-side assignor
group.initial.rebalance.delay.ms (broker)3000Delay the first classic rebalance of an empty group so more members can joinTests (set 0) or large groups starting together
✏️ Sharpen your pencil

Start two console consumers in the same group, one classic and one with the new protocol (in two terminals, one command each), then describe the group. What type does the group report, and what happens to the second consumer?

bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic rb-demo --partitions 6
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic rb-demo --group g1 \
  --command-property group.protocol=consumer
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic rb-demo --group g1
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list --type
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group g1 --members --verbose
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group g1 --state
Answer

The first consumer creates a Consumer-type group. The classic member still joins: the coordinator supports mixed groups so you can migrate with a rolling restart, and it serves the classic member through the classic JoinGroup/SyncGroup APIs. --list --type shows the group's TYPE (Consumer). --members alone lists both members with a #PARTITIONS count (6 split between them); add --verbose to see the actual assigned partitions. --state shows the coordinator, assignment strategy, state and member count. Stop one consumer and describe again: its partitions move to the other.

🔬 Go deeper: inside the new group coordinator

Since 4.0 the coordinator is the Java group-coordinator module. GroupCoordinatorService routes each request to a coordinator shard for the right __consumer_offsets partition (partitionFor = Utils.abs(groupId.hashCode()) % numPartitions). GroupMetadataManager holds ClassicGroup and ConsumerGroup objects. Classic groups move through states like PreparingRebalance → CompletingRebalance → Stable.

For KIP-848, TargetAssignmentBuilder computes the desired assignment for a new group epoch, and CurrentAssignmentBuilder is "the reconciliation engine": per member it walks the states STABLE, UNREVOKED_PARTITIONS (the member must revoke some partitions before moving to the next epoch) and UNRELEASED_PARTITIONS (the member moved to the new epoch but waits for partitions that previous owners haven't released yet). A member with a stale epoch gets FENCED_MEMBER_EPOCH: "the member must abandon all its partitions and rejoin".

Wire APIs: classic uses FindCoordinator (key 10), JoinGroup (11), Heartbeat (12), LeaveGroup (13) and SyncGroup (14); the new protocol uses ConsumerGroupHeartbeat (68) with fields MemberEpoch, InstanceId, RebalanceTimeoutMs, SubscribedTopicNames/SubscribedTopicRegex (RE2J, evaluated on the server), ServerAssignor and TopicPartitions.

📌 Bullet points
  • The coordinator for a group lives on the leader of __consumer_offsets partition abs(hash(groupId)) % 50.
  • Classic = JoinGroup/SyncGroup with a client-side assignor and a global barrier. Eager revokes everything; cooperative revokes only what moves (two rounds).
  • KIP-848 (group.protocol=consumer) = heartbeat-driven, server-side assignor, per-member incremental reconciliation, no barrier.
  • Crash → partitions idle until the session timeout (45 s). Slow poll() → out after max.poll.interval.ms (5 min).
  • group.instance.id (static membership) avoids rebalances on quick restarts.

The idempotent producer: retries without duplicates

numbered envelopes make the clerk say "already got this one"

Here's the classic distributed-systems trap. The producer sends a batch; the leader appends it; the acknowledgement gets lost on the way back (a timeout, a dropped connection). The producer can't tell "not written" from "written but not acknowledged", so it retries. Before Kafka 0.11 that meant a duplicate in the log: at-least-once delivery. The design docs compare it to inserting into a database table with an auto-generated key.

The fix is the one every good clerk uses: number the envelopes. If each sender writes "envelope #7" on every envelope, the clerk can say "I already filed #7" and hand back the old receipt instead of filing it twice. Kafka does exactly this, and it's on by default (enable.idempotence=true).

The three numbers

All three live in the batch header you met in Chapter 8 (producerId, producerEpoch, baseSequence). They cost nothing extra on the wire.

What the broker checks

For every partition, the leader keeps a small producer state per PID: the current epoch and metadata about the last 5 batches it appended (sequence range and offsets). When a batch arrives:

▶ A lost ack and a retry run with idempotence on · then off · then send a gap
Producer PID 4000 · epoch 0
Partition leader
The log already holds batch seq 0–2 at offsets 0–2. Press ▶ to send the next batch (seq 3–5) and lose its ack.
Simplified: one partition, batches of 3 records, a single in-flight request. PID 4000 and the offsets are illustrative. Real producers retry automatically (retries defaults to Integer.MAX_VALUE, bounded by delivery.timeout.ms).

Why "max 5 in flight"?

The broker only remembers the last 5 batches per producer per partition. So the producer must never have more than 5 unacknowledged requests on one connection. Otherwise a retry could refer to a batch the broker has already forgotten, and duplicate detection would fail. That's why idempotence requires max.in.flight.requests.per.connection ≤ 5 (default 5), plus retries > 0 and acks=all. And the producer docs promise that with idempotence on, ordering is preserved for any allowed value. The broker's refusal to accept sequence gaps is what makes that possible: a batch can't jump ahead of an earlier one that has to be retried.

⚠️ Watch it!

Idempotence silently turns itself off if your configs conflict and you didn't enable it explicitly. The rule from the producer docs: idempotence is on by default "if no conflicting configurations are set". If you set acks=1 (or 0) or retries=0 without setting enable.idempotence, the producer logs one INFO line and disables idempotence, so retries can duplicate again. With enable.idempotence=true set explicitly, the same configs throw a ConfigException at start-up instead. Set it explicitly so a bad config fails loudly. (The in-flight limit is stricter: since 4.0, max.in.flight.requests.per.connection > 5 with idempotence enabled is always a ConfigException; there's no silent fallback any more.)

❓ There are no dumb questions
Q:
So is this "exactly once"?
A:
Only for one producer session writing to one partition: no duplicates caused by the producer's internal retries. If your application calls send() twice for the same thing, or the producer restarts (new PID), Kafka sees two different messages. Cross-partition atomicity and consume-transform-produce need transactions (Part III), which build on exactly this PID/epoch/sequence machinery.
Q:
Does the broker remember producer ids forever?
A:
No. A partition leader expires an idle PID after producer.id.expiration.ms (default 86 400 000 ms = 1 day), and sooner if the producer's last write is deleted by retention. The docs note that keeping it at or above delivery.timeout.ms helps avoid expiry during retries.
Q:
What survives a leader failover?
A:
Producer state is rebuilt from the log itself (sequence numbers are in the batch headers) plus the .snapshot files written at segment rolls (Chapter 7). That's why log compaction preserves each batch's first and last offset and sequence, and can keep an emptied batch in the log so that a producer's last sequence number survives.
ConfigDefaultWhat it doesUse it when
enable.idempotencetruePID + sequence dedupe of retriesAlways. Set it explicitly to fail loudly on conflicts
acksallRequired to be all for idempotenceKeep all
max.in.flight.requests.per.connection5Unacked requests per connection; must be ≤ 5 with idempotenceLeave at 5 (ordering is still preserved)
retries2147483647Retry count; must be > 0 for idempotenceLeave it; bound time with delivery.timeout.ms
producer.id.expiration.ms (broker)86400000How long a leader keeps idle producer stateVery slow or bursty producers
✏️ Sharpen your pencil

Produce a few records with the console producer and inspect the batch headers. Which fields show that idempotence was active, and what would they show with enable.idempotence=false?

bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic idem-demo --partitions 1
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic idem-demo
> a
> b
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/idem-demo-0/00000000000000000000.log
Answer

Each batch line shows a real producerId (≥ 0), a producerEpoch, and baseSequence/lastSequence counting up from 0 across batches (0, then 1 if each line went in its own batch). With idempotence disabled (--command-property enable.idempotence=false on the console producer), you'd see producerId: -1, producerEpoch: -1 and baseSequence: -1: no identity, so no deduplication.

🔬 Go deeper: ProducerStateManager and the duplicate path

Each UnifiedLog has a ProducerStateManager holding a ProducerStateEntry per PID. The entry keeps the epoch and a queue of BatchMetadata, capped at ProducerStateEntry.NUM_BATCHES_TO_RETAIN = 5. On a client append, UnifiedLog calls findDuplicateBatch. It only matches when the epoch equals the entry's epoch and the (baseSequence, lastSequence) range equals a retained batch. On a hit, the append is skipped and the returned LogAppendInfo carries the duplicate's first/last offset and timestamp.

Otherwise ProducerAppendInfo.checkSequence validates. With a new epoch, the sequence must restart at 0. With the same epoch, the first sequence must directly follow the current last sequence, or an OutOfOrderSequenceException is thrown. An exception: if the broker has no epoch for the PID at all (state lost to retention or DeleteRecords), any sequence is accepted. On the client, TransactionManager (used by idempotent producers too) tracks sequences per partition and can bump the idempotent epoch (bumpIdempotentEpochAndResetIdIfNeeded) to recover from sequence errors without a restart.

📌 Bullet points
  • Idempotence = PID + epoch + per-partition sequence stamped into every batch header. On by default.
  • The leader remembers the last 5 batches per producer per partition. A retry of one of them returns the original offsets, with no second append.
  • Gaps → OUT_OF_ORDER_SEQUENCE_NUMBER; stale epoch → INVALID_PRODUCER_EPOCH.
  • Requires acks=all, retries > 0, max.in.flight.requests.per.connection ≤ 5. Ordering holds even with 5 in flight.
  • It protects against the producer's own retries only. App-level resends and cross-partition atomicity need transactions.
Part III

Expert internals

The machinery behind the guarantees: how transactions really commit, how compaction rewrites the ledger without moving a single line number, what a request lives through inside a broker, how replicas agree on history after a crash, how old segments move to object storage, how share groups turn a log into a queue, and how Kafka Streams keeps state alive across instances.

Transactions & exactly-once

all the pages get stamped, or none of them do

The idempotent producer from Part II solved one problem: a retry of the same batch to the same partition doesn't create a duplicate. That's it. It says nothing about two different partitions, nothing about a producer that crashes and restarts, and nothing about the consumer offset you want to move at the same time as you write your output.

Think about the classic "read → process → write" loop. You read record 41 from orders, write a result to invoices, then commit offset 42. Crash between the write and the commit, and on restart you read 41 again and write a second invoice. Commit first, crash before the write, and the invoice is never written. Both orders are wrong. You need the output write and the offset move to be one atomic act.

🧠 Brain power

In the city ledger archive, your clerk copies lines from the "orders" book into two other books ("invoices" and "audit") and then moves the bookmark in the orders book. All three books may live in different branches. How would you make sure a reader never sees the invoice without the audit line, and never sees either if the clerk faints halfway? You can't erase ink — the books are append-only.

Kafka's answer: write everything immediately, decide visibility later. The records go into the logs straight away, flagged as "part of transaction X". When the producer commits, a tiny control record — a commit or abort marker — is appended to every partition that took part. Readers that ask for isolation.level=read_committed hold back transactional records until they see the marker, and skip the ones that end up aborted. In ledger terms: the clerk writes in pencil-grey ink, and at the end a notary stamps "VALID" or "VOID" in every book touched.

The cast

A transactional producer is an idempotent producer with a name. You set transactional.id (setting it implies enable.idempotence). That name is stable across restarts — that's the whole point. It's hashed onto one partition of the internal topic __transaction_state (50 partitions by default, replication factor 3, min ISR 2), and the leader of that partition acts as the producer's transaction coordinator. The coordinator keeps the transaction's state machine and writes every state change to __transaction_state, so a new coordinator can pick up after a failover.

The API is five calls: initTransactions() once, then per transaction beginTransaction(), send()…, optionally sendOffsetsToTransaction(offsets, consumer.groupMetadata()), then commitTransaction() or abortTransaction(). On the consumer side you set isolation.level=read_committed and enable.auto.commit=false — the offsets travel inside the transaction instead.

▶ Transaction timeline run a transaction, then let a zombie try to write
Producertransactional.id = billing-1epoch 5
Transaction coordinator__transaction_statestate Empty
invoices-0
audit-3
__consumer_offsets
read_committed consumer on invoices-0: sees nothing yet
non-transactionalpending (in open txn)committedabortedmarker
Press ▶ to run one consume–transform–produce transaction.
Simplified: one coordinator, two output partitions, offsets and epochs are illustrative. The flow shown is transaction version 2 (no separate AddPartitionsToTxn / AddOffsetsToTxn calls from the client); the red bar is the last stable offset (LSO) of invoices-0.

What happens on commit

The coordinator's state machine moves Empty → Ongoing → PrepareCommit → CompleteCommit (or PrepareAbort → CompleteAbort). The moment PrepareCommit is durably written to __transaction_state, the decision is final — even if the coordinator dies, the next one will finish the job. Then it sends WriteTxnMarkers requests to the leaders of every partition in the transaction (including the __consumer_offsets partition holding your group's offsets). Once all markers are written, it records CompleteCommit.

On the reading side, every partition tracks a last stable offset (LSO): the point before which every transactional record has been decided. A read_committed consumer only gets records below the LSO, and the fetch response carries a list of AbortedTransactions so the client can drop records from aborted ones. That's why one long-running open transaction stalls every read_committed consumer of that partition — even consumers that don't care about that producer. Records behind the open transaction are withheld until it finishes.

⚠️ Watch it!

The open transaction blocks everyone. Since read_committed consumers stop at the LSO, a producer that begins a transaction and then hangs holds back the whole partition until the coordinator aborts it after transaction.timeout.ms (producer default 60000 ms; it can't exceed the broker's transaction.max.timeout.ms, default 15 minutes). Keep transactions short, and alert on consumer lag that sits exactly at the LSO while the high watermark keeps moving.

Zombie fencing — why the id must be stable

Imagine instance A of your service stalls in a long GC pause. The platform thinks it's dead and starts instance B with the same transactional.id. B calls initTransactions(); the coordinator bumps the producer epoch for that id and aborts whatever A left open. When A wakes up and tries to write with the old epoch, the partition leader rejects it with INVALID_PRODUCER_EPOCH (surfaced as InvalidProducerEpochException, which asks the app to abort); as soon as A talks to the coordinator again — to commit or abort — it gets the fatal ProducerFencedException and must close. The epoch is the notary's seal: only the newest seal is honoured. Without a stable id there's nothing to fence on, and A and B would happily write duplicates side by side.

❓ There are no dumb questions
Q:
So the consumer is transactional too?
A:
No. Only the producer has transactional.id. The trick is that the consumer's position is itself just a record in __consumer_offsets, so the producer writes it inside the transaction via sendOffsetsToTransaction. The consumer is merely configured to read only committed data.
Q:
Does "exactly-once" cover my database write in the processing step?
A:
No. The guarantee covers reading from Kafka, writing to Kafka, and moving offsets in Kafka. Side effects outside Kafka (an HTTP call, a DB insert) can still happen twice after an abort and retry — they need their own idempotency or must store offsets in the same place as their output.
Q:
What about read_uncommitted consumers?
A:
That's the default isolation.level. They read up to the high watermark and see records of open and aborted transactions. Transactions only protect readers who opt in.

KIP-890 and transaction version 2

The original protocol had a gap: a produce request that arrived late (say, a retry delayed in the network) could land after a transaction finished and silently become part of the next one, or linger as a "hanging" transaction that pins the LSO forever. KIP-890 ("transactions server-side defense") closes this in two steps. First, partition leaders verify with the coordinator that a partition really is part of an ongoing transaction before accepting transactional data (transaction.partition.verification.enable, default true). Second, with transaction.version=2 (TV2), the producer epoch is bumped at the end of every transaction, so a straggler from transaction N carries a stale epoch and is rejected by construction.

TV2 also trims round trips. The client no longer sends AddPartitionsToTxn itself: the partition leader adds the partition to the transaction on the server side while handling the produce request. sendOffsetsToTransaction likewise skips the separate AddOffsetsToTxn call and goes straight to TxnOffsetCommit. The server side has been on since 4.0 and is controlled by the transaction.version feature; 4.x producers switch to it at the start of their next transaction once they learn the cluster supports it — no restart needed. Downgrades are safe too.

✏️ Sharpen your pencil

On a local 4.3 cluster, check which transaction version the cluster runs, then list the transactions the coordinators know about and describe one. Which commands?

Answer
bin/kafka-features.sh --bootstrap-server localhost:9092 describe
bin/kafka-transactions.sh --bootstrap-server localhost:9092 list
bin/kafka-transactions.sh --bootstrap-server localhost:9092 describe --transactional-id billing-1
# watch only committed data:
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic invoices \
  --isolation-level read_committed --from-beginning

Look for transaction.version in the describe output. If a partition's read_committed consumers are stuck, kafka-transactions.sh find-hanging --broker-id 1 looks for hanging transactions and abort --topic … --partition … --start-offset … aborts one by hand.

ConfigDefaultWhat it doesUse it when
transactional.id (producer)nullEnables transactions; stable identity used for fencingAny consume–transform–produce or multi-partition atomic write; one id per input shard/instance
transaction.timeout.ms (producer)60000Coordinator aborts a transaction open longer than thisLower it so a hung producer unblocks read_committed readers sooner
isolation.level (consumer)read_uncommittedread_committed → only decided data up to the LSOAlways for downstream of transactional producers
transaction.max.timeout.ms (broker)900000 (15 min)Upper bound a producer may requestRarely; raise only for long batch jobs
transactional.id.expiration.ms (broker)604800000 (7 d)Idle transactional ids are expired from the coordinatorMany short-lived ids (dynamic ids per job)
transaction.state.log.replication.factor / .min.isr / .num.partitions3 / 2 / 50Durability and spread of __transaction_stateDev clusters with < 3 brokers must lower the RF
🔬 Go deeper: coordinator internals and the TV2 code path

Finding the coordinator. TransactionStateManager.partitionFor(transactionalId) is Utils.abs(transactionalId.hashCode) % transactionTopicPartitionCount. The client discovers it with FindCoordinator (key type transaction), then calls InitProducerId, which returns the producer id and a fresh epoch.

States. org.apache.kafka.coordinator.transaction.TransactionState defines EMPTY, ONGOING, PREPARE_COMMIT, PREPARE_ABORT, COMPLETE_COMMIT, COMPLETE_ABORT, DEAD, PREPARE_EPOCH_FENCE. PREPARE_EPOCH_FENCE is the path taken when the coordinator must abort an open transaction and fence its producer epoch — both when a new InitProducerId arrives for the id and when abortTimedOutTransactions aborts a transaction that exceeded its timeout.

Markers. TransactionMarkerChannelManager batches WriteTxnMarkers requests per destination broker. Each marker carries ProducerId, ProducerEpoch, TransactionResult (true = COMMIT), CoordinatorEpoch and, from request v2, a TransactionVersion field.

TV2 on the client. In TransactionManager.maybeAddPartition, when isTransactionV2Enabled() is true, the partition is only tracked locally — no AddPartitionsToTxn request is queued. sendOffsetsToTransaction uses txnOffsetCommitHandler directly, skipping AddOffsetsToTxn. The broker side, when a produce arrives for a partition not yet in the transaction, asks the coordinator to add it and, on CONCURRENT_TRANSACTIONS (the previous transaction's markers still being written), retries with add.partitions.to.txn.retry.backoff.ms (default 20) up to add.partitions.to.txn.retry.backoff.max.ms (default 100). The docs note this can show up as higher produce latency while end-to-end transaction latency stays the same.

Feature levels. TransactionVersion: TV_0 original, TV_1 flexible transactional state records, TV_2 "epoch bump per transaction and optimizations"; LATEST_PRODUCTION = TV_2.

Error classes. The docs group the transactional producer's exceptions into: retriable (handled internally), refresh-retriable, abortable (abort the transaction and rewind the consumer), application-recoverable (restart the producer), invalid-configuration, and generic KafkaException.

📌 Bullet points
  • Transactional records go into the logs immediately; commit/abort markers decide visibility later.
  • The coordinator is the leader of one __transaction_state partition, chosen by hashing transactional.id.
  • read_committed consumers stop at the LSO; one long-open transaction stalls the partition for them.
  • Offsets are committed inside the transaction with sendOffsetsToTransaction — that's the exactly-once part.
  • Stable transactional.id + epoch bump = zombie fencing (stale produces get INVALID_PRODUCER_EPOCH; the zombie's next coordinator call gets ProducerFencedException).
  • TV2 (KIP-890): epoch bumped every transaction, server-side AddPartitions, fewer round trips, no restart needed to adopt it.

Log compaction

keep the last word on every account, lose the gossip

Time-based retention is blunt: after seven days the whole segment goes, whether or not it holds the only copy of a customer's current address. For a stream of state — "account 123 now has email X" — you want something smarter: forget the superseded entries, keep the latest one for every key, forever. That's cleanup.policy=compact.

In the ledger archive, a compacted book is one where an archivist periodically recopies old pages, leaving out every line that a later line for the same account has overruled. The crucial detail: the line numbers are copied too. Line 38 stays line 38, even if lines 36 and 37 were dropped. Offsets never change; compaction only creates gaps.

🧠 Brain power

A consumer's committed offset is 37, but offset 37 was compacted away. Where does its next fetch start? And if a key should be deleted, how do you express that in a log where you can only append?

Head, tail and the cleaner

A compacted log has two regions. The tail (older, already cleaned) holds at most one record per key. The head (newer, "dirty") is an ordinary log with dense offsets. The log cleaner — a pool of background threads, log.cleaner.threads, default 1 — picks the log with the highest dirty ratio, builds a compact map of key → last offset for the dirty section, and recopies segments from the beginning, dropping every record whose key appears again later. Cleaned segments are swapped in as it goes, so the extra disk needed is about one segment, not a copy of the log.

Answer to the brain question, part one: every offset stays a valid position. Reading from a compacted-away offset simply returns the next record that still exists — the docs' example is that a read starting at 36, 37 or 38 all begin at 38.

▶ The cleaner at work run the cleaner, delete a key, fast-forward the clock
clock: +0 h · delete.retention.ms = 24 h · first dirty offset: 0
clean taildirty headactive segment (never cleaned)tombstone
Keys A, B and C have been updated several times. Press 🧹 to clean.
Simplified: the real cleaner only picks a log once its dirty ratio exceeds min.cleanable.dirty.ratio (0.5) or max.compaction.lag.ms forces it — here you run it by hand. Keys, values and timings are illustrative.

Tombstones: deleting in an append-only world

To delete a key you append a record with that key and a null value — a tombstone. The cleaner removes every earlier record for the key. The tombstone itself must linger for a while, or consumers that are rebuilding state would never learn the key was deleted. How long? delete.retention.ms, default 24 hours. In the v2 batch format the cleaner stamps a delete horizon into the batch the first time it sees the tombstone (now + delete.retention.ms) and drops the tombstone on a later pass after that time.

⚠️ Watch it!

A consumer that lags more than delete.retention.ms can miss deletes. The docs say it plainly: tombstone removal happens concurrently with reads, so a consumer that takes longer than that to reach the head may never see the delete marker — and keeps the deleted key in its cache or database forever. If you rebuild state from a compacted topic, make sure a full replay finishes well within that window, or raise it.

What compaction promises (and doesn't)

From the design docs: a consumer that keeps up with the head sees every record; ordering is never changed; offsets never change; a consumer reading from the start sees at least the final value of every key. What it does not promise is "exactly one record per key right now". The active segment is never cleaned, nothing past the last stable offset is cleaned, and records younger than min.compaction.lag.ms are protected. So a compacted topic can contain duplicates at any time — your consumer must treat it as "latest wins", never as a unique-key table.

🔥 Fireside chat
RetentionI just delete whole old segments. Simple, cheap, predictable.
CompactionAnd you throw away the only copy of a customer who hasn't changed in a week. I keep the last word for every key — forever if needed.
RetentionForever? Your topic never stops growing if keys never repeat.
CompactionThat's why people combine us: cleanup.policy=compact,delete. Old segments go by time or size, the survivors get compacted.
ConfigDefaultWhat it doesUse it when
cleanup.policydeletecompact, delete, both, or empty list (infinite retention)State/changelog topics: compact
min.cleanable.dirty.ratio0.5Dirty share of the log needed before the cleaner picks itLower → fewer duplicates, more cleaning I/O
delete.retention.ms86400000 (24 h)How long tombstones survive after being stampedRaise if full replays take longer
min.compaction.lag.ms0Minimum age before a record may be compactedConsumers need to see every update for a while
max.compaction.lag.msLong.MAX_VALUEMaximum time a record stays ineligible for compactionLow-traffic topics that never reach the dirty ratio (e.g. GDPR deletes must happen)
log.cleaner.threads1Cleaner threads per brokerMany compacted partitions falling behind
log.cleaner.dedupe.buffer.size134217728 (128 MiB)Memory for the key→offset maps, across all cleaner threadsHuge key spaces; each pass can cover more
log.cleaner.io.max.bytes.per.secondDouble.MAX_VALUE (unthrottled)Throttle cleaner read+write I/OCleaner competes with producers/consumers for disk
✏️ Sharpen your pencil

Create a compacted topic with tiny segments so the cleaner has something to do, write keyed updates and a tombstone, then look at the raw segment. Which commands?

Answer
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic accounts \
  --partitions 1 --config cleanup.policy=compact --config segment.ms=10000 \
  --config min.cleanable.dirty.ratio=0.01 --config delete.retention.ms=60000

bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic accounts \
  --reader-property parse.key=true --reader-property key.separator=: \
  --reader-property null.marker=NULL
> A:1
> B:1
> A:2
> B:NULL        # tombstone for B

bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/accounts-0/00000000000000000000.log --print-data-log

After a segment roll and a cleaner pass (it sleeps log.cleaner.backoff.ms, 15 s, when idle), the dump shows gaps in offsets and only the latest value per key. The log directory path depends on your log.dirs.

🔬 Go deeper: how the cleaner decides what to keep

Choosing a log. LogCleanerManager computes a LogToClean per compacted partition with cleanableRatio = cleanableBytes / totalBytes and picks the dirtiest. The cleanable range ends at the first uncleanable offset, the minimum of: the last stable offset, the active segment's base offset, and (if min.compaction.lag.ms > 0) the first segment still inside the lag.

The offset map. Cleaner builds a SkimpyOffsetMap (MD5-hashed keys; dedupe.buffer.size split across threads); the docs say each entry takes exactly 24 bytes, so "with 8GB of cleaner buffer one cleaner iteration can clean around 366GB of log head (assuming 1kB messages)". The map is filled up to log.cleaner.io.buffer.load.factor (0.9).

The retain rule (Cleaner.shouldRetainRecord): keep a record only if its offset ≥ the map's offset for its key, and it has a value or its tombstone isn't past the batch's deleteHorizonMs. Records without a key are invalid in a compacted topic and get dropped.

Transactions. The cleaner also removes records of aborted transactions (it collects them from the transaction index) and, piggy-backing on tombstone retention, eventually removes transaction markers — but never before all records of that transaction are gone.

Metrics. Watch uncleanable-partitions-count, max-clean-time-secs and max-compaction-delay-secs. A partition marked uncleanable after an error stays uncompacted and grows. Note log.cleaner.enable is deprecated since 4.1 and slated for removal in 5.0 — don't set it to false, because __consumer_offsets is compacted too.

📌 Bullet points
  • Compaction keeps at least the latest record per key; offsets and order never change, only gaps appear.
  • The active segment, anything past the LSO and records younger than min.compaction.lag.ms are never cleaned.
  • Delete = tombstone (key + null value); tombstones live delete.retention.ms (24 h) after being stamped.
  • Lagging longer than delete.retention.ms can make a consumer miss deletes.
  • The cleaner runs when the dirty ratio > min.cleanable.dirty.ratio (0.5) or max.compaction.lag.ms forces it.
  • Consumers of compacted topics must be "latest wins" — duplicates per key are normal.

The broker's request pipeline & purgatory

a post office with a waiting room for impatient letters

Every produce, fetch, heartbeat and metadata call is a request that travels through the same assembly line inside the broker. When latency spikes, "Kafka is slow" is useless; "produce requests spend 900 ms in the request queue" tells you exactly which part of the line is jammed. This chapter gives you that vocabulary.

Picture the archive branch's front desk. A doorman (the acceptor) lets visitors in and hands each to one of a few counter clerks (network processors). Clerks don't do the work — they read the form and drop it into an in-tray (the request queue). Behind the scenes, archivists (I/O handler threads) take forms from the tray, write in the ledgers or look things up, and put answers in each clerk's out-tray (response queue). Clerks hand the answers back. And some forms can't be answered yet — "tell me when three branches have a copy" — so they go to a waiting room: purgatory.

The stages

1. Acceptor. One thread per listener accepts TCP connections and assigns each to a processor. The docs describe the model as "a single acceptor thread and N processor threads which handle a fixed number of connections each".

2. Network processors (num.network.threads, default 3 — per listener). Each runs a non-blocking selector, reads complete requests off its sockets, and puts them in the shared request queue. After reading a request from a connection it mutes that connection until the response is sent, which is how Kafka keeps per-connection ordering: one request per connection is being processed at a time on the broker.

3. Request queue (queued.max.requests, default 500). When full, network threads block — they stop reading new requests. This is back-pressure, deliberately.

4. I/O handler threads (num.io.threads, default 8). They run KafkaApis.handle: validate, append to the log (page cache), or read for a fetch. This is where disk, CPU for decompression/validation, and lock contention show up.

5. Purgatory. If a request can't be completed right now, it's parked as a delayed operation and the I/O thread moves on. A produce with acks=all waits in the produce purgatory until the in-sync followers have fetched the data (or the request's timeout — the producer's request.timeout.ms, default 30 s — expires). A fetch waits in the fetch purgatory until fetch.min.bytes (default 1) are available or fetch.max.wait.ms (default 500) passes. That's how long-polling works without burning a thread per waiting consumer.

6. Response queue → processor → socket. Each processor has its own response queue; it writes the response and unmutes the connection.

▶ Inside one broker send a burst, then make the disk slow
Press ▶ to send 60 produce requests through the broker.
A tick-based toy model: 3 network threads, 8 I/O threads and a request queue scaled down to 24 slots (real default 500). Service and replication times are illustrative; the metric names are the real JMX names under kafka.network:type=RequestMetrics.

Reading the latency breakdown

For each request type (Produce, FetchConsumer, FetchFollower, …) the broker publishes TotalTimeMs, split into:

MetricStageIf it's high…
RequestQueueTimeMswaiting in the request queueI/O threads saturated — check RequestHandlerAvgIdlePercent
LocalTimeMsprocessing on the leader (append/read)disk, page-cache misses, compression/validation cost, lock contention
RemoteTimeMswaiting in purgatory (followers for acks=all; data for fetches)slow followers / ISR problems for produce; for consumer fetch it's mostly fetch.max.wait.ms and is normal
ResponseQueueTimeMswaiting for the network threadnetwork threads busy — check NetworkProcessorAvgIdlePercent
ResponseSendTimeMswriting the responseslow clients / network, big fetch responses

The monitoring docs suggest both idle-percent gauges stay "ideally > 0.3". RequestChannel's RequestQueueSize and the purgatory gauges (PurgatorySize for Produce and Fetch) complete the picture — the docs note the produce purgatory is "non-zero if ack=-1 is used" and the fetch one's "size depends on fetch.wait.max.ms in the consumer".

❓ There are no dumb questions
Q:
High RemoteTimeMs on FetchConsumer — is my cluster broken?
A:
Probably not. An idle consumer's fetch sits in purgatory up to fetch.max.wait.ms waiting for data. That's by design. RemoteTime on Produce is the one to worry about: it's the time followers take to replicate for acks=all.
Q:
Should I just crank num.io.threads to 64?
A:
Only if the threads are genuinely idle-starved and the bottleneck isn't the disk itself. More threads hammering a saturated disk just move the queueing from the request queue into the disk queue. Look at LocalTimeMs and disk metrics first. (And you couldn't jump 8 → 64 in one dynamic change anyway: thread-pool updates are limited to currentSize / 2 … currentSize * 2 per step.)
🧰 Workflow / use case

Producer p99 latency jumped from 20 ms to 800 ms. Triage in five steps:

  1. Look at TotalTimeMs,request=Produce on the brokers: if it's low, the delay is client-side (batching, linger.ms, max.block.ms, network).
  2. Split it: which of Queue / Local / Remote / ResponseQueue / ResponseSend dominates?
  3. Queue high → RequestHandlerAvgIdlePercent near 0 → I/O threads saturated; check what they're doing (Local).
  4. Remote high → followers slow: check follower fetch TotalTimeMs,request=FetchFollower, ISR shrinks, the network between brokers.
  5. ResponseQueue/Send high → network threads (NetworkProcessorAvgIdlePercent) or huge responses.
✏️ Sharpen your pencil

Your broker runs with defaults. Name the three thread/queue settings of this chapter and say which one you'd check first when RequestQueueTimeMs rises. Then read a metric from the command line.

Answer

num.network.threads (3), queued.max.requests (500), num.io.threads (8). Rising queue time means requests wait for an I/O thread — check RequestHandlerAvgIdlePercent and LocalTimeMs before touching num.io.threads. Thread counts are dynamically updatable broker configs:

bin/kafka-configs.sh --bootstrap-server localhost:9092 --entity-type brokers \
  --entity-default --alter --add-config num.io.threads=16
bin/kafka-jmx.sh --jmx-url service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi \
  --object-name kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercent

(The JMX tool needs the broker started with JMX_PORT=9999.)

🔬 Go deeper: the classes behind the stages

kafka.network.SocketServer creates an Acceptor per listener and a pool of Processors. A processor does requestChannel.sendRequest(req) and then selector.mute(connectionId); the connection is unmuted on ChannelMuteEvent.RESPONSE_SENT (or later if a quota throttle is active — Part IV).

kafka.network.RequestChannel holds the bounded request queue (queueSize = queued.max.requests). KafkaRequestHandler threads (the pool is KafkaRequestHandlerPool) poll it and call KafkaApis.handle, which dispatches on the API key.

DelayedOperationPurgatory (server-common) keeps delayed operations watched by keys (e.g. a partition) and a hierarchical TimingWheel in a SystemTimer for expiry. tryCompleteElseWatch tries to finish the operation immediately and otherwise registers it; events like "HW advanced" call checkAndComplete for the key.

DelayedProduce completes when every partition is satisfied: this broker is no longer leader, the replica is gone, or enough replicas have caught up (or an error). DelayedFetch lists its completion cases A–H in the source, including "the accumulated bytes … exceeds the minimum bytes", the fetch offset not being on the last segment, leadership change and "a diverging epoch was found" (next chapter). fetch.purgatory.purge.interval.requests and producer.purgatory.purge.interval.requests (both 1000) control how often completed entries are purged.

📌 Bullet points
  • Acceptor → network processors (3) → request queue (500) → I/O threads (8) → purgatory if needed → response queue → processor.
  • A full request queue blocks network threads: back-pressure, by design.
  • Connections are muted while a request is in flight on the broker, preserving per-connection order.
  • Purgatory parks acks=all produces and long-poll fetches without holding a thread.
  • Diagnose with TotalTimeMs = Queue + Local + Remote + ResponseQueue + ResponseSend, plus the two idle-percent gauges (> 0.3).

Leader epochs, truncation, unclean election & ELR

whose version of history wins after a crash?

Part II showed the happy path of replication: followers fetch, the high watermark (HW) advances, committed records are safe. This chapter is about the unhappy path: a leader dies holding records its followers never got, a new leader writes different records at the same offsets, and the old leader comes back. Two photocopies of the same ledger book now disagree about what's on line 6. Somebody has to tear out pages — the question is who, and how many.

🧠 Brain power

A returning follower knows its own log and its own (possibly stale) high watermark. Why is "truncate everything above my high watermark, then fetch" not safe? Hint: followers learn the HW one fetch round after the leader advances it.

Why the high watermark wasn't enough

Early Kafka truncated a restarting follower to its own HW. KIP-101 describes what went wrong. Because a follower learns the new HW only on its next fetch, its local HW lags. If it truncates to that stale HW and then becomes leader before re-fetching, records that were already committed vanish. And after a multi-broker power loss, replicas could end up with different records at the same offsets — divergent lineages that no amount of fetching would repair.

Leader epochs: numbering the reigns

The fix: every time the controller elects a new leader for a partition, the leader epoch goes up (the KRaft controller also bumps it on some other partition changes, such as certain reassignments), and every batch the leader writes is stamped with it (the partitionLeaderEpoch field in the batch header you met in Part II). Each replica also keeps a small leader epoch cache — a list of epoch → first offset of that epoch, persisted in the partition directory as leader-epoch-checkpoint.

Now a returning follower doesn't guess. It asks the leader: "the last epoch I have is 1 — where did epoch 1 end in your log?" The leader looks in its cache: epoch 2 started at offset 6, so epoch 1 ended at 6. The follower truncates everything from offset 6 up and fetches the leader's version. Only records that genuinely diverge are removed. In the ledger analogy: each reign of a head archivist uses a different ink colour, and the register at the front of the book says on which line each colour started. Comparing registers tells you exactly where two copies part ways.

▶ Three replicas, one history fail over cleanly, rescue with ELR, or break it with unclean election
epoch 1epoch 2lost committed record
Replicas A (leader), B, C; min.insync.replicas = 2. Pick a scenario.
Simplified: one partition, RF 3, offsets and timings illustrative. The ▼ marks the high watermark. Modern followers carry LastFetchedEpoch in their Fetch requests and get a DivergingEpoch back; older paths use a separate OffsetForLeaderEpoch request — the outcome is the same truncation point.

Unclean leader election: availability over truth

If every in-sync replica is gone, Kafka faces the dilemma the design docs spell out: wait for an ISR member to come back (maybe never), or promote whatever replica is alive, even if it's missing committed records. unclean.leader.election.enable (default false) chooses between them. When it's on and an out-of-sync replica becomes leader, its log becomes the source of truth: committed records it lacked are gone, and the other replicas truncate to match when they return. Consumers that had already read past that point detect it — with no automatic reset policy the consumer throws LogTruncationException with the first diverging offset.

In KRaft, turning the flag on dynamically doesn't act instantly: the controller checks leaderless partitions every unclean.leader.election.interval.ms (default 5 minutes). To force it now, the config docs point to kafka-leader-election.sh with the unclean option.

⚠️ Watch it!

Unclean election is data loss you asked for. Records acknowledged with acks=all can disappear, and consumers can see offsets reused for different data. Use it as a deliberate, per-topic emergency lever (e.g. a metrics topic where availability beats completeness), never as a cluster default for business data.

ELR: electing safely outside the ISR (KIP-966)

There's a middle ground the ISR alone can't see. With min.insync.replicas enforced, the HW can't advance while the ISR is smaller than min ISR. So a replica that was in the ISR when it dropped below min ISR still has every committed record — nothing new could be committed after it left. Such replicas are safe leaders even though they're not in the ISR any more.

Eligible Leader Replicas track exactly those. The KRaft controller stores them in the partition's metadata record (EligibleLeaderReplicas, plus LastKnownElr). When the ISR shrinks below min ISR, former ISR members move into the ELR — except replicas that had an unclean shutdown, which may have lost unflushed data. When the ISR is back at or above min ISR, the ELR is cleared. Leader election order becomes:

  1. If the ISR is not empty, pick from the ISR.
  2. Else, if the ELR is not empty, pick an unfenced ELR member.
  3. Else, the last known leader if it's unfenced (roughly the pre-4.0 behaviour when all replicas were offline).

ELR arrived in 4.0 behind the eligible.leader.replicas.version feature (off by default in 4.0) and is enabled by default on new clusters since 4.1. Enabling it has side effects on min.insync.replicas: it gets a cluster-level value, the broker-level setting is removed and can't be altered, and changing it at cluster or topic level clears ELR state.

❓ There are no dumb questions
Q:
Does ELR make unclean election unnecessary?
A:
It makes it necessary less often. ELR only helps when a safe replica exists outside the ISR. If every replica that has all committed data is permanently gone, you're back to the old dilemma: wait, or elect uncleanly.
Q:
Why does it matter whether a broker shut down cleanly?
A:
Kafka doesn't fsync every write by default. A broker that crashed may have lost records that were only in the page cache, so it can't be trusted to hold all committed data — the controller filters unclean-shutdown replicas out of the ELR.
✏️ Sharpen your pencil

On a local 4.3 cluster: check whether ELR is enabled, see a partition's ELR, and (in a lab only!) force an unclean election for a leaderless partition.

Answer
bin/kafka-features.sh --bootstrap-server localhost:9092 describe      # eligible.leader.replicas.version
bin/kafka-features.sh --bootstrap-server localhost:9092 upgrade --feature eligible.leader.replicas.version=1
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic orders   # shows Elr / LastKnownElr
bin/kafka-leader-election.sh --bootstrap-server localhost:9092 \
  --election-type unclean --topic orders --partition 0

The docs say ELR fields are exposed through the DescribeTopicPartitions API, which the admin client uses when describing topics.

🔬 Go deeper: where epochs live in code and protocol

org.apache.kafka.storage.internals.epoch.LeaderEpochFileCache — "a cache of (LeaderEpoch => Offset) mappings for a particular replica", where the offset is "the offset of the first message in each epoch". It's checkpointed by LeaderEpochCheckpointFile to leader-epoch-checkpoint.

FetchRequest v12+ carries LastFetchedEpoch; if the leader detects divergence, the FetchResponse contains DivergingEpoch: "the largest epoch and its end offset such that subsequent records are known to diverge". DelayedFetch completes immediately in that case ("Case H") so the follower can truncate. OffsetForLeaderEpoch still exists; its CurrentLeaderEpoch fences stale callers with FENCED_LEADER_EPOCH or UNKNOWN_LEADER_EPOCH. Consumers use the same machinery to validate their position after a leader change.

ELR logic lives in the controller's PartitionChangeBuilder: maybePopulateTargetElr() clears ELR when the target ISR ≥ min ISR, otherwise unions the current ISR into the ELR, removing the target ISR and unclean-shutdown replicas; an unclean election clears the ELR fields. PartitionRecord version 2 added the tagged fields EligibleLeaderReplicas and LastKnownElr.

📌 Bullet points
  • Every leader election bumps the leader epoch; batches are stamped with it; replicas keep an epoch → start-offset cache.
  • Returning followers truncate to where their last epoch ended on the leader — not to their stale HW (KIP-101).
  • Only uncommitted (never acked with acks=all) records get truncated in a clean failover.
  • unclean.leader.election.enable=false by default; turning it on trades committed data for availability.
  • ELR (KIP-966, default on new clusters since 4.1) lets the controller elect a safe non-ISR replica when the ISR fell below min ISR.

Tiered storage (KIP-405)

recent pages at the desk, old volumes in the warehouse

A Kafka broker's disk does two jobs at once: it's the hot write path, and it's the long-term archive. Most reads are tail reads served from the page cache; old data is touched rarely, for backfills and recovery — the docs make exactly this point. Yet keeping 90 days of data means buying 90 days of fast local disk, replicated three times, and moving all of it whenever you add a broker or replace a failed one.

Tiered storage splits the two jobs. The local tier is the log you know. The remote tier is an external store (the docs mention HDFS or S3) that receives completed segments. In the archive: the branch keeps the last few volumes on the reading-room shelves; finished volumes are shipped to a central warehouse, and a card index says which warehouse box holds which lines.

🧠 Brain power

Which segments may safely be shipped to the warehouse? Think about the active segment, and about records that aren't committed yet (or belong to open transactions).

The moving parts

RemoteStorageManager (RSM) — a plugin interface that copies segment data and its indexes to the remote store, fetches them back, and deletes them (copyLogSegmentData, fetchLogSegment, fetchIndex, deleteLogSegmentData). Kafka ships no production implementation: you configure a vendor or open-source plugin with remote.log.storage.manager.class.name and .class.path.

RemoteLogMetadataManager (RLMM) — keeps strongly consistent metadata about remote segments. The default, TopicBasedRemoteLogMetadataManager, stores it in the internal topic __remote_log_metadata; you must set remote.log.metadata.manager.listener.name so its internal clients know which listener to use.

RemoteLogManager (RLM) — the broker component that runs per-partition tasks: a copy task on the leader, an expiration task that deletes remote segments past retention, and a follower task. Segments eligible for copying are those that are not the active segment and whose end offset is below the last stable offset — "as remote storage should contain only committed/acked messages". That answers the brain question.

▶ Hot and cold tiers let time pass, read old data, then take the object store down
Broker local disk local.retention.ms = 2 h (illustrative)
Remote store retention.ms = 30 d
Each box is a segment (offsets inside). Press ⏩ to let segments roll, upload and age out of local disk.
Simplified: one partition on its leader; segment sizes, times and the disk meter are illustrative. In reality uploads run on remote.log.manager.copier.thread.pool.size threads and remote reads on remote.log.reader.threads.

Two retention clocks

A tiered topic (remote.storage.enable=true) has two retention settings. local.retention.ms / local.retention.bytes decide how long segments stay on the broker; retention.ms / retention.bytes decide how long the data exists at all (in the remote tier). The local values default to -2, meaning "use the full retention" — so you must set them explicitly to get any benefit, and they must not exceed the total retention. Crucially, a local segment becomes eligible for deletion only after it has been uploaded.

Reading old data

Consumers don't change at all. When a fetch asks for an offset that's no longer local, the leader serves it from the remote store: the read runs on the remote-reader thread pool (remote.log.reader.threads, default 10) and the fetch waits in a dedicated DelayedRemoteFetch purgatory (up to remote.fetch.max.wait.ms, default 500). Expect higher latency for these cold reads and budget for the object store's request costs.

⚠️ Watch it!

An object-store outage fills local disks. Since local deletion waits for a successful upload, a broken plugin, bad credentials or an unreachable bucket means segments pile up locally no matter what local.retention.ms says. Alert on remote copy lag and errors, and size local disks with headroom for an outage.

🤓 Deep dive

Limitations listed in the 4.3 docs: no support for compacted topics; tiered storage must be disabled on every topic before disabling it at the broker level; admin actions need clients 3.0+; no support for segments missing a producer snapshot file (topics created before 2.8.0).

Turning it off per topic (KRaft): remote.log.copy.disable=true makes remote data read-only (then set local.retention.* to match total retention or -2, or the disk can fill), or remote.storage.enable=false,remote.log.delete.on.disable=true deletes all remote data.

New in 4.3: the dynamic broker config follower.fetch.last.tiered.offset.enable (default false, KIP-1023). With it, a newly added follower that has no local data skips straight to the leader's earliest pending-upload offset instead of re-fetching data already in remote storage — the upgrade notes say this "reduces bootstrap time significantly for large tiered-storage topics".

ConfigDefaultWhat it doesUse it when
remote.log.storage.system.enable (broker)falseTurns on tiered storage in the brokerCluster-wide opt-in; needs an RSM plugin
remote.log.storage.manager.class.name / .class.pathnoneYour RSM plugin (Kafka ships none for production)Always, when enabled
remote.log.metadata.manager.listener.namenoneListener for the default topic-based RLMM clientsMandatory with the default RLMM
remote.storage.enable (topic)falseTier this topicLong-retention, append-only (non-compacted) topics
local.retention.ms / local.retention.bytes-2 (= use retention.ms / retention.bytes)How much stays on local diskSet to hours/days to actually shrink local disk
remote.log.reader.threads10Threads serving remote readsMany consumers backfilling from old offsets
remote.log.manager.copy.max.bytes.per.secondLong.MAX_VALUEUpload throttleUploads compete with replication/clients for bandwidth
follower.fetch.last.tiered.offset.enablefalseEmpty new follower skips already-tiered data (4.3)Adding brokers / reassigning big tiered partitions
✏️ Sharpen your pencil

The official quick start uses the test-only LocalTieredStorage plugin (built from the source tree) to simulate a remote store in a local directory. After configuring the broker, how do you create a tiered topic that keeps only a second locally, push enough data to roll segments, and prove offset 0 is served from "remote"?

Answer
bin/kafka-topics.sh --create --topic tieredTopic --bootstrap-server localhost:9092 \
  --config remote.storage.enable=true --config local.retention.ms=1000 --config retention.ms=3600000 \
  --config segment.bytes=1048576 --config file.delete.delay.ms=1000
bin/kafka-producer-perf-test.sh --bootstrap-server localhost:9092 --topic tieredTopic \
  --num-records 1200 --record-size 1024 --throughput -1
ls /tmp/kafka-remote-storage/kafka-tiered-storage/        # uploaded .log/.index/.timeindex/... files
bin/kafka-console-consumer.sh --topic tieredTopic --from-beginning --max-messages 1 \
  --bootstrap-server localhost:9092 --formatter-property print.offset=true

These are the docs' own commands. The local segment disappears only after its upload; the consumer read of offset 0 then has to go to the remote tier.

🔬 Go deeper: tasks, metadata and the fetch path

org.apache.kafka.server.log.remote.storage.RemoteLogManager schedules RLMCopyTask (leader: candidateLogSegments() skips the active segment and anything whose next segment starts beyond the LSO), RLMExpirationTask (enforces retention.ms/bytes on remote data) and RLMFollowerTask. Tasks run every remote.log.manager.task.interval.ms (30 s).

For each uploaded segment the RSM receives the log file plus offset index, time index, transaction index, producer snapshot and a leader-epoch checkpoint — visible in the quick-start listing as .index, .timeindex, .snapshot, .leader_epoch_checkpoint files. The RLMM records segment lifecycle events in __remote_log_metadata (default 50 partitions, RF 3, retention -1 = unlimited).

On the fetch path, ReplicaManager detects an offset below the local log start, creates a remote read and parks the fetch as DelayedRemoteFetch in its own purgatory; that purgatory's purge interval is 0 so completed results can be garbage-collected immediately. Remote index files are cached locally (remote.log.index.file.cache.total.size.bytes, 1 GiB).

📌 Bullet points
  • Two tiers: local segments on the broker, completed segments in a remote store via an RSM plugin (none shipped for production).
  • Only non-active segments below the LSO are uploaded; local copies are deleted only after a successful upload.
  • local.retention.* defaults to -2 (= full retention) — set it, or nothing shrinks.
  • Old-offset reads are transparent to clients but slower: remote reader threads + DelayedRemoteFetch.
  • Not for compacted topics. 4.3 adds follower.fetch.last.tiered.offset.enable for faster new followers.

Queues for Kafka: share groups (KIP-932)

many clerks, one book, a sign-out sheet per line

Consumer groups give you ordering and scale by handing each partition to exactly one member. That's perfect for ordered streams — and awkward for a work queue. Want 50 workers on a topic with 8 partitions? Tough: 42 sit idle. One slow record blocks everything behind it in that partition. And there's no built-in notion of "this record failed three times, stop trying".

Share groups are a second kind of group for exactly those workloads. They were previewed in 4.1 and declared production-ready in 4.2. The design docs list the differences: consumers cooperatively consume and a partition may be assigned to several consumers; there can be more consumers than partitions; records are acknowledged individually (optimised for batches); and delivery attempts are counted, so unprocessable records can be handled automatically. The price: no ordering guarantee between records handed to different consumers.

In the archive analogy: instead of assigning each clerk a whole book, the clerks share the book, and there's a sign-out sheet next to it. A clerk signs out a few lines, works on them, and ticks them "done" — or hands them back. If a clerk vanishes, their sign-outs expire and the lines go back into the pool. The sheet also counts how often each line was signed out; after too many attempts the line is stamped "unprocessable" and set aside.

▶ A share group on one partition three consumers, one partition — then crash one, then feed it a poison record
orders-0
availableacquired (locked)acknowledgedarchived
Three share consumers, one partition. Press ▶ to let them poll.
Simplified: one partition, 2 records per fetch, lock timeout compressed to a second or two. Delivery limit 5 = the default group.share.delivery.count.limit. The small number on each record is its delivery count; SPSO/SPEO are the share-partition start/end offsets.

Acquisition locks and the record lifecycle

When a share consumer fetches, the broker hands it available records and marks them acquired with a time-limited acquisition lock — 30 s by default (share.record.lock.duration.ms group config, broker default group.share.record.lock.duration.ms). While acquired, no other member of the group gets them. The holder then does one of:

Each acquisition bumps the record's delivery count. When a record would become available again but has already reached the limit (share.delivery.count.limit, broker default group.share.delivery.count.limit = 5), it's archived instead. That's your built-in poison-pill guard. In 4.3.1, archived simply means "no more delivery attempts": dead-letter queues for share groups (KIP-1191) exist only as groundwork in the source — a ShareGroupDLQ interface with a no-op implementation and the ARCHIVING record state — with no configuration and nothing actually written to a DLQ topic yet.

The broker also caps in-flight work per partition: group.share.partition.max.record.locks (default 2000 acquired records). Hit it and fetches return nothing until locks are released or time out.

Where the state lives

For each share-partition (group × topic-partition) the partition leader keeps the in-flight window between the share-partition start offset (SPSO) — everything below is done — and the share-partition end offset (SPEO). The durable version of this is written by the share coordinator to the internal topic __share_group_state (50 partitions, RF 3, min ISR 2 by default): a snapshot has the start offset plus state batches of (firstOffset, lastOffset, deliveryState, deliveryCount), where the state is 0 Available, 2 Acked or 4 Archived. Membership and assignment are handled by the group coordinator, with a heartbeat protocol (ShareGroupHeartbeat) similar to KIP-848.

🔥 Fireside chat
Consumer groupOne partition, one owner. You get strict order and a single bookmark per partition. Cheap and simple.
Share groupAnd your 42 extra workers stare at the wall. I let them all pull from the same partitions and track every record separately.
Consumer groupTracking every record costs state. And you scramble the order.
Share groupRight — use me when records are independent jobs, processed one at a time. If order matters, you're still the one to call.
ConfigDefaultWhat it doesUse it when
share.record.lock.duration.ms (group) / group.share.record.lock.duration.ms (broker)30000 (broker bounds 15000–60000)Acquisition lock lengthMatch to your worst normal processing time; RENEW for outliers
share.delivery.count.limit (group, new in 4.3) / group.share.delivery.count.limit5 (broker bounds 2–10)Attempts before a record is archivedLower for fail-fast, higher for flaky downstreams
share.partition.max.record.locks (group, new in 4.3) / group.share.partition.max.record.locks2000 (broker bounds 100–4000)Max acquired records per share-partitionMany consumers on few partitions need more in-flight
share.auto.offset.reset (group)latestWhere a new share group startsearliest to process the backlog
share.isolation.level (group)read_uncommittedWhether to deliver records of open/aborted transactionsread_committed downstream of transactional producers
share.acknowledgement.mode (consumer)implicitimplicit: next poll/commit accepts the batch; explicit: call acknowledge() per recordexplicit when you need RELEASE/REJECT per record
✏️ Sharpen your pencil

Start two console share consumers in the same share group on a 1-partition topic, make one reject everything, set the group's delivery limit, and inspect the group. Commands?

Answer
bin/kafka-console-share-consumer.sh --bootstrap-server localhost:9092 --topic jobs --group workers
bin/kafka-console-share-consumer.sh --bootstrap-server localhost:9092 --topic jobs --group workers --reject
bin/kafka-configs.sh --bootstrap-server localhost:9092 --alter --entity-type groups --entity-name workers \
  --add-config share.delivery.count.limit=3,share.auto.offset.reset=earliest
bin/kafka-share-groups.sh --bootstrap-server localhost:9092 --describe --group workers --members
bin/kafka-share-groups.sh --bootstrap-server localhost:9092 --describe --group workers --offsets

Both consumers receive records from the same partition. On a cluster with fewer than 3 brokers, first set share.coordinator.state.topic.replication.factor and share.coordinator.state.topic.min.isr to 1 — the upgrade notes call this out.

🔬 Go deeper: share-partition internals

The broker-side state machine is org.apache.kafka.server.share.fetch.RecordState: AVAILABLE, ACQUIRED, ACKNOWLEDGED, ARCHIVING, ARCHIVED (ARCHIVING per KIP-1191, the share-group dead-letter-queue KIP; org.apache.kafka.server.share.dlq.ShareGroupDLQ defines reasons such as DELIVERY_COUNT_EXCEEDED and CLIENT_REJECT, but only NoOpShareGroupDLQManager exists and the broker does not call it in 4.3.1). In-flight ranges are InFlightBatches holding an InFlightState with the delivery count, member id and an AcquisitionLockTimerTask. The poison-pill rule is literally: if (newState == AVAILABLE && ops != DECREASE && deliveryCount >= maxDeliveryCount) newState = ARCHIVED.

Protocol: ShareGroupHeartbeat (membership/assignment), ShareFetch (fetch + piggy-backed acknowledgements, with share sessions capped by group.share.max.share.sessions, 2000), ShareAcknowledge, and the coordinator-facing InitializeShareGroupState, ReadShareGroupState, WriteShareGroupState, DeleteShareGroupState. Share fetches that must wait use a ShareFetch purgatory (monitoring exposes PurgatorySize,delayedOperation=ShareFetch).

Feature flag: share.version — SV_1 is bound to metadata version IBP_4_2_IV0 and is the latest production level. 4.3 deprecates group.coordinator.rebalance.protocols; in 5.0 group types are controlled only by feature versions (group.version, streams.version, share.version).

📌 Bullet points
  • Share groups (GA in 4.2) let many consumers share partitions; more consumers than partitions is fine; ordering across consumers is not guaranteed.
  • Records are acquired with a time-limited lock (30 s default) and individually accepted, released, rejected or renewed.
  • Delivery count limit (default 5) archives poison records automatically; 4.3 made it a per-group config.
  • Durable state: SPSO + state batches in __share_group_state, written by the share coordinator.
  • Use them for independent jobs processed one at a time; keep consumer groups for ordered streams.

Kafka Streams internals

a stream processor that keeps its notebook in the archive

Kafka Streams is a library, not a cluster. You write a topology — "read orders, group by customer, count, write order-counts" — and run as many copies of your app as you like. The library splits the work, keeps local state, survives crashes and can give you exactly-once, using nothing but the Kafka features from this and the previous parts: consumer groups, compacted topics and transactions.

Archive analogy: each clerk keeps a private notebook (a state store) with running totals. Every change to the notebook is also written as a line in a dedicated ledger book in the archive (the changelog topic, compacted). If the clerk quits, a replacement rebuilds the notebook by reading that ledger — or, faster, takes over from a colleague who has been keeping a shadow copy all along (a standby replica).

Topology, sub-topologies, tasks

Your topology is grouped into sub-topologies: sets of connected processors that are linked to each other only through Kafka topics — typically a repartition topic, created when data must be re-keyed. Each sub-topology is instantiated once per input partition as a task, named <subtopology>_<partition> — so 0_2 is sub-topology 0 on partition 2. The docs stress that "the assignment of partitions to tasks never changes": a task is the fixed unit of parallelism, and the maximum parallelism is bounded by the number of input partitions. Threads (num.stream.threads, default 1) and instances only decide where tasks run.

State stores and changelogs

A stateful task owns a local store (RocksDB or in-memory). For each store Kafka Streams maintains a changelog topic named <application.id>-<storeName>-changelog, with one partition per task, and log compaction enabled (windowed stores use delete,compact). Repartition topics use delete with infinite retention; Streams purges them itself once records are consumed. When a task moves to another instance, its store is restored by replaying its changelog partition before processing resumes — that restore is usually the dominant cost of a rebalance.

▶ Tasks, state and failover kill an instance with and without standby replicas
changelog: counter-app-counts-changelog partitions 0–3 (compacted)
Four tasks (0_0…0_3) of one stateful sub-topology on three instances. Pick a failure.
Simplified: one sub-topology, one store, one thread per instance. Record counts and restore times are illustrative — real restore time depends on changelog size, network and the store.

Standby replicas and warm-up

num.standby.replicas (default 0) keeps shadow copies of each task's store on other instances by continuously reading the changelog. On failover the task is assigned where a standby exists, so only a small tail needs replaying. Since 2.6 the classic assignor also only assigns a stateful task to an instance with a caught-up copy if one exists (within acceptable.recovery.lag, default 10000 records), warming others up in the background with up to max.warmup.replicas (default 2) extra "warm-up" tasks.

Exactly-once v2

processing.guarantee is at_least_once by default; exactly_once_v2 turns on the Chapter-13 machinery end to end: output records, changelog writes and input offsets are committed in one transaction per commit, consumers read with read_committed, and commit.interval.ms drops from 30000 to 100 ms by default. The docs warn that EOS with a replication factor below 3 "effectively voids EOS" — use RF 3 and min.insync.replicas=2 for all involved topics.

⚠️ Watch it!

Without standbys, a crash can mean minutes of downtime per task. Replaying a multi-GB changelog before a task processes anything again is the classic Streams outage. Budget restore time, enable standby replicas for large stores, and don't let changelog topics lose compaction (check cleanup.policy on the internal topics).

The Streams rebalance protocol (KIP-1071)

Classically, Streams runs on a consumer group with a client-side assignor (StreamsPartitionAssignor) that computes task assignments during a group-wide rebalance. KIP-1071 applies the KIP-848 idea to Streams: the app registers a dedicated streams group, sends its topology to the broker in StreamsGroupHeartbeat (sub-topologies, source, changelog and repartition topics), and the broker computes active and standby task assignments continuously and incrementally (the heartbeat has a WarmupTasks field, but warm-up tasks are not supported yet). The core feature set is production-ready in 4.2, and the feature is enabled by default on new 4.2 clusters (streams.version=1); clients opt in with group.protocol=streams.

The docs list what's not yet supported: static membership, significant topology changes (need a new group), the high-availability assignor (so no warm-up tasks or rack awareness — only a sticky assignor), regex subscriptions, online migration, and supplying the main consumer via a custom KafkaClientSupplier. Migration is offline only; the upgrade notes recommend against migrating classic → streams on 4.2.0 because of a broker bug fixed in 4.2.1.

ConfigDefaultWhat it doesUse it when
processing.guaranteeat_least_onceexactly_once_v2 = transactions + read_committedResults must not double-count
num.standby.replicas0Shadow copies of each task's stateLarge state, fast failover needed
num.stream.threads1Threads per instance running tasksMore cores than instances
commit.interval.ms30000 (100 with EOS)How often offsets/transactions are committedLatency vs throughput under EOS
acceptable.recovery.lag / max.warmup.replicas10000 / 2"Caught-up" threshold and warm-up budget (classic assignor)Tuning task movement on scale-out
group.protocolclassicstreams = KIP-1071 broker-side assignment4.2+ brokers and clients, no unsupported features in use
group.streams.num.standby.replicas (broker) / streams.num.standby.replicas (group)0 (max 2)Standbys under the streams protocol, set on the broker/groupStreams groups — the broker assigns standbys
✏️ Sharpen your pencil

You run a Streams app with group.protocol=streams and application.id=counter-app on a 4.3 cluster. How do you check the feature, list/describe the streams group with its members and task assignment, and give it one standby replica per task?

Answer
bin/kafka-features.sh --bootstrap-server localhost:9092 describe          # streams.version
bin/kafka-streams-groups.sh --bootstrap-server localhost:9092 --list
bin/kafka-streams-groups.sh --bootstrap-server localhost:9092 --describe --group counter-app --members --verbose
bin/kafka-configs.sh --bootstrap-server localhost:9092 --alter --entity-type groups \
  --entity-name counter-app --add-config streams.num.standby.replicas=1

Under the streams protocol, standbys are a broker/group setting (bounded by group.streams.max.standby.replicas, default 2), not the client's num.standby.replicas.

🔬 Go deeper: what the broker learns about your app

StreamsGroupHeartbeatRequest carries MemberEpoch, a Topology (with Epoch and Subtopologies, each listing SourceTopics, StateChangelogTopics, RepartitionSinkTopics, RepartitionSourceTopics and CopartitionGroups), the member's current ActiveTasks / StandbyTasks / WarmupTasks, its ProcessId, UserEndpoint (for interactive queries), and TaskOffsets / TaskEndOffsets so the broker can judge how caught-up each member's state is. ShutdownApplication lets one member request a coordinated shutdown of the whole app.

Broker defaults (GroupCoordinatorConfig): streams session timeout 45000 ms (bounds 45000–60000), heartbeat 5000 ms (5000–15000), initial rebalance delay 3000 ms, assignment interval 1000 ms (new in 4.3, KIP-1263).

org.apache.kafka.streams.processor.TaskId.toString() is subtopology + "_" + partition (prefixed with the topology name for named topologies). With EOS v2 each stream thread uses one transactional producer for all its tasks.

📌 Bullet points
  • Topology → sub-topologies (linked only via topics, e.g. repartition topics) → one task per input partition; tasks are the fixed unit of parallelism.
  • State stores are backed by compacted changelog topics; restoring = replaying the changelog.
  • num.standby.replicas (default 0) keeps warm copies for fast failover.
  • exactly_once_v2 = transactions over output, changelogs and offsets; commit interval 100 ms; use RF 3.
  • KIP-1071 streams groups (group.protocol=streams, core GA in 4.2): broker-side task assignment, with documented gaps (static membership, warm-ups, regex, online migration).
Part IV

Mastery: operations

Running Kafka for other people: fair sharing between tenants, tuning with a method instead of folklore, diagnosing incidents from symptoms, copying data between cities, and upgrading without drama.

Quotas & multi-tenancy

the archive's rule: nobody hogs the counter

Up to now your archive served one well-behaved city department. Real clusters are shared: payments, search, analytics and one intern's load test all write to the same branches. The official docs call the result "noisy neighbors": one tenant saturates the network or the request threads, and everyone else's latency goes through the roof.

In the ledger-archive picture, a quota is a counter rule: "each patron group may carry out at most X pages per second at this branch". A patron who goes faster isn't turned away. The clerk just says "sit down for a moment" and doesn't serve them until they're back under the limit. That is exactly how Kafka works: quotas throttle by delaying, not by rejecting (with one exception, below).

What can you limit?

Kafka has two families of client quotas. Network bandwidth quotas are byte-rate thresholds: producer_byte_rate and consumer_byte_rate, in bytes/sec. Request rate quotas (request_percentage) cap the share of broker network and I/O thread time a group may use. A quota of n% means n% of one thread, so the total capacity is (num.io.threads + num.network.threads) × 100%. On top of those, controller_mutation_rate limits how fast a client may create topics, create partitions and delete topics, counted in partitions.

There are also broker-side connection limits, which aren't tied to a client identity: max.connections.per.ip, max.connection.creation.rate, and a per-IP connection_creation_rate quota. All of them default to "unlimited" (Integer.MAX_VALUE).

🧠 Brain power

The multi-tenancy docs claim that request-rate quotas often matter more than bandwidth quotas. How can a tenant hurt everyone else while moving hardly any bytes? (Think about thousands of tiny produce requests with linger.ms=0, or a consumer with fetch.max.wait.ms set near zero.)

Who is "a client"? Users, client-ids and precedence

A quota applies to a group of clients. The identity is the authenticated user principal, the application-chosen client.id, or the pair (user, client-id). Every connection in a quota group shares the quota. If (user=test-user, client-id=test-client) gets 10 MB/s, all producer instances with that pair split those 10 MB/s together. For a given connection, the most specific matching quota wins, in this order:

  1. matching user and client-id
  2. matching user, default client-id
  3. matching user
  4. default user, matching client-id
  5. default user, default client-id
  6. default user
  7. matching client-id
  8. default client-id

Quota overrides are written to the KRaft metadata log. Every broker reads them and they take effect immediately, so you never need a rolling restart to re-tune a tenant. By default there is no quota at all: clients get unlimited throughput until you set one.

▶ The counter clerk send traffic, then remove the quota and watch the neighbours
under quotaover quotamuted (throttled)
Three tenants share one broker. Press ▶ to send one measurement window of traffic.
Illustrative numbers: quota 10 MB/s per broker, one 2-second window. The real broker measures rates over quota.window.num (default 11) samples of quota.window.size.seconds (default 1 s), and the delay formula is the one in QuotaUtils.throttleTime. The latency effect in "Remove all quotas" is qualitative.

How throttling actually works

When a group goes over its quota, the broker computes how long the client must stay silent for its average rate to drop back under the limit. It returns the response immediately, with that delay in the ThrottleTimeMs field (a fetch response carries no data in that case). Then it mutes the channel: it stops reading from that client's socket until the delay is over. A modern client sees a non-zero throttle time and holds back on its own. An old or misbehaving client that keeps sending anyway still gets nowhere, because its requests sit unread in a muted socket. The docs describe this as blocking the client "from both sides".

Why per broker and not cluster-wide? A cluster-wide limit would need the brokers to share their quota usage with each other in real time. The design docs say bluntly that this "can be harder to get right than the quota implementation itself!" So a 10 MB/s quota means 10 MB/s at each broker. A tenant whose partitions sit on six brokers can move up to six times that in total.

❓ There are no dumb questions
Q:
So a throttled producer never gets an error?
A:
For byte-rate and request quotas, no. It just slows down, and you'll see it in produce-throttle-time-avg / fetch-throttle-time-avg on the client and in the broker's throttle-time attribute. The exception is controller_mutation_rate: once the strict mutation quota is exhausted, operations above it are rejected with THROTTLING_QUOTA_EXCEEDED. The Java admin client retries them by default (CreateTopicsOptions.retryOnQuotaViolation is true).
Q:
Why are samples so short (1 s)? Wouldn't a long window be smoother?
A:
The design docs argue the opposite. Large windows (for example 10 × 30 s) let a client burst for a long time and then punish it with a long stall. Many small windows catch a violation fast and correct it with short delays.
✏️ Sharpen your pencil

On your local 4.3 cluster, give the client-id loadtest a producer quota of 1 MB/s, describe it, then run the perf tool with that client id and watch the throughput settle.

Answer
bin/kafka-configs.sh --bootstrap-server localhost:9092 --alter \
  --add-config 'producer_byte_rate=1048576' --entity-type clients --entity-name loadtest
bin/kafka-configs.sh --bootstrap-server localhost:9092 --describe \
  --entity-type clients --entity-name loadtest
bin/kafka-producer-perf-test.sh --bootstrap-server localhost:9092 --topic perf \
  --num-records 200000 --record-size 1000 --throughput -1 \
  --command-property client.id=loadtest

Throughput should hover near 1 MB/s instead of whatever your laptop can push. --entity-default instead of --entity-name sets the default for all client-ids. (--bootstrap-server and --command-property are the 4.2+ spellings from KIP-1147. The older --producer-props still works but is deprecated.)

⚠️ Watch it!

Replication throttles set by kafka-reassign-partitions.sh --throttle are a different mechanism: leader.replication.throttled.rate / follower.replication.throttled.rate plus per-topic lists of throttled replicas. Forget to run --verify after the reassignment and the throttle stays behind, quietly throttling normal replication. Set it too low, below max(BytesInPerSec), and the move never finishes.

🧰 Workflow / use case

Onboarding a new tenant onto a shared cluster, the way the multi-tenancy docs recommend:

  1. Give them a namespace with a hierarchical prefix, for example acme.infosec., and turn off auto.create.topics.enable (but don't rely on that alone).
  2. Grant prefixed ACLs: kafka-acls.sh --add --allow-principal User:Alice --producer --resource-pattern-type prefixed --topic acme.infosec.
  3. Set per-user quotas for bytes and request_percentage, plus controller_mutation_rate if they create topics programmatically.
  4. Set retention.ms/retention.bytes on their topics and alert on kafka.log:type=Log,name=Size.
  5. Graph their throttle-time. Throttling that never stops means they need a bigger contract, not a silent raise.
🔬 Go deeper: the delay formula and the ThrottledChannel queue

ClientQuotaManager records each request's bytes (or thread time) in a per-group Sensor with a rate Quota. When recording breaks the bound, the metrics library throws QuotaViolationException, and QuotaUtils.throttleTime computes

throttleTimeMs = (observed − bound) / bound × windowSize

using "the precise window used by the rate calculation". That is the time the client must stay idle for the average to fall back to the bound. ClientQuotaManager.throttle(...) then records the value in the throttle-time sensor and puts a ThrottledChannel in a delay queue. A ThrottledChannelReaper thread takes it out when the time is up and notifies the socket server that throttling is done, so the channel is unmuted. ControllerMutationQuotaManager specialises this: its quota is strict ("does not accept any mutations once the quota is exhausted") for request versions that support it, and permissive for older ones. You can replace the whole policy with client.quota.callback.class (a ClientQuotaCallback), for example to assign quotas per tenant group instead of per principal.

📌 Bullet points
  • Quotas: producer_byte_rate, consumer_byte_rate, request_percentage, controller_mutation_rate, plus IP connection limits. Default: unlimited.
  • Entities: user, client-id, or (user, client-id). The most specific match wins, and all connections in the group share one quota.
  • Enforcement is per broker, by delaying: the response carries ThrottleTimeMs and the channel is muted. Mutation quotas reject with THROTTLING_QUOTA_EXCEEDED.
  • Changes live in the metadata log and apply immediately, with no restart.
  • Watch throttle-time (broker) and *-throttle-time-avg (client).

Performance tuning playbook

measure, change one knob, measure again

Every Kafka performance "secret" you've read on a forum is one of four trades: latency for throughput (batch more), CPU for bytes (compress), safety for speed (acknowledge less), and memory and file handles for parallelism (more partitions). This chapter gives you the knobs for each trade, their real defaults, and a method for turning them without guessing.

In the archive, the clerks are fastest when patrons bring bundles of pages, not single sheets. They're slower when every bundle must be photocopied at two other branches before the patron leaves. And they're slowest of all when their desks are cluttered with ten thousand half-used ledger books. Keep those three images in mind; they explain almost every row below.

The method (before any knob)

  1. Reproduce with the perf tools: kafka-producer-perf-test.sh and kafka-consumer-perf-test.sh give you a baseline you can compare against.
  2. Find the bottleneck, don't assume it. The broker's request metrics split TotalTimeMs into RequestQueueTimeMs, LocalTimeMs, RemoteTimeMs, ResponseQueueTimeMs and ResponseSendTimeMs (chapter 15's pipeline). High queue time means the threads are busy. High local time means the disk or log append is slow. High remote time on produce means you're waiting for followers (acks=all).
  3. Check thread idleness: NetworkProcessorAvgIdlePercent and RequestHandlerAvgIdlePercent should ideally stay above 0.3 according to the monitoring docs.
  4. Change one knob, re-run, and keep a log. Two knobs at once teach you nothing.
▶ Producer tuning desk change a knob, or press a preset
Defaults loaded. Change a knob or press ▶.
Heuristic, illustrative model, not a benchmark. The bars show the direction each knob pushes (qualitative scores from 0 to 100). Real results depend on record size, key distribution, partition count, network and hardware. "Latency" means latency at light load. Under heavy load, batching usually lowers it, as the 4.0 release notes for linger.ms point out.

Producer knobs

ConfigDefaultWhat it doesUse it when
linger.ms5 (was 0 before 4.0)Upper bound on how long a not-yet-full batch waits for more records. A full batch is sent right away.Raise it for throughput-heavy pipelines (how far is a judgement call; measure). The docs' example: 50 adds up to 50 ms of latency when there's no load.
batch.size16384Per-partition batch buffer size, and the upper bound of a batch. 0 disables batching.Raise it with linger.ms when records are small and throughput matters. Each batch allocates the full buffer, so memory use grows.
compression.typenonegzip, snappy, lz4, zstd. Compresses whole batches.Often worth it for text/JSON (heuristic; measure with compression-rate-avg). Better batching means better compression. Levels: compression.{gzip,lz4,zstd}.level.
acksallHow many replicas must have the batch before the send succeeds.Keep all with min.insync.replicas ≥ 2 for anything you'd hate to lose. 1 trades durability for lower latency.
buffer.memory33554432 (32 MiB)Total memory for unsent batches. When it's full, send() blocks up to max.block.ms (60000).Raise it if bufferpool-wait-ratio is non-zero with many partitions per producer.
max.request.size1048576Largest request the producer will build. It also caps a single record.Raise it (with broker/topic max.message.bytes) only for genuinely large records.

Consumer knobs

ConfigDefaultWhat it doesUse it when
fetch.min.bytes1The broker waits until this much data is available (or fetch.max.wait.ms passes) before answering.Raise it for throughput and fewer fetch requests, at "some additional latency".
fetch.max.wait.ms500Maximum time the broker holds a fetch in purgatory waiting for fetch.min.bytes.Lower it only if you raised fetch.min.bytes and latency suffers.
fetch.max.bytes52428800 (50 MiB)Soft cap per fetch response. The first batch is always returned so the consumer can make progress.Lower it for memory-constrained consumers.
max.partition.fetch.bytes1048576Per-partition cap per fetch (also soft).Relevant when a few partitions are very hot.
max.poll.records500Records returned per poll(). It doesn't change fetching; records are cached and handed out incrementally.Lower it when processing is slow, so each loop finishes inside max.poll.interval.ms (300000).

Broker knobs

ConfigDefaultWhat it doesUse it when
num.network.threads3Threads that read requests from and write responses to sockets (per listener).Raise it when NetworkProcessorAvgIdlePercent is low.
num.io.threads8Request handler threads that process requests, which "may include disk I/O".Raise it when RequestHandlerAvgIdlePercent is low and RequestQueueTimeMs grows.
queued.max.requests500Request queue depth before network threads block.Rarely. A full queue is a symptom; fix the slow stage behind it.
num.replica.fetchers1Fetcher threads per source broker for replication.Followers lag (MaxLag growing) while the leader's disk and network are fine.
socket.send/receive.buffer.bytes102400TCP buffer sizes on the broker socket server.High-latency links (cross-DC). The OS max socket buffer must allow it too.
num.recovery.threads.per.data.dir2 (was 1 before 4.0)Threads for log recovery on startup and flushing at shutdown.Faster restarts after an unclean shutdown, "at the expense of extra IO cycles".

Partitions: the parallelism you pay for

The partition count is the maximum parallelism of a consumer group, and each partition must fit entirely on one server. So more partitions means more parallel consumers and spreads the load across more brokers. But each partition's segments cost file descriptors and memory map areas. The OS docs recommend at least 100000 file descriptors for the broker as a starting point. Each segment uses 2 map areas (index and time index), and the docs warn that 50000 partitions on one broker means 100000 map areas, which "likely" crashes it with OutOfMemoryError (Map failed) at a typical vm.max_map_count of about 65535. Remember too that you can add partitions but never remove them, and adding them remaps keys.

OS and disks

Kafka leans on the page cache. The hardware docs give a back-of-the-envelope memory estimate: buffer about 30 seconds of writes, write_throughput × 30. Keep Kafka data on dedicated drives (not shared with application logs), mount with noatime, and prefer XFS. In the docs' comparison XFS gave "160ms vs. 250ms+" request local time against the best EXT4 configuration. Keep the default flush settings (no application fsync): durability comes from replication, and forcing fsync gives the OS less room to reorder writes and adds latency.

🔥 Fireside chat
linger.ms
Everyone blames me for latency. I wait five lousy milliseconds!
acks=all
At least you're predictable. I wait for the slowest in-sync follower, and nobody can tell me how long that takes.
linger.ms
But without me, you'd be doing that wait for a hundred tiny batches instead of one fat one.
acks=all
Fine. Let's agree: under load we're a team, and at idle we're both pure overhead that buys safety and efficiency.
✏️ Sharpen your pencil

Run the producer perf test twice against a local topic: once with defaults, once with linger.ms=50 batch.size=262144 compression.type=lz4. Which metric should improve, and which should get worse at light load?

Answer
bin/kafka-producer-perf-test.sh --bootstrap-server localhost:9092 --topic perf \
  --num-records 500000 --record-size 200 --throughput -1
bin/kafka-producer-perf-test.sh --bootstrap-server localhost:9092 --topic perf \
  --num-records 500000 --record-size 200 --throughput -1 \
  --command-property linger.ms=50 batch.size=262144 compression.type=lz4

Records/sec and MB/s should go up (fewer, fuller, compressed batches). With --throughput set to a low rate instead of -1, average latency grows by up to the linger time, because batches wait to fill. Add --print-metrics and compare batch-size-avg, compression-rate-avg and record-queue-time-avg. The exact numbers depend on your machine.

🔬 Go deeper: where the time goes inside the producer

The producer's sender metrics (SenderMetricsRegistry) are your tuning dashboard. record-queue-time-avg is how long batches sit in the RecordAccumulator (dominated by linger.ms at light load). batch-size-avg shows whether batches actually fill. compression-rate-avg shows what compression buys. request-latency-avg is the network plus broker round trip, and produce-throttle-time-avg exposes quotas (chapter 20). If record-queue-time-avg is large while batch-size-avg is small, you're lingering for nothing. If bufferpool-wait-ratio is non-zero, buffer.memory is the wall. On the broker, compare LocalTimeMs against RemoteTimeMs for request=Produce to tell a slow disk from a slow follower.

📌 Bullet points
  • Tuning is a set of trades: latency vs throughput (linger.ms, batch.size, fetch.min.bytes), CPU vs bytes (compression), safety vs speed (acks).
  • Defaults: linger.ms 5, batch.size 16384, compression.type none, acks all, max.poll.records 500, fetch.min.bytes 1.
  • Diagnose with the request-time breakdown and thread idle percentages (ideally > 0.3) before adding threads.
  • Partitions buy parallelism but cost file descriptors (≥100000 recommended) and 2 map areas per segment.
  • Page cache, dedicated disks, XFS, noatime, and no forced fsync.

Failure scenarios & troubleshooting

symptom → cause → fix, without panic

At 3 a.m. nobody reads the design docs. You get a symptom ("lag is climbing", "producers throw NotEnoughReplicasException") and you need to go from that symptom to a cause and a safe fix in minutes. This chapter is that map. It leans on everything you already know: ISR and high watermark (ch. 10), rebalances (ch. 11), idempotence (ch. 12), the request pipeline (ch. 15), leader epochs and ELR (ch. 16), KRaft (ch. 9).

Archive version: an inspector walks in, checks a few gauges on the wall (metrics), asks the front desk a few questions (CLI tools), and only then moves furniture. The worst incidents come from moving furniture first, like adding consumers, deleting data or lowering min.insync.replicas, before knowing which gauge is red.

▶ Triage board pick a symptom and follow the lit path
Pick a symptom, or press ▶ to walk the most common one.
Each path shows the first checks and the most likely causes. Real incidents can combine several. Metric and command names are real (4.3). The consumer group picture in 💥 uses illustrative lag numbers.

The symptom table

SymptomLikely causeFix
Consumer lag keeps growing (LAG column, records-lag-max)Processing slower than the produce rate; too few consumers or partitions; frequent rebalances pausing work; a quota throttling the fetch.Profile the handler first. Scale consumers up to the partition count; beyond that, add partitions (keys will remap). Check fetch-throttle-time-avg.
UnderReplicatedPartitions > 0A broker is down, or a follower can't keep up (disk, network, GC), so the ISR shrank.Find the common broker in --under-replicated-partitions. Restore it, or fix its disk/network. Consider more num.replica.fetchers.
Producers get NotEnoughReplicasException; UnderMinIsrPartitionCount > 0ISR size < min.insync.replicas while acks=all. The broker refuses the write on purpose.Bring replicas back into the ISR. Lowering min.insync.replicas trades away the durability you asked for, so do it only as a conscious, temporary decision.
Rebalance storm (group keeps flipping state; throughput collapses)Poll loop slower than max.poll.interval.ms (300 s); members missing heartbeats (session.timeout.ms 45 s); rolling deploys without static membership.Lower max.poll.records or speed up processing; use group.instance.id; move to group.protocol=consumer (KIP-848, incremental and server-side).
RecordTooLargeException / MESSAGE_TOO_LARGE; BytesRejectedPerSec risingRecord above producer max.request.size (1048576), or batch above broker message.max.bytes / topic max.message.bytes (1048588).Raise the producer and topic limits consistently (followers still replicate oversized batches, because replica.fetch.max.bytes is a soft limit), or store big payloads elsewhere and send a reference.
Disk full / OfflineLogDirectoryCount > 0; clients see KafkaStorageExceptionRetention too long for the volume; skewed partitions; a failed disk.Check with kafka-log-dirs.sh --describe. Shorten retention or add capacity. Move replicas with kafka-reassign-partitions.sh (with a throttle). Replace the failed disk.
Controller / quorum lost: no metadata changes, topic creation hangsMajority of controllers down or partitioned. The sum of ActiveControllerCount over the controllers ≠ 1.kafka-metadata-quorum.sh describe --status and --replication. Restore a majority of voters before anything else.
ISR flapping (IsrShrinksPerSec / IsrExpandsPerSec non-zero without failures)Followers repeatedly fall more than replica.lag.time.max.ms (30000) behind: GC pauses, saturated disks or network.Fix the resource. Don't just raise the lag time, because that hides slow replicas inside the ISR and slows acks=all.
Offset out of range (consumer jumps to start or end)The committed offset was deleted by retention (the consumer was down longer than retention), so auto.offset.reset kicks in.Decide the policy explicitly (earliest, latest, by_duration:…, or none to fail loudly). Recover with --reset-offsets --to-datetime.
OfflinePartitionsCount > 0Every replica eligible to lead is down.Bring back a replica from the ISR or ELR (ch. 16). Unclean election (kafka-leader-election.sh --election-type unclean) is a last resort that can lose acknowledged data.
⚠️ Watch it!

NotEnoughReplicasAfterAppendException is the nasty sibling. The broker had already appended the batch when it discovered the ISR was too small. Its javadoc says it outright: "Producer retries will cause duplicates", unless the producer is idempotent (enable.idempotence defaults to true; ch. 12). One more reason not to turn idempotence off.

The dashboard: metrics worth an alert

MBeanHealthy value (per docs)What red means
kafka.server:type=ReplicaManager,name=UnderReplicatedPartitions0Replication is behind somewhere.
kafka.server:type=ReplicaManager,name=UnderMinIsrPartitionCount0acks=all writes to these partitions are failing.
kafka.controller:type=KafkaController,name=OfflinePartitionsCount0Partitions with no leader: unavailable.
kafka.controller:type=KafkaController,name=ActiveControllerCountexactly one node has 10 everywhere means no active controller.
kafka.server:type=ReplicaManager,name=IsrShrinksPerSec / IsrExpandsPerSec0 outside broker restartsFlapping followers.
kafka.controller:type=ControllerStats,name=UncleanLeaderElectionsPerSec0Possible data loss occurred.
kafka.log:type=LogManager,name=OfflineLogDirectoryCount0A disk or log dir failed.
kafka.network:type=SocketServer,name=NetworkProcessorAvgIdlePercent; kafka.server:type=KafkaRequestHandlerPool,name=RequestHandlerAvgIdlePercentideally > 0.3Threads are saturated; queues will grow.
kafka.network:type=RequestMetrics,name=TotalTimeMs,request=Produce (and its parts)stableBreak it down: queue / local / remote / response.
consumer records-lag-max (published by the consumer, not the broker)boundedThe consumer is falling behind.
✏️ Sharpen your pencil

Your pager says "UnderMinIsrPartitionCount = 12". Write the three commands you'd run first on a 4.3 cluster, and what you're looking for in each.

Answer
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --under-min-isr-partitions
bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --under-replicated-partitions
bin/kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe --status

(1) Which partitions, and which replica is missing from Isr? Usually one broker id repeats. (2) How wide the damage is: under-replicated but still ≥ min ISR is a warning, not an outage. (3) Whether the controller quorum is healthy (a leader, small lag), so leadership and ISR changes can actually be committed. Then check that broker's disk (kafka-log-dirs.sh --bootstrap-server localhost:9092 --describe --broker-list 2) and its logs.

🧰 Workflow / use case

A consumer group lost its place after a long outage and jumped to latest. Replay the last 6 hours safely:

  1. Stop all instances of the group. --reset-offsets requires inactive members.
  2. Dry run (the default mode): bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --reset-offsets --group billing --topic invoices --by-duration PT6H
  3. Check the NEW-OFFSET column, and optionally save it with --export.
  4. Re-run with --execute, then start the consumers. Processing must be idempotent, because you will see some records again.
🔬 Go deeper: reading the error codes

Every failed response carries an Errors code, and the broker counts them in kafka.network:type=RequestMetrics,name=ErrorsPerSec,request=…,error=… (error=NONE counts successes). Useful ones: OFFSET_OUT_OF_RANGE (1), MESSAGE_TOO_LARGE (10), RECORD_LIST_TOO_LARGE (18, a batch larger than the segment size), NOT_ENOUGH_REPLICAS (19, rejected before append), NOT_ENOUGH_REPLICAS_AFTER_APPEND (20, written but under-replicated), THROTTLING_QUOTA_EXCEEDED (89). Graphing ErrorsPerSec by error tag often explains an incident faster than any log. For disk trouble, KafkaStorageException's javadoc describes the path: after logs are loaded, an IOException triggers LogDirFailureChannel.maybeAddOfflineLogDir(), the directory goes offline, and clients are told to refresh metadata and retry against the new leaders.

📌 Bullet points
  • Diagnose before acting: metric → CLI → cause → fix.
  • First commands: kafka-consumer-groups --describe, kafka-topics --describe --under-replicated-partitions / --under-min-isr-partitions, kafka-metadata-quorum describe --status.
  • Consumers beyond the partition count sit idle, so they can't fix lag.
  • NotEnoughReplicas is a safety feature. …AfterAppend duplicates on retry without idempotence.
  • Offset-out-of-range means retention ate your position; make auto.offset.reset a deliberate choice.

Geo-replication with MirrorMaker 2

a copying office in another city

Replication inside a cluster (ch. 5, 10) keeps photocopies in other branches of the same archive, coordinated by one city council. Geo-replication is different: two independent archives in two cities, each with its own council, its own offsets and its own consumer groups. Between them sits a copying office that reads one archive and writes into the other. In Kafka that office is MirrorMaker 2 (MM2). The original MirrorMaker 1 was removed in 4.0.

People use it for disaster recovery, for feeding edge clusters into a central one, for separating production from testing, for cloud migrations, and for legal requirements about where data lives. MM2 replicates topic data and topic configurations, consumer group offsets and ACLs. It preserves partitioning and discovers new topics and partitions automatically.

Built from Connect parts

MM2 is not a separate engine. It's a set of Kafka Connect source connectors, which is why it inherits Connect's scaling (run more MM2 processes and they share the work) and Connect's metrics:

Flows, aliases and remote topics

You name clusters with aliases and enable directional flows source->target. A flow is off until you set {source}->{target}.enabled = true. Topologies are just sets of flows: active/passive A->B, active/active A->B, B->A, aggregation A->K, B->K, fan-out K->A, K->B.

clusters = us-west, us-east
us-west.bootstrap.servers = broker1-west:9092
us-east.bootstrap.servers = broker3-east:9092
us-west->us-east.enabled = true
us-west->us-east.topics = orders.*

With the DefaultReplicationPolicy, a replicated ("remote") topic is renamed {source}.{topic}, so orders from us-west becomes us-west.orders in us-east. That prefix is not decoration. It stops two clusters from writing into the same topic-partition, and it's how active/active avoids infinite loops: MM2 knows us-west.orders came from us-west and won't send it back. IdentityReplicationPolicy keeps names unchanged, but its javadoc warns that MM2 then "is not able to prevent cycles", so use it only for one-way topologies.

▶ Two cities, one copying office replicate, then fail over the billing team
us-west holds orders at offsets 1000–1009 (older records already expired). Press ▶.
Illustrative offsets and sync points. Real offset syncs are emitted when the remote partition drifts more than offset.lag.max (default 100) and in other situations; checkpoints are emitted every emit.checkpoints.interval.seconds (default 60). The translation rule (sync's downstream offset, +1 if the group is past the sync) is the one in OffsetSyncStore.translateDownstream.

Offsets don't survive the trip

This is the single most important idea in the chapter. The target cluster assigns its own offsets. If replication starts when the source partition begins at 1000, the remote partition begins at 0. Filtered or aborted records widen the gap further. A consumer group's committed offset "1007" means nothing in the other city.

So MM2 keeps a translation table. The source connector writes offset syncs (upstream ↔ downstream pairs) to mm2-offset-syncs.<target>.internal, which by default lives on the source cluster (offset-syncs.topic.location defaults to source). The checkpoint connector reads each group's committed offsets, translates them, and writes checkpoints to <source>.checkpoints.internal on the target. Applications can read them via RemoteClusterUtils.translateOffsets(...). Alternatively, set sync.group.offsets.enabled=true (default false) and MM2 writes the translated offsets into the target's __consumer_offsets, "as long as no active consumers in that group are connected to the target cluster".

Translation is deliberately conservative. MM2 only knows the sync points exactly. For a group that's past the nearest sync, it translates to "sync's downstream offset + 1". The code comment explains why: overestimating could skip records and lose data. The result is that after a failover you re-read some records, never skip them. Plan for at-least-once and idempotent processing.

❓ There are no dumb questions
Q:
Can't MM2 just keep the same offsets?
A:
Not in general. The target is a different log with its own history (retention, earlier data, filtered transactions). Offsets are positions in that log. That's exactly why offset syncs and checkpoints exist.
Q:
Is MM2 exactly-once?
A:
It can be, since 3.5, for dedicated MM2 clusters. Set <target>.exactly.once.source.support = enabled (existing clusters go through preparing first), dedicated.mode.enable.internal.rest = true, and read the source with isolation.level=read_committed so aborted transactions aren't copied. Consumer failover via translated offsets is still at-least-once, as above.
⚠️ Watch it!

Two MM2 processes that target the same cluster share configuration through that cluster. If process 1 says A->B.topics = foo and process 2 says A->B.topics = bar, the docs say one of them wins depending on who is the elected leader, and you get foo or bar, not both. Keep one shared config per target. Also note that newly created remote topics use MM2's replication.factor, whose default is 2 (the heartbeats, checkpoints and offset-syncs topics default to 3). Set it explicitly for production.

🧰 Workflow / use case

Active/passive DR with "consume from remote, produce to local":

  1. Run MM2 next to the target. Producers suffer more from long, unreliable links than consumers do: bin/connect-mirror-maker.sh connect-mirror-maker.properties --clusters secondary.
  2. Enable primary->secondary only. Set replication.factor=3 and primary.consumer.isolation.level = read_committed.
  3. Decide how groups fail over: sync.group.offsets.enabled=true (offsets pre-written to the passive side), or translate at failover time with RemoteClusterUtils.
  4. Monitor replication-latency-ms, record-age-ms (MirrorSourceConnector) and checkpoint-latency-ms (MirrorCheckpointConnector) under kafka.connect.mirror.
  5. On failover, point consumers at primary.orders on the secondary cluster, with idempotent processing.
🔬 Go deeper: OffsetSyncStore's exponential memory

OffsetSyncStore can't keep every sync forever, so it keeps up to 64 per partition (SYNCS_PER_PARTITION = Long.SIZE), spaced "approximately exponential[ly]": dense near the live end of the topic, sparse further back (its invariants: syncs[0] is the latest sync, syncs[63] the earliest usable one). Translation picks the sync that most closely precedes the group's offset. It returns nothing until the store has read the syncs topic to the end once ("This prevents emitting stale offsets"). If the group is behind the oldest sync it returns -1 ("too far in the past to translate accurately"). The practical effect: lagging groups translate less precisely and re-read more after a failover. Other defaults worth knowing: topics are rescanned every refresh.topics.interval.seconds (600), topic configs sync every 600 s (sync.topic.configs.enabled defaults to true), heartbeats are emitted every second, and topics.exclude defaults to mm2.*\.internal, .*\.replica, __.* in the 4.3.1 source (DefaultTopicFilter). The geo-replication docs page still shows an older pattern.

📌 Bullet points
  • MM2 = Connect source connectors: Source (data, configs, ACLs), Checkpoint (group offsets), Heartbeat. MM1 is gone since 4.0.
  • Flows are directional and off by default. Remote topics are named {source}.{topic}, which prevents loops.
  • Offsets differ across clusters. Offset syncs plus checkpoints translate them conservatively, so failover re-reads rather than skips.
  • sync.group.offsets.enabled defaults to false; remote-topic replication.factor defaults to 2.
  • Run MM2 near the target; one consistent config per target cluster.

Upgrades, feature versions & the 4.x timeline

new binaries first, new rules later

Upgrading a Kafka cluster changes two separate things, and most upgrade mistakes come from mixing them up. The first is the software version: the jars on each node. The second is the feature levels the whole cluster has agreed to use: metadata.version and a handful of feature flags, stored as records in the KRaft metadata log.

In the archive, the software is each branch's clerks and furniture. The feature level is the edition of the city rulebook that the council has formally adopted. You retrain the clerks branch by branch, and the old rulebook stays in force while you do. Only when every branch can handle the new edition does the council vote it in. After that vote, some new rules write records the old clerks can't read, so there's no going back.

The rolling upgrade, per the 4.3 docs

  1. Check the preconditions. 4.x is KRaft-only. Brokers must already run KRaft with software and metadata at least 3.3.x. KRaft clusters older than 3.3.x should go to 3.9.x first. ZooKeeper clusters must migrate to KRaft using a bridge release, and "the last bridge release is Kafka 3.9".
  2. Roll the binaries: stop one broker, update the code, restart it, and repeat. The cluster still speaks the old metadata.version, so a mixed-version cluster is fine.
  3. Verify behavior and performance at your leisure. This is your cheap rollback window: just reinstall the old binaries.
  4. Finalize: bin/kafka-features.sh --bootstrap-server localhost:9092 upgrade --release-version 4.3

Finalizing is where the one-way doors are. Every MetadataVersion carries a boolean that says whether it changes the metadata format. IBP_4_3_IV0(30, "4.3", "IV0", true) does, so after finalizing 4.3 you cannot downgrade the metadata. IBP_4_2_IV1(29, "4.2", "IV1", false) doesn't, which is why the 4.2 docs say downgrade is supported. A downgrade is possible only if no version between current and target has metadata changes.

▶ Rolling upgrade 4.2 → 4.3 roll, finalize, then try to go back; click a release for its story
A three-broker cluster on 4.2 software, metadata.version=4.2-IV1. Press ▶.
Feature levels come from MetadataVersion and the *Version enums in server-common (4.3.1). The refusal text is quoted from FeatureControlManager. Controllers are left out; in a real cluster you roll them too. 5.0 is not released; its card lists only removals announced in the 4.x upgrade notes.

Feature flags: one switch per protocol

Since KIP-584, metadata.version isn't the only versioned feature. Each big protocol change has its own flag, so it can be enabled, and in most cases downgraded, independently. --release-version X sets all features to their defaults for that release. The tool's own help says "3.9-IV0 will set metadata.version=21 and kraft.version=1". --feature name=level changes just one.

FeatureLevel → enablesDefault from releaseNotes
metadata.version3.3-IV3 (min) … 4.3-IV0 = level 30 (latest production)n/aGates metadata record formats; one-way when "metadata changes" = true.
kraft.version1 → KIP-853 dynamic controller quorum3.9-IV0Static→dynamic upgrade of existing quorums is supported since 4.1; afterwards, replace controller.quorum.voters with controller.quorum.bootstrap.servers.
group.version1 → KIP-848 consumer rebalance protocol4.0-IV0Once groups use it, cluster downgrade only to ≥ 3.4.1.
transaction.version1 → flexible transactional state records; 2 → epoch bump per transaction (KIP-890)4.0-IV2Downgrades are safe; producers switch on their next transaction.
eligible.leader.replicas.version1 → ELR (KIP-966)4.1-IV0 (depends on MV ≥ 4.0-IV1)Enabling moves min.insync.replicas to cluster level; downgrade to 0 is safe.
share.version1 → share groups (KIP-932)4.2-IV0In 4.1 (preview) you had to enable it explicitly.
streams.version1 → Streams rebalance protocol (KIP-1071)4.2-IV1upgrade --feature streams.version=1 / downgrade --feature streams.version=0.
✏️ Sharpen your pencil

On a local 4.3 cluster: (a) list the finalized features, (b) see what --release-version 4.1 would map to without touching anything, (c) rehearse an upgrade with no side effects.

Answer
bin/kafka-features.sh --bootstrap-server localhost:9092 describe
bin/kafka-features.sh --bootstrap-server localhost:9092 version-mapping --release-version 4.1
bin/kafka-features.sh --bootstrap-server localhost:9092 upgrade --release-version 4.3 --dry-run

describe prints each feature's SupportedMinVersion, SupportedMaxVersion and FinalizedVersionLevel. Use --bootstrap-controller localhost:9093 to ask the controllers directly, and --node-id (4.2+) to pick a node. feature-dependencies --feature eligible.leader.replicas.version=1 shows that ELR needs a sufficiently new metadata.version. For brand-new clusters, kafka-storage.sh format accepts --release-version and --feature too.

⚠️ Watch it!

4.0 removed old protocol API versions (KIP-896). Brokers and Java clients (including Streams and Connect) must both be at least 2.1 before either side goes to 4.0, and non-Apache clients need checking separately. Clients and Streams need Java 11+, and brokers, Connect and tools need Java 17+. Upgrading the cluster can break an ancient client that nobody remembers owning.

🤓 Deep dive

Why can't you just "undo" 4.3's metadata? Because after finalizing, the controller may write records in a format that older code can't parse, and a downgrade would have to drop them. Ask for it anyway and FeatureControlManager answers "Refusing to perform the requested downgrade because it might delete metadata information." Even with downgrade --unsafe, the answer in 4.3.1 is "Unsafe metadata downgrade is not supported in this version." A code comment points to KAFKA-13896 for a future implementation. The practical rule: finalize only after you're sure you won't need the old binaries.

What 5.0 will take away

5.0 isn't out, but the 4.x notes already name what it removes. Clean these up now: group.coordinator.rebalance.protocols (in 5.0 all protocols are always on, and group.version/streams.version/share.version alone control them), the kafka-streams-scala library, the CLI options deprecated by KIP-1147 (--producer-props, --consumer.config, --property…, replaced by --command-property, --command-config, --formatter-property, --reader-property), --max-partition-memory-bytes in the console producer, MX4J support, the misspelled PARTITIONER_ADPATIVE_PARTITIONING_ENABLE_CONFIG constant, the tag-less app-info metric names, and PrincipalConnectorClientConfigOverridePolicy (the new allowlist policy becomes the default).

🔬 Go deeper: how a feature's default is chosen

Every feature level is an enum constant with a bootstrap metadata version, for example GV_1(1, MetadataVersion.IBP_4_0_IV0, Map.of()), and optional dependencies, like ELRV_1(1, IBP_4_1_IV0, Map.of("metadata.version", IBP_4_0_IV1.featureLevel())). Feature.defaultVersion(mv) walks the levels and picks the highest one whose bootstrap version is ≤ mv. That's what --release-version and kafka-storage format use, and what version-mapping prints. Feature.validateVersion rejects levels whose dependencies aren't met. The 4.3.1 source already defines IBP_4_4_IV0(31, …), but LATEST_PRODUCTION is IBP_4_3_IV0. Versions past that are for testing only, and MINIMUM_VERSION is IBP_3_3_IV3. The broker exposes the active level as kafka.server:type=MetadataLoader,name=CurrentMetadataVersion.

📌 Bullet points
  • Two steps: roll binaries (reversible), then kafka-features.sh upgrade --release-version X (maybe not reversible).
  • 4.x needs KRaft and ≥ 3.3.x; ZooKeeper clusters migrate via the 3.9 bridge first.
  • Feature flags: group.version (KIP-848), transaction.version (KIP-890), eligible.leader.replicas.version (ELR), share.version, streams.version, kraft.version.
  • 4.3-IV0 has metadata changes: no metadata downgrade, and unsafe downgrade isn't supported.
  • Start removing 5.0-deprecated configs, CLI flags and metric names today.
Part V

Mastery: building apps

You know how the archive works inside. Now build clerks that survive bad records, broken networks, rebalances and their own bugs — with the plain Kafka clients, with Spring for Apache Kafka 4.1.1, and with an honest look at when Kafka is not the right archive at all.

Error handling with the Kafka clients

not every error deserves a retry — and some deserve nothing else

Every Kafka application eventually meets three kinds of trouble: the network or cluster hiccups (a leader moves, an ISR shrinks), the data is bad (a record your code can't parse or can't process), or your own process misbehaves (too slow, killed mid-batch, fenced by a newer instance). Each needs a different reaction, and the most common production bug is treating all three the same way — usually with a try { … } catch (Exception e) { retry(); }.

🧠 Brain power

A clerk of our city ledger archive is copying entries into ledger book 3 when the branch reports "the book's keeper just changed — try again". A minute later another entry is returned with "this entry is larger than the page allows". Should the clerk treat both the same way? Which one will never succeed no matter how many times it is retried?

The producer: retriable vs non-retriable

The Java client encodes the first split directly in its exception hierarchy. Every exception that extends org.apache.kafka.common.errors.RetriableException is, in the client's own words, "a transient exception that if retried may succeed". NotEnoughReplicasException, TimeoutException and the metadata errors (InvalidMetadataException and friends, such as NotLeaderOrFollowerException) live there. Everything else — RecordTooLargeException, InvalidTopicException, AuthorizationException, UnknownServerException — is non-retriable: the Callback javadoc calls them "fatal, the message will never be sent".

Here is the part many people miss: the producer already retries retriable errors for you. retries defaults to Integer.MAX_VALUE, and the real budget is time, not attempts: delivery.timeout.ms (default 120 000 ms = 2 minutes) is "an upper bound on the time to report success or failure after a call to send() returns". It covers the time the record waits in the accumulator, the wait for the broker's acknowledgement and all retries. Between attempts the client backs off exponentially from retry.backoff.ms (100 ms) up to retry.backoff.max.ms (1000 ms). Each single request is bounded by request.timeout.ms (30 000 ms), and the docs say delivery.timeout.ms should be ≥ request.timeout.ms + linger.ms.

So when your callback receives an exception, the client has already done what it could. A TimeoutException in your callback is technically "retriable", but it usually means the whole two-minute budget is gone. The other case is that send() already waited max.block.ms for metadata or buffer space, and doSend() reports that timeout through the callback too. Re-sending it blindly in the callback is how you get unbounded memory growth and records reordered behind newer ones. The right reaction is almost always: record the failure somewhere durable (a local outbox, a log, a metric + alert) and let a human or a controlled process decide.

ConfigDefault (4.3.1)What it doesUse it when
delivery.timeout.ms120000Total time budget per record from send() returning to success/failure, including retriesThe one knob to tune how long to keep trying; raise it to ride out longer leader elections, lower it for latency-sensitive paths that have their own fallback
retries2147483647Max retry attempts for transient errorsLeave unset — the docs say to "prefer to leave this config unset and instead use delivery.timeout.ms". 0 disables idempotence (or throws if idempotence is explicitly on)
request.timeout.ms30000How long the client waits for one request's response before retrying or failingRarely changed; keep it well below delivery.timeout.ms
max.block.ms60000How long send() may block waiting for metadata and buffer space before failingLower it if a blocking send() would stall a request thread
retry.backoff.ms / retry.backoff.max.ms100 / 1000Exponential back-off between retriesRarely changed
enable.idempotencetrueBroker de-duplicates retried batches; ordering preserved for up to 5 in-flight requestsKeep it on; it is what makes automatic retries safe (Part II, idempotent producer)
⚠️ Watch it!

Some errors are thrown from send() itself, not passed to the callback: a SerializationException ("if the key or value are not valid objects given the configured serializers"), an InterruptException, and IllegalStateException after close(). If you only look at the callback, those bypass your error handling. Wrap the call, not just the result. Note also that send() can block for up to max.block.ms (metadata fetch plus buffer allocation). That timeout arrives as a TimeoutException in the callback/future, after your thread has already waited.

Since Kafka 4.1, calling producer.flush() inside a callback throws a KafkaException: "KafkaProducer.flush() invocation inside a callback is not permitted because it may lead to deadlock." Callbacks run on the producer's I/O thread; the javadoc asks you to keep them fast and hand expensive work to your own Executor.

// Kafka clients 4.3.1 — a producer callback that classifies instead of blindly retrying
producer.send(record, (metadata, e) -> {
    if (e == null) return;                                // acknowledged
    if (e instanceof RetriableException) {
        // the client already retried until delivery.timeout.ms ran out
        outbox.park(record, e);                           // durable, replayable later
    } else if (e instanceof RecordTooLargeException) {
        deadLetters.record(record, e);                    // will never fit: don't retry
    } else {
        fatal.set(e);                                     // auth, unknown server error…: stop, alert
    }
});

The transactional and idempotent producer adds one more category. ProducerFencedException (a newer instance with the same transactional.id took over — Part III) extends ApplicationRecoverableException, whose javadoc says the error "is fatal to the producer, and the application needs to restart the producer after handling the error". The send() javadoc lists the transactional errors that abortTransaction() cannot fix — ProducerFencedException, OutOfOrderSequenceException, UnsupportedVersionException, AuthorizationException — and says "the only option left is to call close()". Any other failed transactional send surfaces at commitTransaction(); you abort and carry on.

The consumer: poison pills, lost commits, wake-ups

A poison pill is a record your consumer can't deserialize — someone wrote JSON into an Avro topic, or a producer shipped a new schema early. The consumer deserializes inside poll(), so the failure surfaces as a RecordDeserializationException thrown from poll(). Its message ends with the key sentence: "If needed, please seek past the record to continue consumption." The position does not move past the bad record by itself, so a loop that just logs and polls again hits the same record forever. Since Kafka 3.8 (KIP-1036) the exception carries everything you need to quarantine it: topicPartition(), offset(), origin() (KEY or VALUE), keyBuffer(), valueBuffer(), headers(), timestamp().

// Kafka clients 4.3.1 — quarantine a poison pill and move on
while (running) {
    ConsumerRecords<String, Order> records;
    try {
        records = consumer.poll(Duration.ofMillis(500));
    } catch (RecordDeserializationException e) {
        dlqProducer.send(new ProducerRecord<ByteBuffer, ByteBuffer>(
                "orders-dlq", null, e.keyBuffer(), e.valueBuffer(), e.headers()));
        consumer.seek(e.topicPartition(), e.offset() + 1);   // step over it
        continue;
    }
    for (ConsumerRecord<String, Order> r : records) process(r);   // must be idempotent
    consumer.commitSync();
}

The second classic is CommitFailedException. Its javadoc explains it exactly: the commit failed "with an unrecoverable error. This can happen when a group rebalance completes before the commit could be successfully applied" — the partitions may already belong to another member. The default message even names the usual cause: the time between poll() calls exceeded max.poll.interval.ms (default 300 000 ms), so fix it "either by increasing max.poll.interval.ms or by reducing the maximum size of batches returned in poll() with max.poll.records" (default 500). Do not retry the commit: the records you processed will be re-delivered to the new owner. That is why consumers must be idempotent.

RebalanceInProgressException from commitSync() is milder: the javadoc suggests completing the rebalance with poll() and reconsidering the commit afterwards, with the caveat that your assignment may have changed.

Finally, WakeupException is not an error at all — it's the only thread-safe way to interrupt a consumer. KafkaConsumer "is NOT thread-safe", except for wakeup(), which makes the blocked call throw WakeupException. The javadoc pattern: set a closed flag, call consumer.wakeup() from the shutdown hook, catch WakeupException in the poll loop, rethrow it only if you weren't closing, and close() in finally. Thread interrupts also work, but the javadoc discourages them "since they may cause a clean shutdown of the consumer to be aborted".

▶ The error classifier pick an exception, then classify it — or break the poll loop
—
① Extends RetriableException?
② Time budget (delivery.timeout.ms) left?
③ Does the client stay usable?
🔁 Client retries for youidempotent producer keeps order & no dupes
🧰 Your code decidesskip · park · dead-letter · reprocess
🛑 Fatalclose / restart client, alert a human
👋 Clean shutdownnot an error: close in finally
Pick an exception and press ▶ Classify.
Classification follows the 4.3.1 exception hierarchy and javadocs (Callback, KafkaProducer.send, KafkaConsumer). Real handling depends on your delivery guarantees; offsets 40–44 in the loop demo are illustrative.

Dead-letter queues and idempotent consumers

Kafka's plain consumer has no built-in dead-letter queue (DLQ): a consumer group tracks one offset per partition, not a per-record fate. A DLQ in a consumer-group app is a pattern you build — a separate topic you produce the failed record to, with headers describing why, before you commit past it. Kafka Connect has one built in for sink connectors (errors.deadletterqueue.topic.name with errors.tolerance=all), and since 4.2 Kafka Streams has one too (errors.dead.letter.queue.topic.name, KIP-1034). Spring for Apache Kafka gives you one for listeners (next chapter). Share groups (Part III) take a different route: they count delivery attempts per record, and a rejected record gets no further delivery attempts.

A good DLQ record is replayable: the original bytes (not a toString()), the original topic/partition/offset, the exception class and message, a timestamp, and the consumer group. Someone will fix the bug and want to push those records back through — design for that day.

Because the consumer gives you at-least-once delivery (the KafkaConsumer javadoc: "each record will likely be delivered one time but in failure cases could be duplicated"), processing must tolerate duplicates. Common designs: a natural idempotent write (upsert by business key), a processed-ids table updated in the same database transaction as the effect, or storing the consumed offset alongside the result and seeking to it on assignment (the javadoc's "storing offsets outside Kafka" pattern). For Kafka-to-Kafka pipelines, transactions (Part III) make the read-process-write cycle exactly-once.

🔥 Fireside chat
Retry
Give me one more go. The database was just restarting, I'll get through this time.
Ordering
Fine — but while you sit on record 17, records 18, 19 and 20 with the same key wait behind you. If you step aside to a retry topic, 18 overtakes you and the account balance goes negative.
Retry
So I either block the whole partition or give up order?
Ordering
For that key, yes. Block briefly for transient faults, send true poison to a DLQ fast, and if the key's history matters, park all later records of that key too.

That dialogue is the central trade-off of this Part. Inside a partition, order is only preserved if you process strictly in sequence. Any retry that lets later records go first (a retry topic, a DLQ you replay later, a share group redelivery) breaks per-key order. Blocking retries keep order but stall every key in the partition and risk max.poll.interval.ms. Decide per topic which one you can afford.

❓ There are no dumb questions
Q:
Should I set retries=0 so I can handle failures myself?
A:
Almost never. retries=0 disables idempotence (the config check logs "Idempotence will be disabled because retries is set to 0"), and your own resend creates a new record that the broker can't de-duplicate. Tune delivery.timeout.ms instead.
Q:
Is enable.auto.commit=true at-least-once?
A:
Only if you finish processing every record from a poll() before the next poll() or close(). Hand records to another thread and the auto-commit can run ahead of what you've processed — the javadoc says this "results in missing records".
Q:
Can I catch CommitFailedException and just commit again?
A:
No. The partitions may already be someone else's. Let the records be re-processed by the new owner and make processing idempotent.
✏️ Sharpen your pencil

Make a poison pill on a local 4.3 cluster and watch a consumer choke on it:

bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic ints --partitions 1
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic ints
> hello
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic ints --from-beginning \
  --formatter-property value.deserializer=org.apache.kafka.common.serialization.IntegerDeserializer

What happens? Which flag of kafka-console-consumer.sh keeps it running, and why is that flag the console-tool version of a DLQ-less "skip"?

Answer

"hello" is 5 bytes, and IntegerDeserializer throws SerializationException: Size of data received by IntegerDeserializer is not 4, so the tool stops. Add --skip-message-on-error ("If there is an error when processing a message, skip it instead of halt") and it logs the error and continues. Skipping without recording the record anywhere is data loss by design. Acceptable for a console tool, not for an application: there you would quarantine the bytes first. (The console consumer reads raw bytes and deserializes in its formatter, so the error surfaces there. In your own consumer with a value deserializer configured, the same bytes cause a RecordDeserializationException from poll().)

SymptomLikely causeFix
Consumer logs the same deserialization error forever; lag grows on one partitionPoison pill; the loop catches and re-polls without seekingseek(tp, offset+1) after quarantining the bytes; or an error-handling deserializer
CommitFailedException, duplicates downstreamProcessing a batch took longer than max.poll.interval.ms; member evictedLower max.poll.records, speed up processing, or raise max.poll.interval.ms; make processing idempotent
Callbacks report TimeoutException after ~2 minutesdelivery.timeout.ms exhausted (under-min-ISR partition, unreachable leader)Fix the cluster side (Part IV troubleshooting); park records durably; alert
Throughput collapses after adding logging to the callbackSlow work on the producer I/O threadHand work to your own executor; keep callbacks trivial
Shutdown hangs or loses the last commitThread interrupt instead of wakeup(); no close() in finallyUse the javadoc wakeup() pattern
🔬 Go deeper: where the client decides "retriable"

Broker errors travel as numeric error codes in each response. The client maps each code to an exception class through org.apache.kafka.common.protocol.Errors, and whether that class extends RetriableException decides the producer's path. In the producer's Sender, a batch that fails with a retriable error goes back into the RecordAccumulator if its delivery deadline hasn't passed; otherwise the batch completes exceptionally and every record's callback fires with that exception. Metadata-type errors extend InvalidMetadataException, which also triggers a metadata refresh before the retry — that's how a leader move becomes invisible to you.

The 4.x clients also have intermediate classes in the hierarchy: RefreshRetriableException ("requiring a refresh … before retrying"), ApplicationRecoverableException (restart the producer) as abstract bases, plus the concrete TransactionAbortableException (abort the transaction and continue). Classify on these base classes, not on long lists of leaf exceptions, and your handler keeps working when new leaves are added.

On the consumer side, deserialization happens in CompletedFetch (ShareCompletedFetch for share consumers): newRecordDeserializationException(…) builds the exception with the raw key/value ByteBuffers and headers, and the fetch keeps the bad record as the next one to return, so the same failure repeats until you seek.

📌 Bullet points
  • Producer: RetriableExceptions are retried automatically until delivery.timeout.ms (120 s); leave retries at its default and tune the timeout.
  • An exception in your callback means the client is done trying. Park or dead-letter the record instead of re-sending it blindly.
  • ProducerFencedException, OutOfOrderSequenceException, UnsupportedVersionException and AuthorizationException are fatal for a transactional producer: close() it. Don't flush() inside callbacks (throws since 4.1).
  • Poison pill → RecordDeserializationException from poll(); quarantine keyBuffer()/valueBuffer(), then seek(offset + 1).
  • CommitFailedException = you were rebalanced out (usually slow processing). Don't retry the commit; make processing idempotent.
  • wakeup() + WakeupException is the thread-safe shutdown path.
  • Retries that let later records overtake break per-key order. Blocking retries keep order but stall the partition.

Spring for Apache Kafka best practices

the framework gives you the patterns — you still pick them

Spring for Apache Kafka wraps the clients you just learned in a listener container: it runs the poll loop, calls your @KafkaListener method, commits offsets, catches exceptions, retries, dead-letters and shuts down cleanly. The patterns from the last chapter are all there, already built. The catch is that its defaults were chosen for "works out of the box", not for "correct for your topic". This chapter is pinned to Spring for Apache Kafka 4.1.1 (2026-08-20). That release builds against kafka-clients 4.2.1 and Spring Framework 7.0.9 (from its gradle/libs.versions.toml), and its quick tour lists Java 17 as the minimum.

🧠 Brain power

Your listener throws on a record because the payment gateway is down. With no error handler configured at all, how many times will Spring try that record before giving up — and what happens to it then?

The container: threads, partitions, commits

A ConcurrentKafkaListenerContainerFactory builds a ConcurrentMessageListenerContainer per @KafkaListener. With concurrency n it creates n KafkaMessageListenerContainers, each with its own KafkaConsumer and thread — consumers are not thread-safe, so one consumer per thread. Kafka's group protocol then spreads the partitions. In the archive: n clerks from the same team, each holding whole ledger books. More clerks than books means idle clerks. The docs warn about a subtler version: with three topics of five partitions each and concurrency=15, you "see only five active consumers" under the classic RangeAssignor default. Size concurrency to your partition count (summed over instances) and check the assignor.

Spring sets enable.auto.commit to false unless you set it (since 2.3), and commits for you according to the container's AckMode. The default is BATCH.

AckModeWhen the container commitsUse it when
BATCH (default)After all records returned by one poll() have been processedGeneral purpose; fewer commits
RECORDAfter the listener returns for each recordSlow, expensive records where re-processing a whole batch after a crash hurts; suggested for retry topics
TIME / COUNT / COUNT_TIMEAfter the poll's records are processed, if ackTime elapsed and/or ackCount records were receivedHigh-volume topics where commit frequency matters
MANUALListener calls Acknowledgment.acknowledge(); then BATCH semanticsHand-off to async work that completes later
MANUAL_IMMEDIATEImmediately when acknowledge() is calledYou need the commit to happen right at that point

New in 4.1: @KafkaListener(ackMode = "MANUAL") overrides the factory's mode per listener, so you no longer need a second factory bean just for that. With transactions, offsets are sent to the transaction instead, and semantics equal RECORD or BATCH depending on the listener type.

Blocking retries: DefaultErrorHandler

When the listener throws, the container calls a CommonErrorHandler, by default the DefaultErrorHandler. It remembers the failed record, seeks the partition back so the record (and everything after it) is redelivered by the next poll(), and waits according to a Spring BackOff. The default is FixedBackOff(0, 9): ten deliveries with no delay, then the record is logged at ERROR and skipped. That answers the brain-power question. Without a recoverer, the default ends by skipping and logging, which is quiet data loss.

Two built-in guard rails matter:

Blocking retries keep order: nothing behind the failing record in that partition is processed until it succeeds or is recovered. The price is a stalled partition and the poll-interval clock. The container sleeps between attempts, but the consumer must still poll() within max.poll.interval.ms (300 s by default) or it is kicked out of the group. For long back-offs, the docs point to ContainerPausingBackOffHandler, which "pauses the listener container until the back off time passes", for delays "longer than the max.poll.interval.ms".

Dead letters: DeadLetterPublishingRecoverer

Give the error handler a recoverer and exhausted records get somewhere to go. DeadLetterPublishingRecoverer publishes the failed record with a KafkaTemplate. By default it goes to <originalTopic>-dlt and the same partition, so the DLT "must have at least as many partitions as the original topic". (The suffix became -dlt in 3.3. Older apps used .DLT and must opt in to keep it.) Each dead letter carries headers such as KafkaHeaders.DLT_EXCEPTION_FQCN, DLT_EXCEPTION_MESSAGE, DLT_EXCEPTION_STACKTRACE, DLT_ORIGINAL_TOPIC, DLT_ORIGINAL_PARTITION, DLT_ORIGINAL_OFFSET and DLT_ORIGINAL_CONSUMER_GROUP, which is everything the last chapter said a replayable DLQ record needs.

// Spring for Apache Kafka 4.1.1 (Spring Framework 7)
import org.springframework.kafka.support.ExponentialBackOffWithMaxRetries;
import org.springframework.kafka.listener.*;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;

@Configuration
class KafkaErrorConfig {

    @Bean
    DeadLetterPublishingRecoverer recoverer(KafkaOperations<?, ?> template) {
        return new DeadLetterPublishingRecoverer(template);        // -> orders-dlt, same partition
    }

    @Bean
    DefaultErrorHandler errorHandler(DeadLetterPublishingRecoverer recoverer) {
        ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
        backOff.setInitialInterval(1_000L);
        backOff.setMultiplier(2.0);
        backOff.setMaxInterval(10_000L);                            // 1 s, 2 s, 4 s, then DLT
        DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
        handler.addNotRetryableExceptions(IllegalArgumentException.class);   // validation: no retry
        return handler;
    }

    @Bean
    ConcurrentKafkaListenerContainerFactory<String, Order> kafkaListenerContainerFactory(
            ConsumerFactory<String, Order> consumerFactory, DefaultErrorHandler errorHandler) {
        var factory = new ConcurrentKafkaListenerContainerFactory<String, Order>();
        factory.setConsumerFactory(consumerFactory);
        factory.setConcurrency(3);                                  // <= partitions per instance
        factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD);
        factory.setCommonErrorHandler(errorHandler);
        return factory;
    }
}

Poison pills: ErrorHandlingDeserializer

Remember that deserialization happens inside poll(), before Spring sees anything, so "Spring has no way to handle the problem". The fix is to wrap your real deserializer in ErrorHandlingDeserializer. If the delegate fails, it returns null and puts a DeserializationException (with the raw bytes) in a header. The container sees the header, skips your listener and calls the error handler. Because DeserializationException is on the fatal list, the record goes straight to the recoverer. Paired with DeadLetterPublishingRecoverer, the DLT record gets the original bytes back as its value. Configure the delegate with ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS (spring.deserializer.value.delegate.class). In 4.x, the Jackson 3 JacksonJsonDeserializer replaces the deprecated Jackson 2 JsonDeserializer.

props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JacksonJsonDeserializer.class);
props.put(JacksonJsonDeserializer.VALUE_DEFAULT_TYPE, "com.example.Order");
props.put(JacksonJsonDeserializer.TRUSTED_PACKAGES, "com.example");

Non-blocking retries: @RetryableTopic and @DltHandler

The alternative to stalling a partition is to move the failing record aside. With @RetryableTopic on a @KafkaListener, a failure forwards the record to a retry topic with a back-off timestamp. The retry topic's consumer pauses that partition until the record is due, then processes it again. After the last attempt, the record goes to the DLT, handled by a @DltHandler method (or a default consumer "which only logs"). The framework creates the topics and containers for you.

@RetryableTopic(attempts = "4",
        backOff = @BackOff(delay = 1000, multiplier = 2.0, maxDelay = 10000))
@KafkaListener(topics = "payments", groupId = "payments-svc")
void onPayment(Payment p) { gateway.charge(p); }             // may throw

@DltHandler
void onDeadPayment(Payment p, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
    alerts.raise(p, topic);
}
// creates payments-retry-1000, payments-retry-2000, payments-retry-4000, payments-dlt

Defaults to know: attempts = "3" (the number of attempts made before the message goes to the DLT), a fixed back-off of 1000 ms, retry suffix -retry, DLT suffix -dlt, numPartitions = "1" and replicationFactor = "-1" (broker default) for auto-created topics, and SameIntervalTopicReuseStrategy.SINGLE_TOPIC. So a fixed back-off uses one …-retry topic. (4.1 changed RetryTopicConfigurationBuilder's default to SINGLE_TOPIC too, to match the annotation.) In 4.0 the @Backoff from Spring Retry became Spring Kafka's own @BackOff, because Spring Retry was dropped in favour of Spring Framework 7's core retry support.

Two hard limits from the docs: non-blocking retries are not supported with batch listeners, and they cannot combine with container transactions. And the big caveat, in the docs' own capitals: "IMPORTANT: By using this strategy you lose Kafka's ordering guarantees for that topic." Record 18 overtakes the retried record 17. That is exactly what the figure's toggle shows.

▶ Retry topology run the same failure through blocking and non-blocking retries
orders p0
orders-retry 1 s back-off
orders-dlt
processed, in order of completion
attempt: –partition p0: flowingorder for key A: –
Record A1 (key A) will fail twice with a transient error, then succeed. A2 has the same key; B1 has another key. Pick a strategy.
Simplified: one partition, three attempts (blocking: FixedBackOff(1000L, 2L); non-blocking: @RetryableTopic defaults — 3 attempts, fixed 1 s, single -retry topic). Timing is compressed and illustrative; real retry topics pause the partition until the record is due.
🔥 Fireside chat
DefaultErrorHandler
I keep your ledger in order. Nothing leaves the desk until the bad page is sorted.
@RetryableTopic
And the whole desk waits while the payment gateway is down for ten minutes. I put the bad page in a tray and keep the queue moving.
DefaultErrorHandler
Then the refund for that same account gets processed before the charge. Enjoy explaining that.
@RetryableTopic
Fair. So: me for independent events (emails, notifications, idempotent upserts), you for per-key state machines. And both of us can work together — the docs let you retry some exceptions blocking first, then hand over to me.

That combination is real: extend RetryTopicConfigurationSupport and override configureBlockingRetries(…) to retry chosen exceptions in place first (the docs' example: a database hiccup that "would likely trigger errors on the next records as well"), with non-blocking retries after that. There's a global list of fatal exceptions for retry topics too; those skip retries and go straight to the DLT.

Transactions

Give the DefaultKafkaProducerFactory a transactionIdPrefix and it keeps a cache of transactional producers (transactional.id = prefix + n). The prefix "must be unique per instance". With Spring Boot, setting spring.kafka.producer.transaction-id-prefix auto-configures a KafkaTransactionManager and wires it into the listener container. The container then starts a transaction before calling your listener, your KafkaTemplate sends join it, and on success it sends the consumed offsets with sendOffsetsToTransaction() before committing. That's the consume-transform-produce pattern from Part III, done for you. On an exception it rolls back and repositions the consumer. Repeated failures go to the DefaultAfterRollbackProcessor, the transactional twin of DefaultErrorHandler. Spring Kafka 3.0+ only supports EOSMode.V2 (KIP-447 fencing). The docs are careful to scope exactly-once: the read → process → write sequence completes once; "the read and process have at least once semantics". Side effects outside Kafka (an HTTP call, an email) can still happen twice.

For a database write plus a Kafka send, annotate the method @Transactional with your DataSourceTransactionManager. The KafkaTemplate synchronizes with it, and the DB commits first, then Kafka. That is two transactions, not one atomic one. If the second commit fails, the docs say the exception reaches the caller and you "should take remedial action … to compensate". For outside-world side effects, pair transactions with idempotent consumers.

Sending: KafkaTemplate returns a CompletableFuture

Every send(…) returns CompletableFuture<SendResult<K, V>>. Attach whenComplete((result, ex) -> …) for non-blocking handling. The Throwable can be cast to KafkaProducerException, whose getFailedProducerRecord() gives you the record. If you must block, the docs recommend get() "with a timeout". By default the template has a LoggingProducerListener that only logs errors, so a send you never check fails silently apart from a log line. The producer-side rules from the last chapter still apply underneath: keep completion handlers short, and move slow work to your own executor (e.g. whenCompleteAsync(…, executor)).

Share consumers (Kafka queues) in Spring

Spring Kafka 4.0 added early-access share consumers. The docs say that since 4.1, "backed by Apache Kafka 4.2, share consumers are promoted to full production support". You use a DefaultShareConsumerFactory, a ShareKafkaListenerContainerFactory and a plain @KafkaListener(containerFactory = "shareKafkaListenerContainerFactory"). Acknowledgement follows ContainerProperties.ShareAckMode (new in 4.1, replacing a boolean):

Since 4.1, undeserializable records are caught at poll level and REJECTed, so a poison pill can't kill the thread. Current limits: no batch listeners and no message converters for share consumers. The broker's delivery-count limit (the share.delivery.count.limit group config, broker default 5 via group.share.delivery.count.limit) is your built-in "poison pill" cutoff, and there is no ordering promise to lose.

Observability and testing

If Micrometer is on the classpath with a single MeterRegistry, the listener container and KafkaTemplate record timers automatically: spring.kafka.listener with tags name, result (success/failure) and exception, and spring.kafka.template for sends. Set observationEnabled to true on ContainerProperties and KafkaTemplate to switch to Micrometer Observation (see the Micrometer Tracing reference for tracing). MicrometerProducerListener/MicrometerConsumerListener bind the clients' own metrics (KafkaClientMetrics). One 4.1.1 behaviour change: KafkaAdmin now remembers a failed cluster-id lookup for clusterIdRetryInterval (default 5 minutes). Previously, an observation-enabled container could block on every record while the cluster id was unavailable.

For tests, @EmbeddedKafka starts an in-JVM broker. Since 4.0 that's only EmbeddedKafkaKraftBroker, KRaft only; the ZooKeeper variant and the JUnit 4 EmbeddedKafkaRule are gone. The docs recommend a single broker across tests with a different topic per test, via Spring's context caching. For tests closer to production (real broker image, real networking), the Testcontainers Kafka module's org.testcontainers.kafka.KafkaContainer runs the apache/kafka or apache/kafka-native images, and getBootstrapServers() gives you the address to point your factories at.

🧰 Workflow / use case

Best-practices checklist for a Spring Kafka 4.1 service:

  1. Pick an ordering stance per topic. Per-key state (balances, order status) → blocking DefaultErrorHandler + DLT. Independent events → @RetryableTopic. Truly queue-like work → share consumers.
  2. Always configure a recoverer. The default ends with "log and skip". Use DeadLetterPublishingRecoverer and create the -dlt topic with ≥ the source's partition count.
  3. Wrap deserializers in ErrorHandlingDeserializer so poison pills become dead letters, not crash loops.
  4. Classify exceptions: addNotRetryableExceptions(…) for validation/business errors. Retry only what can heal.
  5. Bound blocking back-off well below max.poll.interval.ms, or use ContainerPausingBackOffHandler.
  6. Keep concurrency ≤ partitions, and watch the assignor for multi-topic listeners.
  7. Make listeners idempotent. Every path here is at-least-once, except Kafka-to-Kafka writes in a container transaction.
  8. Handle the CompletableFuture of every KafkaTemplate.send that matters, or use transactions.
  9. Unique transactionIdPrefix per instance when using transactions. Don't combine them with retry topics.
  10. Monitor the DLT (size, age), spring.kafka.listener failure timers and consumer lag. A DLT nobody reads is a slower way of losing the data.
  11. Test with @EmbeddedKafka for speed, and Testcontainers for real-broker behaviour.
⚠️ Watch it!
Anti-patternWhat goes wrongDo instead
No recoverer on DefaultErrorHandlerAfter 10 fast attempts the record is logged and skipped. Silent loss.DeadLetterPublishingRecoverer + DLT alerting
FixedBackOff.UNLIMITED_ATTEMPTS for everythingOne poison record stalls the partition foreverBounded attempts; unlimited only for known-transient exceptions via setBackOffFunction
Long blocking back-off (minutes)Exceeds max.poll.interval.ms; rebalance; duplicatesContainerPausingBackOffHandler or retry topics
@RetryableTopic on per-key state updatesLater records overtake the retried one: lost orderingBlocking retries, or park the whole key
Catching and swallowing exceptions in the listenerContainer commits; error handler never sees itLet it throw (or throw a classified exception)
Throwing an Error subclass for business failuresBypasses the error handler; consumer terminatesExtend RuntimeException
DLT with fewer partitions than the sourceDefault resolver targets the same partition number, which doesn't existMatch partition counts or supply a destination resolver
concurrency > partitionsIdle threads, false sense of scaleAdd partitions (plan it) or use share consumers
Ignoring the KafkaTemplate futureFailed sends only show up in the logswhenComplete handling or transactions
Same transactionIdPrefix on every replicaInstances fence each other (ProducerFencedException)Unique prefix per instance
✏️ Sharpen your pencil

You deployed the payments listener above. After a gateway outage, you want to inspect what landed in the DLT, including the headers Spring added. Which command shows them on a local 4.3 cluster, and which header tells you the original offset?

Answer
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic payments-dlt --from-beginning \
  --formatter-property print.headers=true --formatter-property print.offset=true

print.headers and print.offset are DefaultMessageFormatter properties, passed with --formatter-property (4.2 deprecated the old --property flag in its favour). The original position is in the kafka_dlt-original-offset header, whose constant is KafkaHeaders.DLT_ORIGINAL_OFFSET. Next to it are …-original-topic, …-original-partition and the kafka_dlt-exception-* headers.

SymptomLikely causeFix
Listener stops consuming, app reports healthyAn Error (not RuntimeException) escaped the listenerThrow runtime exceptions; add a container-stopped event alert
Same record retried ~10 times fast, then vanishesDefault FixedBackOff(0, 9), default logging recovererConfigure back-off and DeadLetterPublishingRecoverer
DLT publish fails: partition doesn't existDLT has fewer partitions than the sourceRecreate DLT with enough partitions or custom resolver
Retries never happen for ClassCastExceptionIt's on the default fatal listIntended; fix the type mapping, or reclassify deliberately
Out-of-order updates after enabling @RetryableTopicNon-blocking retries give up orderingBlocking retries for that topic
🔬 Go deeper: what the container actually does on an exception

In KafkaMessageListenerContainer's consumer loop, a listener exception is wrapped in ListenerExecutionFailedException and passed to CommonErrorHandler.handleRemaining(…) for record listeners. The DefaultErrorHandler (a FailedRecordProcessor) uses a FailedRecordTracker (a per-thread map of topic-partition → failed record and its offset) to count attempts and pick the BackOff, then either recovers the record or calls SeekUtils to seek each affected partition back to its first unprocessed offset. The next poll() re-fetches them. SeekUtils.DEFAULT_MAX_FAILURES is 10, hence DEFAULT_BACK_OFF = new FixedBackOff(0, DEFAULT_MAX_FAILURES - 1). Since 2.9, seekAfterError=false keeps the unprocessed records in memory and redelivers them after a paused poll() instead of seeking. resetStateOnExceptionChange (true by default for record listeners) restarts the back-off when the exception type changes. In 4.1, FailedBatchProcessor gained setBackOffFunction and setResetStateOnExceptionChange for batch listeners, defaulting to false there for compatibility.

For batch listeners, throw BatchListenerFailedException with the failing index. The handler commits the records before it, retries from the failing one and, once retries are exhausted, recovers only that record. Any other exception from a batch listener falls back to retrying the complete batch.

📌 Bullet points
  • Container = poll loop + commits + error handling; concurrency = consumers/threads, useful only up to the partition count.
  • Default AckMode is BATCH; 4.1 lets @KafkaListener(ackMode=…) override it per listener.
  • DefaultErrorHandler default = 10 attempts, no delay, then log and skip. Always add a DeadLetterPublishingRecoverer (<topic>-dlt, same partition).
  • ErrorHandlingDeserializer turns poison pills into dead letters; deserialization errors are fatal (no retries).
  • Blocking retries keep order but stall the partition. @RetryableTopic keeps flowing but loses ordering, and doesn't work with batch listeners or container transactions.
  • Transactions: unique transactionIdPrefix per instance, EOS V2 only. Exactly-once covers read-process-write to Kafka, not other side effects.
  • Share consumers are production-ready in 4.1 (on Kafka 4.2+) with ShareAckMode EXPLICIT/MANUAL/IMPLICIT.
  • Test with @EmbeddedKafka (KRaft only) and Testcontainers' KafkaContainer.

Kafka vs the alternatives

a ledger archive is not a post office — sometimes you need a post office

After twenty-six chapters it would be easy to believe every messaging problem is a Kafka problem. It isn't. Our city ledger archive is superb at one thing: keeping an ordered, replayable, replicated record that many teams can read independently at their own pace. Some jobs look more like a post office: hand each parcel to exactly one courier, route it by address, and forget it once delivered. Others want the archive's model with a different building underneath. This chapter compares Kafka 4.3 with the main alternatives, fairly and with dates.

😌 Relax

Every vendor statement below comes from that vendor's own documentation, fetched on 2026-09-25: RabbitMQ 4.3 docs, Apache Pulsar docs ("next") with 4.2.4 the newest stable line and 4.0 the LTS, Redpanda 26.2 docs, and the AWS Kinesis Data Streams and Google Cloud Pub/Sub docs. These products move fast. Re-check anything you're about to bet a platform on. Performance claims are the vendors' own marketing and are not repeated here as fact.

The one question that sorts everything: log or queue?

A log keeps records after they are read. Consumers track their own position, many independent readers can read the same data, and you can rewind (replay). Order is kept per partition. A queue hands each message to one consumer, tracks per-message acknowledgement, and deletes the message once it's acknowledged. Kafka consumer groups are log semantics. RabbitMQ classic and quorum queues are queue semantics. The interesting systems now offer both: Kafka added share groups (queue-like consumption over a log), RabbitMQ added streams (a log inside a broker built around queues), and Pulsar exposes both through subscription types.

RabbitMQ: queues, quorum queues, streams

RabbitMQ's model starts with routing. Publishers send to exchanges, which route to queues by binding rules. A queue "is an ordered collection of messages", FIFO, but priorities, multiple consumers and requeues after negative acknowledgements can change the order. After a consumer acknowledges, the message is removed. Quorum queues are "a modern queue type which implements a durable, replicated queue based on the Raft consensus algorithm" and the docs call them "the default choice when needing a replicated, highly available queue". They have per-message poison handling built in: since RabbitMQ 4.0 the delivery limit defaults to 20, after which the message is dropped or dead-lettered.

RabbitMQ streams are RabbitMQ's answer to the log: "an append-only log of messages that can be repeatedly read until they expire", with "non-destructive consumer semantics". They are always replicated and persistent, retention is set with x-max-length-bytes / x-max-age, consumers attach at an offset, timestamp or first/last, single active consumer gives ordered exclusive consumption, and super streams partition one logical stream across several streams. The docs' use cases are large fan-outs, replay/time-travel, throughput and large backlogs.

Apache Pulsar: brokers without disks

Pulsar separates serving from storage. Brokers are stateless ("The Pulsar message broker is a stateless component"), message data lives in Apache BookKeeper bookies as append-only ledgers, and a metadata store coordinates the cluster (Oxia recommended, ZooKeeper, or RocksDB). The Kafka analogy is a branch clerk who keeps no books at all: every page is written to a separate vault service. That makes brokers easy to scale out and replace, at the cost of running more distinct components. On top it has built-in multi-tenancy (tenants/namespaces), geo-replication, and four subscription types: Exclusive, Failover, Shared (round-robin, queue-like) and Key_Shared (messages with the same key go to one consumer). It also has negative acknowledgement, a retry letter topic and a dead letter topic built in. By default acknowledged messages are deleted; retention policies keep them for replay.

Redpanda: the Kafka API on a different engine

Redpanda is not a different messaging model. It's a different implementation of Kafka's model. Its docs say it is "compatible with the Kafka API", is "Built on C++", is "packaged as a single binary: it doesn't rely on any external systems", uses a thread-per-core model via the Seastar library, runs "Raft consensus … for data replication" where "every topic partition forms a Raft group", and offers tiered storage. Your Kafka clients and most tools connect unchanged. What you're comparing is operations, cost, performance and licensing, not semantics. Check which newer Kafka protocol features you depend on before assuming parity. Redpanda's Kafka-compatibility page says it "does not implement the server-side portion of KIP-890", so Kafka 4.x clients fall back to the original transaction protocol (no Transactions V2). KIP-848 consumer groups and share groups (KIP-932) are not mentioned in its compatibility docs.

Managed cloud streams: Kinesis and Pub/Sub

Amazon Kinesis Data Streams is a log, like Kafka. A stream is a set of shards (≈ partitions), records are routed by a partition key hashed with MD5, and each shard supports writes of up to 1,000 records/s or 1 MB/s and reads of up to 2 MB/s. Retention defaults to 24 hours and can be raised to 8,760 hours (365 days) at extra cost. The Kinesis Client Library keeps consumer state in DynamoDB tables. Google Cloud Pub/Sub is closer to a queue: at-least-once delivery by default, an optional exactly-once delivery feature, and ordering disabled by default. You enable it per subscription at creation and publish related messages with the same ordering key, limited to 1 MBps of publishing per key. Both remove the operations work. You give up protocol portability (Kafka clients don't speak them) and the Kafka ecosystem (Connect, Streams). Managed Kafka services are the middle ground: the Kafka API and semantics without running brokers. Compare their Kafka version and enabled features against what you need.

…and the option inside Kafka: share groups

Before adding a second broker technology for "we need a work queue", remember Part III. Since Kafka 4.2, share groups are production-ready. Consumers cooperatively consume the same partitions, there can be more consumers than partitions, records are acknowledged individually (accept / release / reject / renew), and delivery attempts are counted, with a broker default limit of 5 (group.share.delivery.count.limit) and a 30 s acquisition lock by default. You keep one platform, one security model and replay of the underlying topic. You give up ordering: the Kafka docs say to use share groups "in cases where records are processed one at a time, rather than as part of an ordered stream". What they don't give you (as of 4.3) is RabbitMQ-style routing or a broker-managed dead-letter topic. A rejected record simply isn't delivered again, so capture it yourself if you need it.

Dimension (as of Sept 2026)Kafka 4.3 consumer groupsKafka 4.3 share groupsRabbitMQ 4.3 quorum queuesRabbitMQ 4.3 streamsPulsar 4.xRedpanda 26.2
Storage modelPartitioned replicated log on broker disks (+ tiered storage)Same log; per-record in-flight state tracked by the broker (SharePartition), persisted via the share coordinator in __share_group_stateRaft-replicated queue; message removed on ackReplicated append-only logStateless brokers + BookKeeper ledgersRaft-replicated partitioned log; tiered storage
ReplayYes (retention, seek)Underlying topic, yesNo (destructive)Yes (offset/timestamp)With retention policyYes (Kafka API)
OrderingPer partitionNot an ordered streamFIFO, but redelivery / multiple consumers can reorderPer stream; single active consumerPer key/partition; Key_Shared per keyPer partition
Consumers > partitionsNo (extras idle)YesYes (competing consumers)No — work is split by super-stream partitions (SAC: one active consumer each)Shared / Key_SharedNo (Kafka groups)
Per-message retry / poison limitApp-level (Spring, custom)Delivery count (default limit 5)Delivery limit (default 20) + dead-letteringApp-levelNack, retry letter topic, DLTApp-level
RoutingBy topic/keyBy topicExchanges + bindingsExchanges + bindingsTopics, namespaces, tenantsBy topic/key
Client protocolKafkaKafka (share APIs)RabbitMQ (AMQP) clientsDedicated stream protocol (recommended) or AMQP 0-9-1Pulsar clientsKafka API
▶ Which archive — or post office — do you need? tick your requirements, then recommend
Tick what your workload needs. The chooser scores six options on the facts in the table above.
Heuristic only: each option gets +1 for a requirement it meets per the vendor docs (Sept 2026) and is flagged ✗ on a requirement it can't meet. Real decisions also weigh team skills, existing platforms, cost and licensing, which this toy ignores.
🔥 Fireside chat
Kafka
I keep everything, in order, for as long as you like, and a hundred teams can read it without bothering each other.
RabbitMQ
And I route a parcel by its label to exactly the right courier, retry it twenty times, and file it under "dead letters" if nobody can take it. No partition planning.
Kafka
I do queues now too — share groups.
RabbitMQ
And I do logs now — streams. Seems we both decided the other one had a point.
❓ There are no dumb questions
Q:
Is Redpanda "Kafka without ZooKeeper"?
A:
That framing is outdated: since 4.0, Kafka itself has no ZooKeeper (KRaft, Part II). The real differences are the engine (C++, thread-per-core, single binary), operations and licensing, plus which newer Kafka features are supported.
Q:
If Pulsar has queues and logs, why not always use it?
A:
More moving parts (brokers, bookies, metadata store), a smaller ecosystem of Kafka-native tools, and a different client protocol. If you need its multi-tenancy, geo-replication model or compute/storage split, it's a strong choice. If you don't, you're paying for them anyway.
Q:
Can share groups replace RabbitMQ?
A:
For "many workers, one topic, per-record ack and retry limits", often yes. For routing by header or binding patterns, priorities, or request/reply idioms, RabbitMQ still does things share groups don't.
✏️ Sharpen your pencil

You have a thumbnails topic with 3 partitions and bursts that need 12 workers. Try the in-Kafka queue option on a local 4.3 cluster (share groups are enabled by default on new 4.2+ clusters; on an upgraded cluster first run bin/kafka-features.sh --bootstrap-server localhost:9092 upgrade --feature share.version=1). On a single-broker dev cluster, also set share.coordinator.state.topic.replication.factor=1 and share.coordinator.state.topic.min.isr=1 in the broker config before first use, because __share_group_state defaults to 3 replicas:

bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic thumbnails --partitions 3
bin/kafka-console-share-consumer.sh --bootstrap-server localhost:9092 --topic thumbnails --group thumbs
# start the same command in 3 more terminals, then produce 20 records

How many of the four share consumers receive records? What would happen with kafka-console-consumer.sh --group thumbs2 in four terminals?

Answer

With the share group, records are spread across all four consumers even though there are only 3 partitions — "the number of consumers in a share group can exceed the number of partitions". With a classic consumer group, at most 3 consumers get partitions and the fourth is idle. The share group version gives up per-partition ordering in exchange.

🔬 Go deeper: why the architectures lead to these trade-offs

Where state lives decides what's cheap. A Kafka consumer group stores one committed offset per partition in __consumer_offsets, so a million-record backlog costs the same bookkeeping as ten records. That's why fan-out and replay are nearly free and per-record acknowledgement isn't offered. Share groups add per-record state (acquired/acknowledged/archived and delivery counts) for a bounded window. The broker caps in-flight acquired records per share-partition (group.share.partition.max.record.locks) and persists share-partition state through the share coordinator in __share_group_state. That bound is how Kafka offers queue semantics without per-message state for the whole log.

A quorum queue keeps per-message state by design, so a message leaves once it's acknowledged. RabbitMQ streams and Kafka keep the log and move per-reader state to the consumer (offset tracking). Pulsar tracks acknowledgement per subscription (individual or cumulative), which is how one topic can serve both queue-like Shared and stream-like Exclusive/Failover subscriptions. Redpanda replaces the per-partition ISR replication you learned in Part II with a Raft group per partition. Clients see the same Kafka API, but the replication and failure behaviour underneath differs, so re-validate your durability assumptions (acks, min ISR semantics) against its docs.

📌 Bullet points
  • Decide log vs queue first: replay and fan-out → log; per-message ack, routing, "forget after delivery" → queue.
  • RabbitMQ 4.3: exchanges route; quorum queues are the replicated default (delivery limit 20); streams add a replayable log.
  • Pulsar: stateless brokers + BookKeeper; Exclusive/Failover/Shared/Key_Shared subscriptions; built-in retry letter and DLT topics.
  • Redpanda: Kafka API, different engine (C++, Raft per partition, single binary). Verify newer-feature parity before relying on it (e.g. no server-side KIP-890 / Transactions V2).
  • Kinesis (shards, 24 h default retention up to 365 d) and Pub/Sub (ordering keys off by default) trade Kafka portability for zero ops.
  • Share groups (Kafka 4.2+) are the in-Kafka queue: more consumers than partitions, per-record ack, delivery limit; no ordering.
  • All comparisons are dated Sept 2026. Re-check vendor docs before deciding.

Exam + cheat sheet

check what stuck

One or more questions per chapter. Questions tagged (L2)–(L4) belong to the deeper levels; the wrong answers are the misconceptions the chapters took apart. Click an answer to see why.

Score: 0 / 0

Cheat sheet

Parameter / commandDefault / what it doesUse when
linger.ms5 (since 4.0) — max wait to fill a batchRaise for throughput; lower only for very latency-sensitive, low-volume producers
batch.size16384 bytes — max per-partition batchRaise for high volume or with compression
acksall — leader waits for the full ISRKeep all; 0/1 only when losing data is acceptable
auto.offset.resetlatest — where a group without a committed offset startsearliest to read existing data; none to fail loudly
enable.auto.committrue, every auto.commit.interval.ms = 5000Turn off for manual commit after processing
min.insync.replicas1 — min ISR size for acks=all writes and for records to become committed/visibleSet 2 with RF 3
default.replication.factor1 — RF for topics created without oneSet 3 in production (or pass --replication-factor 3)
retention.ms / retention.bytes604800000 (7 d) / -1 (per partition, unlimited)Match how far back consumers must re-read; cap disk
cleanup.policydelete — or compact (latest value per key)compact for current-state topics
kafka-consumer-groups.sh --describe --group GCURRENT-OFFSET, LOG-END-OFFSET, LAG per partitionChecking if a group keeps up
segment.bytes1073741824 (1 GiB): max segment file sizeLower it for compacted or short-retention topics so data becomes deletable sooner
segment.ms604800000 (7 d): force a roll after this ageLow-volume topics where retention/compaction must act on time
index.interval.bytes4096: bytes between sparse offset-index entriesAlmost never changed
compression.type (producer / topic)none / producer: batch codec; topic "producer" keeps the producer's codecEnable zstd/lz4 on producers; leave the topic at producer to avoid recompression
kafka-dump-log.sh --files f.log [--cluster-metadata-decoder]Print batch headers (offsets, producerId, sequence, codec, crc) or decoded metadata recordsInspect segments, idempotence state, KRaft metadata
broker.session.timeout.ms9000: broker lease without heartbeats (heartbeat every 2000 ms)Tune failover speed vs false fencing
kafka-metadata-quorum.sh describe --status | --replicationLeader id, epoch, high watermark, voters/observers, follower lagChecking controller quorum health
replica.lag.time.max.ms30000: follower not caught up for this long → removed from the ISRRarely; fix slow brokers instead of raising it
group.protocolclassic: consumer rebalance protocol; "consumer" = KIP-848New apps on 4.x brokers: set consumer
max.poll.interval.ms / session.timeout.ms300000 / 45000: progress timeout / liveness timeoutSlow processing / faster crash detection
enable.idempotencetrue (needs acks=all, retries > 0, max.in.flight ≤ 5)Always; set explicitly so conflicting configs fail loudly
transactional.idnull; enables transactions + fencing (implies idempotence)atomic multi-partition writes, consume–transform–produce
transaction.timeout.ms60000; coordinator aborts older open txns (≤ broker transaction.max.timeout.ms, 15 min)lower it so hung producers stop blocking read_committed readers
isolation.levelread_uncommitted; read_committed reads only up to the LSOdownstream of transactional producers
kafka-transactions.sh list | describe | find-hanging | abortinspect and repair transactionsread_committed consumers stuck at the LSO
cleanup.policy=compact[,delete]delete; compact keeps latest value per keychangelog / state / "current value" topics
delete.retention.ms86400000 (24 h) tombstone lifetimeraise if full state replays take longer
num.network.threads / num.io.threads / queued.max.requests3 (per listener) / 8 / 500tune only after reading the idle-percent gauges and the TotalTimeMs breakdown
unclean.leader.election.enablefalse; true lets an out-of-sync replica lead (data loss)emergency, per topic, when availability beats completeness
remote.storage.enable + local.retention.msfalse / -2 (= full retention); local deletion only after uploadlong retention on cheap object storage, non-compacted topics
share.record.lock.duration.ms / share.delivery.count.limit (group)30000 ms / 5tune per share group: slow jobs, poison-record tolerance
processing.guarantee=exactly_once_v2at_least_once default; EOS drops commit.interval.ms to 100Streams results must not double-count
num.standby.replicas / streams.num.standby.replicas0; warm shadow copies of task statelarge state stores, fast failover
kafka-configs.sh --alter --add-config 'producer_byte_rate=…' --entity-type users|clientsSet a client quota (also consumer_byte_rate, request_percentage, controller_mutation_rate); default unlimitedMulti-tenant clusters; noisy neighbours
quota.window.num / quota.window.size.seconds11 samples / 1 sRarely changed; short windows mean short throttles
kafka-topics.sh --describe --under-replicated-partitions | --under-min-isr-partitionsList partitions with a shrunken ISRFirst step for URP / NotEnoughReplicas alerts
kafka-consumer-groups.sh --reset-offsets … --by-duration PT6H --executeMove a stopped group's offsets (dry run without --execute)Replaying after offset-out-of-range or a bad deploy
{source}->{target}.enabled (MM2)falseTurn on a MirrorMaker 2 replication flow
sync.group.offsets.enabled (MM2)falsePre-write translated group offsets for DR failover
kafka-features.sh upgrade --release-version 4.3Finalize the upgrade: sets metadata.version and all feature defaultsAfter all nodes run 4.3 and behave well (4.3-IV0 cannot be downgraded)
kafka-features.sh describe | version-mapping | --dry-runShow finalized levels / what a release maps to / rehearseBefore and after every upgrade
delivery.timeout.ms120000 — total per-record budget incl. retriesTune how long the producer keeps trying; leave retries unset
retries2147483647 — attempts for transient errorsDon't set to 0: disables idempotence
max.poll.interval.ms / max.poll.records300000 / 500Seeing CommitFailedException / rebalances from slow processing
consumer.seek(tp, offset + 1)step over a poison pill after RecordDeserializationExceptionAfter quarantining keyBuffer()/valueBuffer() to a DLQ
consumer.wakeup()the only thread-safe KafkaConsumer call; poll throws WakeupExceptionClean shutdown from another thread
DefaultErrorHandler(recoverer, backOff)default FixedBackOff(0, 9) + log-and-skipAlways pass a DeadLetterPublishingRecoverer and a bounded BackOff
DeadLetterPublishingRecoverer<topic>-dlt, same partition, kafka_dlt-* headersReplayable dead letters; DLT needs ≥ source partitions
ErrorHandlingDeserializerwraps delegate; failure → header, record to error handlerAny consumer whose payloads could be malformed
@RetryableTopic / @DltHandlerattempts 3, fixed 1000 ms, -retry / -dlt suffixesIndependent events where ordering doesn't matter
ContainerProperties.AckModeBATCH (default), RECORD, TIME, COUNT, COUNT_TIME, MANUAL, MANUAL_IMMEDIATERECORD for slow records/retry topics; per listener via @KafkaListener(ackMode=…) since 4.1
ContainerProperties.ShareAckModeEXPLICIT (default), MANUAL, IMPLICITShare consumers in Spring Kafka 4.1
group.share.delivery.count.limit5 — broker default max delivery attempts per recordIn-Kafka queue with a poison-message cutoff
🎓 Congratulations

Back to the question from the start: how can an append-only log that does not force every write to disk be fast, durable and exactly-once?

Fast because appends are sequential writes into segment files, reads are served from the OS page cache, data moves in compressed batches from producer to disk to consumer without being re-encoded, and plain-text reads use zero-copy transfer from file to socket. Durable not because of fsync, but because of replication: a write counts as committed only when every in-sync replica has it (the high watermark), acks=all plus min.insync.replicas put a floor under that set, and leader epochs let a returning broker cut away exactly the records that never got committed. Exactly-once because idempotent producers stamp every batch with a producer ID, epoch and sequence number so retries cannot duplicate, and transactions write commit or abort markers that read_committed consumers respect — with the consumer's offsets committed inside the same transaction.

Next step: run a three-broker KRaft cluster locally, break it on purpose with the exercises from each chapter, and watch the metrics from the troubleshooting chapter while you do.