Messaging · 03

Kafka Producers & Consumers

Most Kafka incidents are client-side: duplicates, missing records, rebalance storms and lag. This page opens the two client libraries. It follows a record through the producer (serializer, partitioner, accumulator, sender), explains acks, retries, idempotence and transactions, then the consumer side (groups, offsets, commits, replay, lag, rebalancing and the new KIP-848 protocol in Kafka 4.0), schemas and the registry, and ends with plain kafka-clients code and a lab on the apache/kafka:4.0.0 image.

13
Chapters
4.0
Kafka version
10
Diagrams
6
Compiled samples
01
Chapter One - The Producer

The Send Path: From send() to the Broker

Page 02 explained what the brokers do. This page is about the clients: the producer and the consumer libraries in kafka-clients. Most Kafka production incidents are client-side (duplicates, lost messages, rebalance storms, lag), and almost all of them trace back to a handful of mechanisms inside these two libraries. We start with the producer, because its most important property is easy to miss: **send() does not send anything**.

KafkaProducer.send() serializes the record, picks a partition, appends the record to an in-memory batch, and returns a Future. A separate Sender thread later drains the batches and transmits them. Understanding this split explains throughput, latency, memory use, and several confusing exceptions.

What happens inside producer.send(): one thread appends, another thread ships
Application thread (the caller of send()) send(record) Serializer key + value to bytes Partitioner key to partition Append to accumulator returns a Future immediately RecordAccumulator (buffer.memory = 32 MiB shared by all partitions) orders-0 batch batch orders-1 batch orders-2 batch batch batch Sender thread (background I/O) 1. find ready batches 2. group them by broker 3. send, handle responses Broker 1 leader of orders-0 Broker 2 leader of orders-1, orders-2 one ProduceRequest per broker A batch is "ready" when: - it is full (batch.size) - linger.ms has passed - flush() or close() was called - memory is short and others wait The broker response completes the Future and runs your callback on the Sender thread.
StageRuns onWhat it doesCan fail with
Interceptors (optional)Caller threadProducerInterceptor.onSend() can modify or observe the recordExceptions are logged and ignored
Metadata waitCaller threadNeeds the topic's partition count and the leaders. Fetched once, then cached and refreshed on errorsTimeoutException: topic not present in metadata after max.block.ms
SerializerCaller threadkey.serializer and value.serializer turn objects into byte[]SerializationException
PartitionerCaller threadKeyed: murmur2(key) % partitions. Unkeyed: sticky batching (chapter 02)Invalid explicit partition
Size checks and appendCaller threadRejects a record larger than max.request.size (1 MiB) or buffer.memory; takes memory from the pool; appends to the batch for that partitionRecordTooLargeException; TimeoutException when memory stays full for max.block.ms
Drain and sendSender threadCollects ready batches, groups them per broker into one request, tracks in-flight requests, retries retriable errorsReported through the callback or Future
CallbackSender threadRuns when the broker answers or the delivery deadline passesAnything you throw is swallowed after logging, so keep it short
Key insight: send() is dropping a parcel into the outgoing tray. The mail van (the Sender thread) comes when the tray is full or when a timer rings, and takes everything for one destination in a single trip. You learn whether a parcel arrived only when the receipt (callback) comes back, which may be much later than the moment you dropped it.
Where send() can block

Two waits happen inside send(), and both are bounded by the same setting, max.block.ms (default 60000, verified in the Kafka 4.0 producer configuration reference). The first is metadata: the very first send to a topic must learn its partitions, and a typo in the topic name or a broker that is unreachable shows up as TimeoutException: Topic orders not present in metadata after 60000 ms. The second is memory: if the Sender cannot drain batches as fast as the application fills them, the pool of buffer.memory (default 32 MiB) runs dry and send() waits for space.

The two places send() can block, and what you see when it does
send(record) Do I know the leader and partition count? yes Is there buffer memory left? yes appended, Future back no: wait for metadata no: wait for free memory blocks up to max.block.ms blocks up to max.block.ms TimeoutException thrown from send() Both waits share one budget: max.block.ms (default 60 s). Serializer and partitioner time is not counted.
⚠ Treating a blocked send() as a Kafka outage
  • A producer thread stuck in send() for a minute is a symptom: either the cluster is unreachable, or the producer is producing faster than the brokers (or the network) accept. Look at the producer metrics buffer-available-bytes and bufferpool-wait-time, and at broker request latency, before touching timeouts. Raising max.block.ms only makes the application hang longer.
The record you hand over
  • A ProducerRecord has a topic, an optional partition, an optional key, a value, optional headers, and an optional timestamp (default: the time of send(), CreateTime).
  • The key decides the partition (page 02, chapter 07) and therefore the ordering group. The value may be null: on a compacted topic that is a tombstone (delete marker).
  • Headers are key and byte-value pairs that travel with the record and are never used for partitioning. Use them for trace ids, event ids for deduplication, content type, and schema hints.
  • KafkaProducer is thread-safe. Create one instance per application (per set of settings) and share it. One producer per request or per thread wastes connections and defeats batching.
02
Chapter Two - Throughput

Batching, Compression and Partitioning

Kafka is fast because clients ship batches, not single records (page 02, chapter 06). The producer keeps one batch per partition in the accumulator. Two settings decide when a batch leaves: batch.size (how big it may grow) and linger.ms (how long the producer waits for more records before sending what it has).

How linger.ms and batch.size decide when a batch leaves
Time flows to the right. Each tick is one send() call for the same partition. Case A: low traffic records arrive linger.ms (5 ms) from first record send 3 records a small batch goes out because the timer fired Case B: heavy traffic batch fills to batch.size (16 KiB) before the timer fires send full batch Bigger batches mean fewer requests, better compression and more throughput. linger.ms trades a few ms of latency for them.
SettingDefault (Kafka 4.0)What it controlsTuning note
batch.size16384 (16 KiB)Maximum bytes of one batch (per partition). Memory is allocated in this unitIt is a ceiling, not a target: a batch is sent early if the timer fires. A single record larger than this gets a batch of its own
linger.ms5How long to wait for more records before sending a non-full batchChanged from 0 to 5 in Kafka 4.0 (upgrade notes). Raise to 20-100 ms for throughput-oriented pipelines; keep low for latency-sensitive paths
compression.typenoneCompression of the whole batch: gzip, snappy, lz4, zstdLarger batches compress better. The broker stores the batch as received, and consumers decompress
buffer.memory33554432 (32 MiB)Total memory for unsent batches across all partitionsSize it above batch.size x active partitions or batches will wait on each other
max.request.size1048576 (1 MiB)Maximum size of one produce request (also caps one record)Must stay at or below the broker message.max.bytes or topic max.message.bytes
max.block.ms60000How long send() and a few other calls may blockSee chapter 01
Which partition does a record get?

The partitioner runs in the caller thread, before the record reaches the accumulator. In Kafka 4.0 the producer has a built-in default and partitioner.class is null (verified); you only set it to plug in your own logic.

CaseWhat the producer doesWhy
Record has a keymurmur2(key bytes) modulo the partition countSame key, same partition: per-key order and co-location
No keySticky: fill one partition's batch, then switch. With partitioner.adaptive.partitioning.enable (default true) brokers that are slower get fewer recordsFewer, fuller batches than round-robin per record
Explicit partition on the recordUsed as givenYou own the balance
partitioner.ignore.keys=true (default false)Keyed records are treated as unkeyedRarely wanted; breaks per-key ordering
⚠ Changing the partition count under a keyed topic
  • The key-to-partition mapping depends on the partition count. Adding partitions moves keys, so records for one key can land in two partitions, and the new partition has no history. If order per key matters, create a new topic with the larger count and migrate rather than altering in place (page 02 covers sizing).
Compression needs the library

gzip ships with the JDK, but snappy, lz4 and zstd are implemented by separate libraries that kafka-clients declares as optional. If you set compression.type=lz4 without org.lz4:lz4-java on the classpath, the producer fails when it first compresses a batch. This is a real failure we hit while preparing this page: NoClassDefFoundError: net/jpountz/lz4/LZ4Factory. Spring Boot starters and the Confluent bundles usually pull these in; a plain kafka-clients project does not.

A sensible starting point
  • Leave batch.size and buffer.memory alone until metrics tell you otherwise.
  • Set compression.type=zstd or lz4 for JSON or text payloads; keep none for payloads that are already compressed (images, Avro with its own codec).
  • Raise linger.ms (and then batch.size, e.g. 64-128 KiB) when the goal is throughput. Check batch-size-avg, records-per-request-avg and compression-rate-avg in the producer metrics to see whether batches are actually filling.
03
Chapter Three - Delivery

acks, Retries, Timeouts and Ordering

A produce request can fail in many ways: the leader just moved, the network dropped the response, the ISR shrank below min.insync.replicas. The producer's job is to hide transient failures and to tell you clearly about permanent ones. Four settings do almost all of the work.

SettingDefault (Kafka 4.0)Meaning
acksall0: do not wait. 1: the leader wrote it. all: every in-sync replica wrote it (at least min.insync.replicas). Page 02, chapter 04, covers the durability pairing
retries2147483647How many times a retriable failure is retried. The docs recommend leaving it alone and using delivery.timeout.ms instead
delivery.timeout.ms120000Upper bound on the time from send() returning to success or failure: batching delay, in-flight time and all retries together
request.timeout.ms30000How long to wait for the answer to one request before treating it as failed and retrying
retry.backoff.ms / retry.backoff.max.ms100 / 1000Wait between attempts, growing exponentially up to the maximum
max.in.flight.requests.per.connection5Unacknowledged requests the client sends on one connection before waiting
delivery.timeout.ms is the single deadline that wraps every retry
Timeline of one batch (not to scale) send() delivery.timeout.ms = 120 s linger request 1 fails (timeout) backoff request 2 fails backoff request 3: ack attempts continue until an ack arrives or the deadline passes. retries is 2147483647, so the deadline is the real limit. Rule: delivery.timeout.ms (120 s) must be at least request.timeout.ms (30 s) + linger.ms (5 ms). Why ordering can break when idempotence is OFF and max.in.flight > 1 batch A (records 1-5) batch B (records 6-10) in flight in flight A fails (retriable) B is appended first A retried: log is now B, A With idempotence ON the broker rejects B out of sequence until A arrives.
Retriable and non-retriable failures
KindExamplesWhat the producer does
RetriableNotLeaderOrFollowerException, NetworkException, TimeoutException on a request, NotEnoughReplicasExceptionRefresh metadata if needed, wait for the backoff, resend, until delivery.timeout.ms
Non-retriableRecordTooLargeException, SerializationException, InvalidTopicException, TopicAuthorizationExceptionFail the record immediately; your callback gets the exception
Deadline passedTimeoutException whose message says the records expired after delivery.timeout.ms since batch creationFail the whole batch. The record may or may not have reached the broker (the final response could have been lost)

The last row is the one to remember. A timeout is not "the message was not written". It means "no acknowledgment arrived in time". Without idempotence, resending after such a timeout in your own code can create a duplicate; this is exactly the ambiguity the next chapter resolves.

Handling a failed send
  • The callback (or future.get()) is the only place you learn the final outcome. Log it with the key and topic, and route the record to a retry topic, a dead-letter topic, or an outbox table. Never swallow the exception in the callback.
  • A fire-and-forget send() with no callback is acceptable only for data you can lose (metrics, debug logs).
Ordering and max.in.flight

Records of one partition are written in the order batches reach the broker. With several batches in flight on one connection, a failure of an early batch followed by success of a later one lets the retry land after its successor (lower panel of the diagram). That is why older guides say "set max.in.flight.requests.per.connection=1 for strict order", at a large throughput cost.

With idempotence enabled (the default since Kafka 3.0) that advice no longer applies. The broker tracks a sequence number per producer and partition and rejects a batch that arrives ahead of its predecessor, so ordering is preserved for any in-flight value up to 5. The producer configuration reference states this directly: with idempotence on, ordering is preserved, and the value must be 5 or less because the broker retains metadata for at most five batches per producer.

Idempotencemax.in.flightRetriesPer-partition order after a failure
On (default)1 to 5YesPreserved
Off1YesPreserved, low throughput; duplicates possible
Offgreater than 1YesMay be reordered, duplicates possible
OffanyNo (retries=0)Preserved, but failed batches are lost

Kafka 4.0 also removed a convenience: the upgrade notes say enable.idempotence no longer silently falls back when max.in.flight.requests.per.connection exceeds 5. If you set idempotence explicitly with conflicting values (an in-flight above 5, acks other than all, retries=0) the producer throws a ConfigException.

⚠ Setting delivery.timeout.ms below the sum it must cover
  • The producer refuses to start when delivery.timeout.ms is smaller than request.timeout.ms + linger.ms. A subtler error is setting a long request.timeout.ms and a short delivery timeout: the first attempt eats the whole budget and you get no retries at all. Keep the delivery timeout at several request timeouts so that at least two or three attempts fit.
04
Chapter Four - No Duplicates From Retries

The Idempotent Producer

Retries create a dilemma. If a response is lost, the producer cannot tell "the broker never got it" from "the broker got it and the answer was lost". Retrying risks a duplicate; not retrying risks loss. The idempotent producer removes the dilemma by making the retry safe: resending the same batch has no additional effect. It is on by default since Kafka 3.0 (enable.idempotence=true in the 4.0 reference, which requires acks=all, retries above 0 and max.in.flight of 5 or less).

How the broker rejects a duplicate batch: producer id + partition + sequence
Producer pid 7, epoch 0 Partition leader orders-0 state: pid 7, last seq = -1 batch: pid 7, baseSeq 0, 5 records expected 0, got 0: append (offsets 0-4) state: last seq = 4 ack LOST on the network no ack before request.timeout.ms: retry retry: pid 7, baseSeq 0, 5 records seq 0 is already stored: DUPLICATE do not append, answer success ack (offset 0, as the first time) next batch: baseSeq 5 expected 5, got 5: append If a batch arrived with baseSeq 9 while 5 was expected, the broker answers OutOfOrderSequenceException: something was lost, so the producer must not pretend the stream is intact.
Producer starts
▶
It asks a broker for a producer id (PID) with an InitProducerId request. The PID is unique across the cluster and has an epoch
Each partition gets its own counter
▶
Every record sent to a partition by this producer is numbered 0, 1, 2, ... A batch carries its base sequence and its record count
The leader checks every batch
▶
It remembers the last sequence it appended for each PID and partition, plus metadata for the last five batches. Next expected: append. Already seen: acknowledge without appending. Gap: reject
Retry after a timeout
▶
The producer resends the same batch with the same PID and sequence. If the first attempt had landed, the broker recognises it and returns success
Ordering comes free
▶
Because sequences must be contiguous, the broker will not append batch 2 before batch 1, which is why up to 5 requests can be in flight safely
You can see it on disk

Every batch stored by the broker carries the producer id, epoch and sequence. kafka-dump-log.sh prints them. Below are three batches from partition 0 of the lab topic in chapter 10, abridged (extra columns removed from lines that were produced by a real Kafka 4.0.0 broker). The first two came from the Java producer of chapter 10 (PID 2, sequences 0 and 1), the third from kafka-console-producer.sh (a different PID, so its sequence starts again at 0).

Shell - kafka-dump-log.sh on a partition segment (abridged)
baseOffset: 0 lastOffset: 0 count: 1 baseSequence: 0 lastSequence: 0 producerId: 2 producerEpoch: 0 isTransactional: false isControl: false baseOffset: 1 lastOffset: 1 count: 1 baseSequence: 1 lastSequence: 1 producerId: 2 producerEpoch: 0 isTransactional: false isControl: false baseOffset: 2 lastOffset: 10 count: 9 baseSequence: 0 lastSequence: 8 producerId: 3 producerEpoch: 0 isTransactional: false isControl: false
What idempotence does not cover
SituationProtected?Why
Producer retries after a lost ack or leader changeYesSame PID and sequence: broker deduplicates
Reordering caused by retries with up to 5 in flightYesSequence gaps are rejected
Application crashes and restarts, then re-sends the same business eventNoA new KafkaProducer gets a new PID; sequences restart at 0, so the broker cannot know the event is a repeat
Two producer instances send the same logical eventNoDifferent PIDs
The record already failed (callback got an exception) and you send it again yourselfNoA new send() is a new sequence number
Duplicates produced upstream and read by your consumerNoThis is the consumer's problem (chapter 06, idempotent consumer)

Idempotence therefore protects one producer session against its own retries. Surviving a restart needs a stable identity, which is what transactional.id provides (next chapter), or an application-level event id that the consumer deduplicates on (page 01, idempotent consumers).

Epoch and recovery
  • The epoch lets the broker tell an old incarnation of a producer from a new one. When a producer's batch expires, its sequence numbers become uncertain; since Kafka 2.5 the client can bump the epoch and reset sequences for an idempotent producer and carry on instead of failing permanently. You normally never see this, but it is why a long outage does not always mean a dead producer.
05
Chapter Five - Atomic Writes

Transactions and Exactly-Once Semantics

Idempotence covers one producer talking to one partition within one session. Real pipelines do more: read a record, transform it, write results to several partitions, and remember how far they have read. If the process crashes between writing the output and committing the input offset, a restart processes the input again and writes the output twice. Kafka transactions make "write these outputs and advance the input offset" atomic: both happen, or neither.

Consume-transform-produce as one transaction (exactly-once inside Kafka)
Input topic orders Your application (transactional.id = relay-0) 1. poll() with read_committed 2. beginTransaction() 3. send() transformed records 4. sendOffsetsToTransaction( offsets, groupMetadata) Output topic orders-enriched __consumer_offsets (group relay) new data and the new input position are both pending 5. commitTransaction() Transaction coordinator (a broker; state in __transaction_state, keyed by transactional.id) Ongoing PrepareCommit (decision) markers written, CompleteCommit The commit marker (a control record) is appended to every partition touched. read_committed readers hide data until it appears.
PieceRole
transactional.idA stable name for this logical producer, unique per instance (e.g. relay-0, relay-1). It survives restarts and maps to a PID in the transaction coordinator. Default is null (no transactions)
initTransactions()Registers the id with the coordinator, gets a PID and epoch, and fences any older instance with the same id
Epoch and fencingEach initTransactions() raises the epoch. A "zombie" instance (paused, partitioned, then woke up) still holds the old epoch; the broker rejects its writes with ProducerFencedException (or InvalidProducerEpochException)
sendOffsetsToTransaction(offsets, groupMetadata)Adds the consumer group's new offsets to the transaction, so input progress commits with the output. In Kafka 4.0 only the ConsumerGroupMetadata form exists; the older overload that took a group id string was removed (4.0 upgrade notes)
commitTransaction() / abortTransaction()The coordinator records the decision in __transaction_state, then writes commit or abort markers to every partition in the transaction
isolation.level=read_committed (consumer)Hides records of open and aborted transactions. Default is read_uncommitted, which returns everything, including aborted data
transaction.timeout.msProducer setting, default 60000. A transaction open longer is aborted by the coordinator. The broker caps it with transaction.max.timeout.ms (default 15 minutes)
How a read_committed consumer behaves

Data is appended to the log as soon as it is sent, before the transaction commits. The broker therefore tracks the last stable offset (LSO): the offset below the first record of any still-open transaction. A read_committed consumer can read only up to the LSO and the broker sends it the list of aborted ranges so it can skip them. Consequences:

  • Latency of downstream consumers includes the transaction duration: nothing after an open transaction is visible until it completes, even records of other producers (the consumer reads in offset order).
  • A hung transaction stalls every read_committed consumer of that partition until transaction.timeout.ms expires. The consumer docs say so directly: in read_committed mode poll() returns only up to the LSO, so consumers cannot reach the high watermark while a transaction is in flight.
  • Commit and abort markers are control records. They occupy an offset but are never returned to the application.

You can see the markers in a real run. The lab in chapter 10 reads 35 records from orders and writes 35 transformed records to orders-enriched, using TransformRelay from chapter 10. Yet the log end offsets of the output topic add up to 38, three more than the input:

Shell - log end offsets of input and output (real output from the lab)
$ kafka-get-offsets.sh --bootstrap-server localhost:9092 --topic orders orders:0:11 orders:1:15 orders:2:9 $ kafka-get-offsets.sh --bootstrap-server localhost:9092 --topic orders-enriched orders-enriched:0:12 orders-enriched:1:16 orders-enriched:2:10

The extra three offsets are the commit markers, one per partition written. kafka-dump-log.sh shows the batch (transactional, with PID 4) followed by the control batch (abridged below). Note the epoch moves from 1 to 2 between the data and the marker: with Kafka 4.0 clients and brokers the producer epoch is bumped as part of every transaction (KIP-890, "server side defense", described in the 4.0 upgrade notes), so a delayed message from an earlier transaction can never be mistaken for the current one.

Shell - data batch and commit marker in orders-enriched-0 (abridged)
baseOffset: 0 lastOffset: 10 count: 11 baseSequence: 0 lastSequence: 10 producerId: 4 producerEpoch: 1 isTransactional: true isControl: false baseOffset: 11 lastOffset: 11 count: 1 baseSequence: -1 lastSequence: -1 producerId: 4 producerEpoch: 2 isTransactional: true isControl: true
Offsets are not dense on transactional topics
  • On a topic written transactionally the offsets of consecutive records may skip numbers, and the log end offset can exceed the record count. Code that assumes lastOffset - firstOffset + 1 == recordCount, or that computes lag as "end minus committed equals messages waiting", will be off by the number of markers.
What exactly-once does NOT cover

"Exactly-once" in Kafka means: for a read-process-write cycle whose inputs and outputs are all Kafka topics, each input record's effect appears exactly once in the outputs. That is a precise and useful claim, and a narrow one.

ScenarioCovered?What to do instead
Kafka to Kafka, same cluster, read_committed downstreamYesTransactions (or Kafka Streams processing.guarantee=exactly_once_v2)
Consumer writes to a database, calls an HTTP API, sends an email, charges a cardNoIdempotent consumer: dedup key plus business write in one DB transaction (chapter 06)
Downstream consumers left on read_uncommittedNoThey see aborted records. Set isolation.level=read_committed
Two clusters (mirroring, cross-cluster copy)NoTransactions do not span clusters; design for idempotence at the destination
Upstream sources writing duplicates before KafkaNoDeduplicate by business key
A consumer that also commits offsets itself while a transactional producer uses the same groupNoOffsets of a consume-transform-produce loop go only through sendOffsetsToTransaction
⚠ Transactions are not free, and not per record
  • Each transaction costs several round trips (register partitions, offsets, prepare, markers). Commit once per poll batch (hundreds of records), not once per record; a 100 ms commit cycle on 500 records is cheap, the same cycle per record is a throughput disaster. Keep transactions short (well under transaction.timeout.ms) so downstream read_committed consumers are not delayed.
⚠ Sharing a transactional.id between instances
  • Two running instances with the same transactional.id fence each other forever: each initTransactions() kills the other with ProducerFencedException. Give every instance its own stable id (a StatefulSet ordinal, a partition-derived name), and treat a fencing exception as fatal: close the producer, do not retry. The same goes for OutOfOrderSequenceException and AuthorizationException; for any other KafkaException inside a transaction, call abortTransaction(), rewind the consumer to its committed offsets and try again.
06
Chapter Six - The Consumer

The Poll Loop and Consumer Groups

A consumer is the mirror image of the producer, with one big difference: it pulls. The consumer asks the broker "give me records after offset N", and the broker answers from the leader replica. The consumer controls the pace, which is Kafka's built-in backpressure: a slow consumer simply falls behind, and the log keeps the data until retention removes it.

The poll loop

Everything a consumer does is driven by one call, poll(Duration), in a loop run by one thread. A single poll() can fetch records, send heartbeats and join or leave the group (on the classic protocol a background thread also sends heartbeats), run rebalance callbacks, and, if enabled, auto-commit offsets. If you stop calling it, the consumer stops being a group member.

SettingDefault (Kafka 4.0)Effect
max.poll.records500Most records returned by one poll(). The unit of work between two polls
fetch.min.bytes / fetch.max.wait.ms1 / 500The broker answers when it has at least this many bytes or the wait expires
fetch.max.bytes52428800 (50 MiB)Upper bound on the data of one fetch response (soft limit)
max.partition.fetch.bytes1048576 (1 MiB)Upper bound per partition per fetch (soft limit: a bigger record is still delivered)
max.poll.interval.ms300000 (5 min)Longest allowed gap between two poll() calls before the member is considered failed
isolation.levelread_uncommittedSet read_committed to hide aborted and open transactions (chapter 05)
client.rack"" (empty)Rack id; with a broker rack-aware replica selector it lets the consumer fetch from a nearby follower
One topic of 6 partitions read by two independent consumer groups
topic: orders P0 P1 P2 P3 P4 P5 group "billing" (2 consumers): every partition has exactly ONE owner inside the group consumer C1 owns P0, P1, P2 consumer C2 owns P3, P4, P5 billing group "audit" (1 consumer): has its own offsets and sees every record consumer A1 owns P0 to P5 Adding a consumer to a group moves partitions between its members (a rebalance). Adding a group changes nothing for the others. With 6 partitions a group can use at most 6 active consumers: a 7th member joins but gets no partition and sits idle. Groups are for scaling and failover of one logical application. Different applications use different group.id values.
Groups, coordinators and assignment

A consumer group is a set of consumers sharing one group.id. Kafka assigns each partition of the subscribed topics to exactly one member, so the members split the work and never process the same partition at the same time. If a member dies its partitions are given to the survivors. That is how a consumer application scales out and fails over.

  • Each group has a group coordinator: the broker that leads the __consumer_offsets partition the group id hashes to (50 partitions by default, offsets.topic.num.partitions). It tracks membership, heartbeats and commits.
  • On the classic protocol the group's members send their subscriptions to the coordinator, one member (the group leader) computes the assignment on the client with an assignor, and the coordinator distributes it. On the new protocol (KIP-848, chapter 08) the broker computes it.
  • subscribe(topics) uses group management and rebalancing. assign(partitions) skips it: you name the partitions yourself, there is no group membership and no rebalance (used by replay tools, chapter 07).
Assignor (classic protocol)How it spreads partitionsRebalance style
RangeAssignorPer topic, contiguous ranges per member. Co-partitions two topics so partition 0 of both go to one consumerEager
RoundRobinAssignorRound-robin over all subscribed partitions, most evenEager
StickyAssignorEven, and keeps existing ownership where possibleEager
CooperativeStickyAssignorSame as sticky, but moves partitions in two phasesCooperative (chapter 08)

The Kafka 4.0 default is partition.assignment.strategy = [RangeAssignor, CooperativeStickyAssignor] (verified). A group uses the first assignor that all members support, so by default it runs Range, which is eager. The list exists to let you move to cooperative with one rolling restart that drops RangeAssignor. We saw this in the lab: kafka-consumer-groups.sh --describe --state printed ASSIGNMENT-STRATEGY range for a default classic group.

⚠ More consumers than partitions
  • Scaling the consumer deployment from 6 to 12 pods against a 6-partition topic doubles the cost and not the throughput: six consumers idle. Partition count is the ceiling of parallelism (page 02). If per-record work is slow and the ceiling is the problem, you need more partitions (with the key-mapping caveat of chapter 02) or concurrency inside a consumer (chapter 11).
Key insight: A topic is a conveyor belt split into lanes (partitions). A consumer group is a shift of workers; each lane has exactly one worker per shift. Another team (another group) can stand at the same belt with its own clipboard and watch every item without disturbing the first team.
07
Chapter Seven - Progress

Offsets, Commits, Replay and Lag

An offset is a record's position within one partition (page 02). Three different numbers describe a consumer's progress, and confusing them is behind most "why did it reprocess / skip" questions: the position (the next offset this consumer instance will fetch, held in memory), the committed offset (the position saved in Kafka, which is where a replacement starts), and the log end offset (the next offset to be written).

Three numbers per partition: committed offset, position, log end offset
0 1 2 3 4 5 6 7 8 9 10 11 partition orders-0 committed = 5 (stored in Kafka) position = 9 (in the consumer) log end = 12 Green: offsets 0-4 are done and committed. Gold: 5-8 were returned by poll() but not committed. Gray: 9-11 are not fetched yet. Lag = log end - committed = 12 - 5 = 7 records. If the consumer crashes now, the next owner starts at the COMMITTED offset (5): records 5-8 are delivered again. The committed value is always the NEXT offset to read, that is, last processed + 1.
Where offsets live: __consumer_offsets
  • Commits are written to an internal topic, __consumer_offsets (50 partitions, replication factor 3 by default). A commit is a normal record: key = (group, topic, partition), value = offset plus metadata and leader epoch.
  • The topic is compacted: only the latest commit per key is kept. The group's coordinator is the leader of the partition its id hashes to.
  • Committed offsets of a group with no members are retained for offsets.retention.minutes (default 10080, 7 days) and then deleted (Kafka 4.0 broker configuration reference).
  • Consumers do not have to use Kafka for offsets at all (see the database row in the table below), but the group tooling and lag monitoring read from here.
⚠ Offsets expire, and latest is the default
  • If a group stays empty longer than offsets.retention.minutes (a long weekend plus a failed deploy is enough for a small value), its commits vanish. On the next start there is no committed offset, so auto.offset.reset decides: the default is **latest**, and the consumer silently jumps to the end and skips everything produced meanwhile. Set earliest for consumers that must not miss data, or none to fail loudly, and alert on lag so that downtime is noticed long before expiry.
auto.offset.reset: only when there is no valid offset
ValueBehaviour when no committed offset exists or it is out of rangeTypical use
latest (default)Start at the end; only new records are readReal-time dashboards where history does not matter
earliestStart at the oldest retained recordAnything that must process every event; first run of a new group
noneThrow an exception to the applicationSafety-critical consumers that want to decide explicitly
by_duration:PnDTnHnMn.nSStart at the position that is that far back from now (ISO-8601 duration, e.g. by_duration:PT1H)Reprocess the last N hours for a new group

This setting is not "where to start every time". Once a group has a committed offset, it is ignored. To start somewhere else you must reset the group's offsets or seek().

Commit strategies and what they guarantee
StrategyHow it worksGuarantee
Auto commit (enable.auto.commit=true, default)Inside poll(), if auto.commit.interval.ms (5 s) has passed, commits the positions returned by the previous pollAt-least-once if all records of the previous poll were fully processed before the next poll(). Lost records if processing is handed to another thread that has not finished
**Manual commitSync() after processing**Blocks until the broker confirms; retries internally on retriable errorsAt-least-once: a crash after processing, before commit, replays the batch
**Manual commitAsync()**Returns at once; callback reports the result; never retried (a retry could overwrite a newer commit)At-least-once; use for throughput, and a final commitSync() on shutdown and in onPartitionsRevoked
Commit before processingcommitSync() right after poll(), then processAt-most-once: a crash during processing loses those records
Offsets stored with the result (same DB transaction)Write business data and the offset together; on assignment seek() to the stored offsetEffectively once into that database; Kafka's committed offset is then informational
**sendOffsetsToTransaction** (chapter 05)Offset commits atomically with Kafka outputExactly-once Kafka to Kafka
Replay: seek by offset, timestamp, or from the command line
  • seek(partition, offset), seekToBeginning(...), seekToEnd(...) set the position. The next poll() fetches from there.
  • offsetsForTimes(Map<TopicPartition, Long>) returns, for each partition, the earliest offset whose timestamp is at or after the given time (or null if no such record). Combine it with seek() to "replay from yesterday 09:00". It uses the .timeindex files of page 02.
  • beginningOffsets() and endOffsets() give the first retained and the next-to-be-written offsets.
  • From outside the application: kafka-consumer-groups.sh --reset-offsets with --to-earliest, --to-latest, --to-offset, --to-datetime, --by-duration, --shift-by, or --from-file. The group must be inactive (chapter 10 shows the exact error).

Replay is the log's superpower, and the reason consumers must be idempotent (below): a replay deliberately delivers old records again.

Consumer lag

Lag is the number of records between the log end offset and the group's committed offset, per partition. It is the one consumer-health metric to alert on: a steadily growing lag means the consumers are slower than the producers.

Where to read itHow
Command linekafka-consumer-groups.sh --describe --group billing prints CURRENT-OFFSET, LOG-END-OFFSET and LAG per partition
Consumer metrics (JMX)records-lag-max and records-lag in the consumer-fetch-manager-metrics group, measured against the consumer's own position
Broker-side exportersTools such as Burrow or the Kafka exporter for Prometheus compute lag from committed offsets; these keep reporting when the consumer is dead
  • Lag in records is not lag in time. Ten thousand records may be seconds or hours behind, depending on the rate; Kafka does not report time lag. Estimate it from rate, or compare record timestamps.
  • A lag that grows only on one partition points at a hot key or a poison record, not at capacity.
  • A lag of zero with a stalled consumer is possible if nothing is produced: pair lag alerts with a "consumer is polling" check.
The consumer side of exactly-once: be idempotent

Everything on this page produces at-least-once delivery by design: a crash, a rebalance, or a replay re-delivers records that may already have had effects. Page 01 introduced the cure, the idempotent consumer: make processing a record twice equal to processing it once. The robust form puts the dedup record and the business write in one database transaction; chapter 10 has the Java.

SQL - the dedup table behind an idempotent consumer
CREATE TABLE processed_events ( event_id VARCHAR(100) PRIMARY KEY, processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- In ONE database transaction: -- 1. INSERT INTO processed_events(event_id) VALUES (?) ON CONFLICT DO NOTHING; -- 2. if 0 rows were inserted: duplicate, skip (the business write already happened) -- 3. otherwise apply the business change -- 4. COMMIT -- Only then commit the Kafka offset.
  • Dedup key: prefer a business event id that the producer put in the payload or a header. topic-partition-offset also works for plain replays, but it changes if the producer re-sends the same event (a new offset) and is meaningless after copying to another cluster.
  • Order matters: process and commit the DB transaction first, then commit the Kafka offset. A crash in between replays the record, and the dedup row turns the replay into a no-op.
  • Retention of dedup rows: keep them at least as long as a replay can happen (the topic retention) plus the maximum consumer downtime.
08
Chapter Eight - Group Dynamics

Rebalancing: Triggers, Protocols and Timeouts

A rebalance redistributes a group's partitions among its members. It is what makes consumer groups elastic and fault tolerant, and, when badly configured, the most disruptive thing a consumer application can do to itself. Understanding the three timeouts and the two protocols turns "mysterious rebalance storms" into a short checklist.

TriggerExample
A member joinsA pod starts, a deploy rolls out a new instance
A member leaves cleanlyconsumer.close(), graceful shutdown
A member is declared dead: session timeoutJVM crash, node lost, no heartbeat for session.timeout.ms
A member is declared dead: poll intervalProcessing between two poll() calls took longer than max.poll.interval.ms
The subscription or topic metadata changesPartitions added to a topic, a regex subscription matches a new topic
The three timeouts (classic protocol, Kafka 4.0 defaults)
SettingDefaultWho watches itWhat happens when it expires
heartbeat.interval.ms3000Consumer sends heartbeats this often (from a background thread)Not a timeout itself; set to no more than a third of the session timeout
session.timeout.ms45000Broker: no heartbeat for this longMember removed, rebalance. Must be within the broker's group.min.session.timeout.ms (6 s) and group.max.session.timeout.ms (30 min)
max.poll.interval.ms300000Client: time between poll() callsThe client leaves the group itself. Later commits fail with CommitFailedException because the member no longer owns the partitions
max.poll.records500Bounds the work between two pollsLower it when per-record work is slow

The two timeouts exist because they detect different failures: heartbeats prove the process and network are alive; the poll interval proves the application loop is making progress. A consumer stuck in a slow database call keeps heartbeating but stops polling, and the poll interval is what evicts it.

A batch of 500 records takes 8 minutes to process
▶
max.poll.interval.ms (5 minutes) expires. The consumer leaves the group and its partitions move to another member
The slow consumer finally calls commitSync()
▶
It fails with CommitFailedException; the batch is processed again by the new owner (duplicates)
The slow consumer rejoins
▶
A new rebalance starts. If the same slowness hits the next member, the group churns: a rebalance storm with no progress and rising lag
Eager vs cooperative rebalance when a third consumer joins (3 partitions)
EAGER (default Range, classic protocol): everyone gives up everything P0 P1 P2 nobody reads C3 joins: all partitions revoked COOPERATIVE (CooperativeStickyAssignor, or the new protocol): only what must move P0 P1 P2 only P1 pauses briefly Green = being consumed. Red = unowned: records pile up as lag until the new owner starts.
Eager vs cooperative (classic protocol)

In an eager rebalance every member revokes all its partitions, rejoins, and receives a new assignment, even if most partitions end up with the same owner. During that window the whole group is stopped. With a big group, state to rebuild, or a rolling deploy that triggers one rebalance per pod, the pauses add up.

Cooperative rebalancing (CooperativeStickyAssignor) is incremental: in a first round only the partitions that must move are revoked; in a second round they are assigned to their new owners. Everything else keeps being consumed. To adopt it set partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor (or, on the default list, drop RangeAssignor after all members have been upgraded to a version that lists both).

Rebalance callbacks
  • ConsumerRebalanceListener.onPartitionsRevoked is where you commit what you have processed and flush state for partitions you are about to lose; onPartitionsAssigned is where you load state or seek(). With cooperative rebalancing, onPartitionsRevoked receives only the partitions that really move, and onPartitionsLost is called when the ownership was already lost (session expired), where committing is no longer possible.
Static membership: restarts without rebalances

Normally a restarted consumer gets a new member id, so a restart means two rebalances (leave, join). Setting a unique, stable group.instance.id per instance (default null) makes the member static: if it comes back within session.timeout.ms, the coordinator hands it the same partitions and no rebalance happens. The price: when a static member truly dies, its partitions sit idle until the session timeout, so pair it with a session timeout that matches your restart time (for example 1-2 minutes in Kubernetes with a StatefulSet name as the instance id).

The new consumer rebalance protocol (KIP-848) in Kafka 4.0

Kafka 4.0 shipped a redesigned group protocol. What the 4.0 documentation says, and what we saw in the lab:

AspectClassic protocolNew protocol (group.protocol=consumer)
Status in 4.0Still the default on the client (group.protocol=classic, verified in the 4.0 consumer configuration reference)Generally available in 4.0; the broker enables it automatically (feature flag group.version) when the 4.0 upgrade is finalized. The client must opt in with group.protocol=consumer
Who computes the assignmentA client (the group leader member) runs the assignorThe broker; assignors configured with group.consumer.assignors, default uniform,range
Rebalance shapeJoin/Sync barrier; eager unless cooperative sticky is chosenFully incremental: no global synchronisation barrier, members move independently via heartbeats
Heartbeat and sessionClient settings heartbeat.interval.ms, session.timeout.msControlled by the broker: group.consumer.heartbeat.interval.ms (5 s) and group.consumer.session.timeout.ms (45 s). The client settings are no longer usable
Assignor selectionpartition.assignment.strategy on the clientgroup.remote.assignor (optional) names a server-side assignor; partition.assignment.strategy is not usable
Regex subscriptionEvaluated on the clientsubscribe(SubscriptionPattern), RE2J syntax, evaluated on the server
Known limits in 4.0-Client-side (custom) assignors are not supported; rack-aware assignment is not fully supported (docs list KAFKA-18327 and KAFKA-17747)

Migration is online: roll your consumers out with group.protocol=consumer. When the first such member joins, the group converts from classic to consumer and interoperates; this requires that the classic group uses an assignor that does not embed custom metadata. A group is converted back when the last new-protocol member leaves.

We confirmed the behaviour on a Kafka 4.0.0 broker with the 4.0.2 Java client. Two consumers using group.protocol=consumer produced ASSIGNMENT-STRATEGY uniform in --describe --state, and in the verbose member listing the CURRENT-EPOCH and TARGET-ASSIGNMENT columns are populated (they are empty - for a classic group). When the second consumer joined, the first one received only a revoke of the partitions that moved (revoked [orders-1, orders-2]) and kept reading orders-0: an incremental hand-over, with no stop-the-world step.

⚠ "We upgraded to Kafka 4.0, so we use the new protocol"
  • Upgrading the brokers does not switch your consumers. Until the application sets group.protocol=consumer, it keeps the classic protocol and its eager default (Range). Also check that nothing in your code or Spring configuration sets session.timeout.ms, heartbeat.interval.ms or partition.assignment.strategy or calls enforceRebalance(): the 4.0 docs list these as no longer usable with the new protocol.
09
Chapter Nine - The Payload

Serialization, Schemas and the Schema Registry

Kafka stores and moves bytes. Turning your objects into bytes and back is the job of the client's serializer and deserializer, and the format you choose becomes a long-term contract between teams, because a topic is read months later and by consumers you did not write.

FormatStrengthsWeaknesses
String / JSON (Jackson)Human readable, no tooling, easy to debugLarge, no enforced schema, field renames break consumers silently, numbers and dates are ambiguous
AvroCompact binary, schema evolution rules are first class, reader and writer schemas resolved at read timeNeeds the writer schema to read, i.e. a registry or embedded schemas
ProtobufCompact, strong tooling and code generation, field numbers give safe evolutionEvolution rules need care (renumbering is a break); schema distribution as for Avro
JSON SchemaKeeps JSON on the wire with validationLarger than binary formats; compatibility rules are subtle
Raw bytesZero overheadYou own the format and the versioning
The Serializer interface

A custom format is a class implementing org.apache.kafka.common.serialization.Serializer<T> (serialize(topic, data)) and Deserializer<T> (deserialize(topic, data)), configured with key.serializer / value.serializer on the producer and the deserializer pair on the consumer. Kafka ships String, Integer, Long, Double, UUID, ByteArray and a few more.

⚠ The poison pill
  • A record that the deserializer cannot decode makes poll() throw RecordDeserializationException, and **the next poll() fetches the same record again**: the consumer is stuck forever on one bad message. The exception carries topicPartition() and offset() (checked against the 4.0.2 client): catch it, log or dead-letter the raw bytes if you can, then seek(topicPartition, offset + 1) to move past it. Libraries such as Spring Kafka's ErrorHandlingDeserializer (page 04) wrap this pattern for you.
Schema registry flow: the record carries a schema id, not the schema
Producer (Avro serializer) Schema Registry subject: orders-value Consumer (Avro deserializer) 1. register schema 2. id = 42 Kafka record value: 0x00 id = 42 Avro binary payload 3. send 4. fetch 5. id -> schema The wire format used by the Confluent serializers: 1 magic byte (0), 4-byte schema id, then the serialized payload. Registry traffic happens once per schema (then cached), not once per record. Compatibility check at step 1: the registry compares the new schema with the registered ones compatible: version registered, producer proceeds. not compatible: registration is rejected (HTTP 409), the producer fails at startup or first send, and no bad data is written. This moves a schema break from "a consumer crashes in production" to "a build or deploy fails".
Why a schema registry

Without one, a producer team renames total to amount and every consumer breaks at runtime. A schema registry is a small service (the best-known one is the Confluent Schema Registry; Apicurio and AWS Glue Schema Registry are alternatives; it is not part of Apache Kafka) that stores schemas by subject, assigns each version a global id, and enforces compatibility rules before accepting a new version. The record carries only the id.

With the default subject naming (TopicNameStrategy) the value schema of topic orders is registered under the subject orders-value.

Compatibility modes

The mode answers "who may be on the old schema while the other side moves to the new one?". Names and rules below follow the Confluent Schema Registry documentation (checked 2026-10-31); the default mode is BACKWARD.

ModeGuaranteeAllowed changes (Avro)Upgrade first
BACKWARD (default)A consumer on the new schema can read data written with the previous schemaDelete fields; add fields with a defaultConsumers, then producers
FORWARDA consumer on the old schema can read data written with the new schemaAdd fields; delete fields that had a defaultProducers, then consumers
FULLBoth of the above, against the previous versionAdd or delete only fields with defaultsAny order
BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, FULL_TRANSITIVESame, but checked against all earlier versions, not just the lastAs aboveAs above
NONENo checkingAnythingCreate a new topic and migrate

Why BACKWARD is the default: it lets you rewind a consumer to the beginning of a topic and still read everything with the newest schema. That is the replay scenario of chapter 07. (The same documentation notes that for Protobuf the recommended mode is BACKWARD_TRANSITIVE, because adding message types is not forward compatible, and that Kafka Streams supports only BACKWARD.)

JSON - Avro schema version 1
{"type":"record","name":"Order","namespace":"shop", "fields":[ {"name":"orderId","type":"string"}, {"name":"total","type":"double"} ]}
JSON - version 2: adds an optional field with a default (BACKWARD compatible)
{"type":"record","name":"Order","namespace":"shop", "fields":[ {"name":"orderId","type":"string"}, {"name":"total","type":"double"}, {"name":"currency","type":"string","default":"EUR"} ]}

A consumer already upgraded to version 2 reading a version 1 record fills currency with the default EUR. Adding the field without a default would be rejected by the registry in BACKWARD mode, because the new reader would have no value for old data.

Properties - client configuration for the Confluent Avro serializer (not compiled)
# Confluent Avro serializer (artifact io.confluent:kafka-avro-serializer, from the Confluent # Maven repository, not Maven Central). Not compiled here. key.serializer=org.apache.kafka.common.serialization.StringSerializer value.serializer=io.confluent.kafka.serializers.KafkaAvroSerializer schema.registry.url=http://schema-registry:8081 # Production: register schemas from CI, not from running applications. auto.register.schemas=false use.latest.version=true
Make the build fail, not production
  • Register schemas from CI (a Maven or Gradle plugin, or the registry REST API), set auto.register.schemas=false on producers, and run the registry's compatibility check in the pull request. A breaking schema change then fails the build instead of a consumer.
10
Chapter Ten - Build It and Run It

Plain kafka-clients Java and a Local Lab

Frameworks hide the client behind annotations (page 04 covers Spring). Writing the loops once in plain kafka-clients shows exactly what the framework does for you, and makes every setting in this page concrete. All samples were compiled with JDK 21 against kafka-clients 4.0.2 from Maven Central, and the lab ran against the apache/kafka:4.0.0 image.

Setup
XML - the only dependency you need
<dependency> <groupId>org.apache.kafka</groupId> <artifactId>kafka-clients</artifactId> <version>4.0.2</version> </dependency> <!-- plus an SLF4J binding (e.g. slf4j-simple) to see the client logs -->

To compile without Maven, download the three jars and compile all samples of this chapter into one directory. The lab commands below assume $CP is set as shown.

Shell - fetch the jars and compile
$ M=https://repo1.maven.org/maven2 $ curl -sLO $M/org/apache/kafka/kafka-clients/4.0.2/kafka-clients-4.0.2.jar $ curl -sLO $M/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar $ curl -sLO $M/org/slf4j/slf4j-simple/1.7.36/slf4j-simple-1.7.36.jar $ mkdir -p out && javac -cp kafka-clients-4.0.2.jar -d out *.java $ CP=out:kafka-clients-4.0.2.jar:slf4j-api-1.7.36.jar:slf4j-simple-1.7.36.jar
The producer

OrderProducer states the durable settings explicitly (even though they are the defaults), sends five keyed records with a callback, and closes cleanly. Note what is missing: no flush() per record, no future.get(), no new producer per message.

Java - OrderProducer.java (compiled and run against Kafka 4.0.0)
import org.apache.kafka.clients.producer.*; import org.apache.kafka.common.serialization.StringSerializer; import java.util.Properties; public class OrderProducer { public static void main(String[] args) { Properties props = new Properties(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); // State the durable defaults explicitly so a reviewer can see the intent. props.put(ProducerConfig.ACKS_CONFIG, "all"); props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true"); props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, "120000"); props.put(ProducerConfig.LINGER_MS_CONFIG, "5"); // lz4/snappy/zstd need an extra jar on the classpath (kafka-clients does not bundle them). // props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4"); // KafkaProducer is thread-safe: create ONE per application and share it. try (KafkaProducer<String, String> producer = new KafkaProducer<>(props)) { for (int i = 0; i < 5; i++) { String key = "customer-" + (i % 2); ProducerRecord<String, String> record = new ProducerRecord<>("orders", key, "{\"orderId\":\"o-" + i + "\"}"); // send() is asynchronous: it returns after the record is in the accumulator. producer.send(record, (RecordMetadata md, Exception ex) -> { if (ex != null) { // Retries and delivery.timeout.ms are already exhausted here. System.err.println("FAILED " + record.key() + ": " + ex); } else { System.out.printf("acked %s -> %s-%d@%d%n", record.key(), md.topic(), md.partition(), md.offset()); } }); } producer.flush(); // only at the end (or shutdown), never per record } // close() also waits for in-flight batches } }
  • try-with-resources calls close(), which waits for in-flight batches, so records are not lost at exit. A bare System.exit() after send() does lose buffered records.
  • The callback runs on the Sender thread. Do not block there; hand heavy work to another executor.
  • Compression is commented out on purpose: lz4, snappy and zstd need their own jar (chapter 02).
The consumer loop: manual commit after processing

OrderConsumer is the loop most applications should start from: auto commit off, process the batch, then commitSync(); wakeup() from a shutdown hook to exit poll(); a rebalance listener that commits before partitions are taken away.

Java - OrderConsumer.java (compiled and run)
import org.apache.kafka.clients.consumer.*; import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.errors.WakeupException; import org.apache.kafka.common.serialization.StringDeserializer; import java.time.Duration; import java.util.*; public class OrderConsumer { public static void main(String[] args) { Properties props = new Properties(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); props.put(ConsumerConfig.GROUP_ID_CONFIG, "billing"); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); // we commit after processing props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); // only used when no committed offset props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "100"); KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props); Thread main = Thread.currentThread(); // wakeup() is the ONE KafkaConsumer method that is safe to call from another thread. Runtime.getRuntime().addShutdownHook(new Thread(() -> { consumer.wakeup(); try { main.join(); } catch (InterruptedException ignored) { } })); consumer.subscribe(List.of("orders"), new ConsumerRebalanceListener() { @Override public void onPartitionsRevoked(Collection<TopicPartition> partitions) { // Last chance to commit work for partitions this member is about to lose. consumer.commitSync(); System.out.println("revoked " + partitions); } @Override public void onPartitionsAssigned(Collection<TopicPartition> partitions) { System.out.println("assigned " + partitions); } }); try { while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500)); for (ConsumerRecord<String, String> r : records) { process(r); // must be idempotent: a crash before the commit replays it } if (!records.isEmpty()) { consumer.commitSync(); // commits the position after the last record returned by poll() } } } catch (WakeupException e) { // expected on shutdown } finally { try { consumer.commitSync(); } finally { consumer.close(); } } } static void process(ConsumerRecord<String, String> r) { System.out.printf("%s-%d@%d key=%s value=%s%n", r.topic(), r.partition(), r.offset(), r.key(), r.value()); } }
  • commitSync() with no arguments commits the position of every assigned partition, that is, the offset after the last record poll() returned. If process() throws midway, the loop dies before the commit, and the batch is replayed: at-least-once.
  • onPartitionsRevoked commits the work done for partitions about to leave. Without it, a rebalance replays up to one batch for those partitions.
  • wakeup() is the only KafkaConsumer method that may be called from another thread. It makes the blocked poll() throw WakeupException, which we treat as a normal shutdown.
  • auto.offset.reset=earliest applies only to a group with no committed offsets.
Consume-transform-produce with a transaction

TransformRelay is the loop of chapter 05 in code. The consumer has auto commit off and reads read_committed; offsets travel with the transaction through sendOffsetsToTransaction(offsets, consumer.groupMetadata()); the committed offset for each partition is the next offset to read, record.offset() + 1.

Java - TransformRelay.java (compiled; run in the lab below)
import org.apache.kafka.clients.consumer.*; import org.apache.kafka.clients.producer.*; import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.KafkaException; import org.apache.kafka.common.errors.AuthorizationException; import org.apache.kafka.common.errors.OutOfOrderSequenceException; import org.apache.kafka.common.errors.ProducerFencedException; import org.apache.kafka.common.serialization.*; import java.time.Duration; import java.util.*; /** Consume-transform-produce with exactly-once semantics inside Kafka. */ public class TransformRelay { public static void main(String[] args) { Properties cp = new Properties(); cp.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); cp.put(ConsumerConfig.GROUP_ID_CONFIG, "relay"); cp.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); cp.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); cp.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); // offsets go through the transaction cp.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed"); // skip aborted data cp.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); Properties pp = new Properties(); pp.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); pp.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); pp.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); // Stable per instance (e.g. relay-0, relay-1): the broker uses it to fence zombies. pp.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "relay-0"); try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(cp); KafkaProducer<String, String> producer = new KafkaProducer<>(pp)) { producer.initTransactions(); // registers the id, bumps the epoch, fences older instances consumer.subscribe(List.of("orders")); while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500)); if (records.isEmpty()) continue; try { producer.beginTransaction(); Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>(); for (ConsumerRecord<String, String> r : records) { producer.send(new ProducerRecord<>("orders-enriched", r.key(), r.value().toUpperCase())); offsets.put(new TopicPartition(r.topic(), r.partition()), new OffsetAndMetadata(r.offset() + 1)); // the NEXT offset to read } // Output records and consumed offsets commit (or abort) as one unit. producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata()); producer.commitTransaction(); } catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) { throw e; // fatal: a newer instance owns this id (or auth/sequence failure). Close, do not abort. } catch (KafkaException e) { producer.abortTransaction(); // then seek back to the committed offsets and retry rewind(consumer); } } } } static void rewind(KafkaConsumer<String, String> consumer) { Map<TopicPartition, OffsetAndMetadata> committed = consumer.committed(consumer.assignment()); for (TopicPartition tp : consumer.assignment()) { OffsetAndMetadata om = committed.get(tp); if (om != null) consumer.seek(tp, om.offset()); else consumer.seekToBeginning(List.of(tp)); } } }
Replay from a timestamp
Java - SeekDemo.java (compiled)
import org.apache.kafka.clients.consumer.*; import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.serialization.StringDeserializer; import java.time.*; import java.util.*; /** Replay from a timestamp using assign() + offsetsForTimes() + seek(). */ public class SeekDemo { public static void main(String[] args) { Properties props = new Properties(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); props.put(ConsumerConfig.GROUP_ID_CONFIG, "replay-tool"); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); long since = Instant.now().minus(Duration.ofHours(1)).toEpochMilli(); try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) { List<TopicPartition> parts = consumer.partitionsFor("orders").stream() .map(i -> new TopicPartition(i.topic(), i.partition())).toList(); consumer.assign(parts); // manual assignment: no group rebalancing Map<TopicPartition, Long> query = new HashMap<>(); parts.forEach(tp -> query.put(tp, since)); Map<TopicPartition, OffsetAndTimestamp> found = consumer.offsetsForTimes(query); Map<TopicPartition, Long> ends = consumer.endOffsets(parts); for (TopicPartition tp : parts) { OffsetAndTimestamp ot = found.get(tp); // null = no record at or after that time if (ot != null) consumer.seek(tp, ot.offset()); else consumer.seek(tp, ends.get(tp)); } ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(2)); records.forEach(r -> System.out.printf("%s-%d@%d%n", r.topic(), r.partition(), r.offset())); } } }

assign() instead of subscribe() means this tool is not a group member: no rebalances, and it will not disturb the real group. If offsetsForTimes returns null for a partition (no record at or after the time), the example seeks to the end.

An idempotent handler (page 01 made concrete)

The handler inserts the dedup row and applies the change in one database transaction. It uses only java.sql, so it compiles with no driver; run it against any database that supports ON CONFLICT DO NOTHING (PostgreSQL) or adapt the insert (INSERT IGNORE on MySQL).

Java - IdempotentHandler.java (compiled, not run: needs a database)
import org.apache.kafka.clients.consumer.ConsumerRecord; import java.sql.*; import javax.sql.DataSource; /** Dedup + business write in ONE database transaction (the idempotent-consumer pattern from page 01). */ public class IdempotentHandler { private final DataSource ds; public IdempotentHandler(DataSource ds) { this.ds = ds; } /** Returns true if the record was applied, false if it was a duplicate. */ public boolean handle(ConsumerRecord<String, String> r) throws SQLException { // Best key: an event id the PRODUCER put in the payload or a header. Fallback: the record's coordinates. String eventId = r.topic() + "-" + r.partition() + "-" + r.offset(); try (Connection c = ds.getConnection()) { c.setAutoCommit(false); try { try (PreparedStatement ins = c.prepareStatement( "INSERT INTO processed_events(event_id) VALUES (?) ON CONFLICT DO NOTHING")) { ins.setString(1, eventId); if (ins.executeUpdate() == 0) { // row already there: we have seen this event c.rollback(); return false; } } try (PreparedStatement pay = c.prepareStatement( "UPDATE accounts SET balance = balance - ? WHERE id = ?")) { pay.setBigDecimal(1, new java.math.BigDecimal("10.00")); pay.setString(2, r.key()); pay.executeUpdate(); } c.commit(); // dedup row and business change land together return true; } catch (SQLException | RuntimeException e) { c.rollback(); throw e; } } } }

Call handle(record) in the loop of OrderConsumer, and commitSync() only after it returned. A crash between the database commit and the Kafka commit replays the record, and the dedup row makes the replay harmless.

Lab: groups, lag and offset resets with apache/kafka:4.0.0

Everything below was run on a single-node KRaft broker from the official image (the image's defaults advertise localhost:9092, so clients on your machine connect directly). Output blocks are real but abridged: the HOST and CLIENT-ID columns and some whitespace were trimmed, and long consumer ids shortened. Run docker rm -f kafka first if the container from page 02 is still there.

Shell - broker and topics
$ docker run -d --name kafka -p 9092:9092 apache/kafka:4.0.0 $ docker exec kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders --partitions 3 --replication-factor 1 Created topic orders. $ docker exec kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders-enriched --partitions 3 --replication-factor 1 Created topic orders-enriched.
Shell - produce five records with the Java producer
$ java -cp $CP OrderProducer acked customer-1 -> orders-0@0 acked customer-0 -> orders-1@0 acked customer-0 -> orders-1@1 acked customer-0 -> orders-1@2 acked customer-1 -> orders-0@1
Shell - consume them with the Java consumer
$ java -cp $CP OrderConsumer # let it run a few seconds, then Ctrl+C assigned [orders-0, orders-1, orders-2] orders-0@0 key=customer-1 value={"orderId":"o-1"} orders-0@1 key=customer-1 value={"orderId":"o-3"} orders-1@0 key=customer-0 value={"orderId":"o-0"} orders-1@1 key=customer-0 value={"orderId":"o-2"} orders-1@2 key=customer-0 value={"orderId":"o-4"} revoked [orders-0, orders-1, orders-2]

Keys customer-0 and customer-1 hash to different partitions, so offsets are per partition, exactly as in page 02. Now look at what Kafka stored for the group:

Shell - the group after the consumer stopped
$ docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group billing Consumer group 'billing' has no active members. GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID HOST CLIENT-ID billing orders 0 2 2 0 - - - billing orders 1 3 3 0 - - - billing orders 2 0 0 0 - - -

CURRENT-OFFSET is the committed offset (the next record to read); it equals LOG-END-OFFSET, so lag is zero. The group has no members, yet its offsets are kept (up to offsets.retention.minutes). Next, produce 30 more records while no consumer is running, to create lag:

Shell - lag builds up while the consumer is down
$ seq 1 30 | sed 's/^/k/;s/$/:{"n":1}/' | docker exec -i kafka /opt/kafka/bin/kafka-console-producer.sh \ --bootstrap-server localhost:9092 --topic orders --property parse.key=true --property key.separator=: $ docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group billing Consumer group 'billing' has no active members. GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID HOST CLIENT-ID billing orders 0 2 11 9 - - - billing orders 1 3 15 12 - - - billing orders 2 0 9 9 - - -

Lag per partition is LOG-END-OFFSET - CURRENT-OFFSET: 9, 12 and 9 records waiting. Start two copies of OrderConsumer (two terminals) and inspect the live group. Then try to reset offsets while they run:

Shell - members, assignment strategy, and the "group must be inactive" error
# start two copies of OrderConsumer in two terminals, then: $ docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group billing --state GROUP COORDINATOR (ID) ASSIGNMENT-STRATEGY STATE #MEMBERS billing localhost:9092 (1) range Stable 2 $ docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group billing --members --verbose GROUP CONSUMER-ID CLIENT-ID #PARTITIONS CURRENT-EPOCH CURRENT-ASSIGNMENT TARGET-EPOCH TARGET-ASSIGNMENT billing consumer-billing-1-5edcc... consumer-billing-1 2 - orders:0,1 - - billing consumer-billing-1-7390c... consumer-billing-1 1 - orders:2 - - $ docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group billing --topic orders --reset-offsets --to-earliest --execute Error: Assignments can only be reset if the group 'billing' is inactive, but the current state is Stable.

Three things to notice. The group runs the range assignor (the 4.0 default list starts with RangeAssignor). One member got two partitions and the other one, as 3 partitions cannot split evenly. And Kafka refuses to reset offsets for a group with live members. Stop the consumers, then preview with --dry-run (nothing changes), apply with --execute, and verify:

Shell - reset offsets for a stopped group
# stop both consumers (Ctrl+C), then preview, apply, and verify $ docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group billing --topic orders --reset-offsets --to-earliest --dry-run GROUP TOPIC PARTITION NEW-OFFSET billing orders 0 0 billing orders 1 0 billing orders 2 0 $ docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group billing --topic orders:0 --reset-offsets --to-offset 4 --execute GROUP TOPIC PARTITION NEW-OFFSET billing orders 0 4 $ docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group billing GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG billing orders 0 4 11 7 billing orders 1 15 15 0 billing orders 2 9 9 0

Relative resets are clamped to the valid range, which is what the broker told us when we tried --shift-by -5 on a group positioned near the start of the log:

Shell - shift-by below the start is clamped to the earliest offset
$ ... --group billing --topic orders --reset-offsets --shift-by -5 --execute WARN New offset (-3) is lower than earliest offset for topic partition orders-0. Value will be set to 0 WARN New offset (-2) is lower than earliest offset for topic partition orders-1. Value will be set to 0 WARN New offset (-5) is lower than earliest offset for topic partition orders-2. Value will be set to 0

Other reset options follow the same pattern: --to-latest, --to-datetime 2026-10-31T09:00:00.000, --by-duration PT1H, --shift-by N, --from-file plan.csv. Always run --dry-run first.

To see the new rebalance protocol, run the same consumer with one extra line, props.put(ConsumerConfig.GROUP_PROTOCOL_CONFIG, "consumer"), and a different group.id (here billing848), twice:

Shell - a group on the new protocol (KIP-848), two members
$ ... --describe --group billing848 --state GROUP COORDINATOR (ID) ASSIGNMENT-STRATEGY STATE #MEMBERS billing848 localhost:9092 (1) uniform Stable 2 $ ... --describe --group billing848 --members --verbose GROUP CONSUMER-ID #PARTITIONS CURRENT-EPOCH CURRENT-ASSIGNMENT TARGET-EPOCH TARGET-ASSIGNMENT billing848 DTKe4qxtSl-rlvCVz7m5aw 2 2 orders:1,2 2 orders:1,2 billing848 WjKoayKcQNS9Wd4l_gVdVQ 1 2 orders:0 2 orders:0

The strategy column reads uniform and the epoch columns are filled in. Start the first consumer alone, then the second: the first logs a revoke of only the two partitions that moved and keeps reading the third. A classic group shows empty (-) epochs and, with Range, would have revoked all three.

Finally, the transactional relay of chapter 05. It reads orders (35 records) and writes the transformed values to orders-enriched:

Shell - run the relay, inspect the transaction and read the committed output
$ java -cp $CP TransformRelay # wait ~30 s on first start (the transaction topic is created), then Ctrl+C $ docker exec kafka /opt/kafka/bin/kafka-transactions.sh --bootstrap-server localhost:9092 list TransactionalId Coordinator ProducerId TransactionState relay-0 1 4 CompleteCommit $ docker exec kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic orders-enriched \ --partition 0 --from-beginning --isolation-level read_committed --timeout-ms 4000 --property print.key=true customer-1 {"ORDERID":"O-1"} customer-1 {"ORDERID":"O-3"} k2 {"N":1} ...

The first start can take up to half a minute while the broker creates its internal __transaction_state topic. Restart the relay and nothing is processed twice: the committed input offsets moved inside the transaction.

Clean up
  • docker rm -f kafka removes the container, the topics and the group state.
11
Chapter Eleven - Failure Modes

Common Mistakes and How They Show Up

Nearly every client incident falls into a small number of patterns. Each one below states the symptom you see, the cause, and the fix.

SymptomMost likely causeFirst check
Duplicate records downstream after a deploy or crashAt-least-once consumer without idempotence; producer restarted and re-sentIs the consumer idempotent (chapter 07)? Do events have a stable id?
Records missing after a consumer crashOffset committed before processing, or auto commit with asynchronous processingWhere is the commit relative to the work?
Consumer silently skips history after a long outageOffsets expired, auto.offset.reset=latestoffsets.retention.minutes, the reset policy, lag alerts
Constant rebalancing, lag grows, throughput near zeroProcessing slower than max.poll.interval.ms; unstable pods; session timeout too lowTime per batch versus max.poll.interval.ms; max.poll.records
CommitFailedException or "member no longer part of the group" on commitThe member was evicted before it committed (poll interval exceeded)Same as above; also GC pauses and CPU throttling
ConcurrentModificationException: KafkaConsumer is not safe for multi-threaded accessOne consumer used by several threadsWho calls poll(), commit*() or seek()?
One partition lags, the rest are fineHot key, or a poison record the consumer retries foreverPer-partition lag; the offset the consumer is stuck at
send() hangs for 60 s then TimeoutExceptionUnreachable cluster, wrong topic name, or buffer fullmax.block.ms, buffer metrics, broker health
Out-of-order records within a keyIdempotence off with max.in.flight above 1, or key not set, or partitions addedProducer config as printed in the startup log
ProducerFencedExceptionTwo instances with the same transactional.id, or a zombie after a pauseUnique ids per instance; treat as fatal
Mistake 1: committing before processing
Java - the same loop, wrong and right
// WRONG: at-most-once. A crash inside process() loses the whole batch. ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500)); consumer.commitSync(); // committed BEFORE the work is done for (ConsumerRecord<String, String> r : records) process(r); // RIGHT: at-least-once. A crash replays the batch, so process() must be idempotent. ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500)); for (ConsumerRecord<String, String> r : records) process(r); consumer.commitSync(); // committed AFTER the work is done

Committing first is attractive because it avoids replays, but it trades them for loss, which is almost always worse. If you truly need at-most-once (a metrics sampler, say), do it deliberately and document it.

Mistake 2: slow processing turns into a rebalance storm
The batch takes longer than max.poll.interval.ms
▶
The client leaves the group; its partitions are reassigned
Another member receives them and hits the same slow downstream
▶
It also exceeds the interval, and the partitions move again
Every move is a rebalance (eager: stop the world)
▶
No consumer makes progress; each restarts from the last commit, so the same slow batch is retried over and over; lag climbs

Fixes, from cheapest: lower **max.poll.records so a batch fits the interval comfortably; raise max.poll.interval.ms only to cover legitimate variation; speed up or time-limit the downstream call; use cooperative or the KIP-848 protocol to make each rebalance cheap; add static membership so rolling deploys do not churn; and, if the work is genuinely long, decouple** it: poll in one thread, hand records to a worker pool, call consumer.pause(partitions) while the pool is busy (a paused consumer keeps polling and heartbeating but returns no records) and commit only offsets that the workers have finished, in order.

Mistake 3: sharing one KafkaConsumer across threads

KafkaConsumer is deliberately not thread-safe. When two threads enter it at the same time, the second gets this exception (reproduced in the lab with two threads polling one consumer):

Text - the failure (real output)
java.util.ConcurrentModificationException: KafkaConsumer is not safe for multi-threaded access. currentThread(name: worker-2, id: 22) otherThread(id: 21)

The rule is one consumer per thread, all with the same group.id; Kafka splits the partitions among them. wakeup() is the single exception. ConsumerPerThread shows the pattern, and the thread count should not exceed the partition count.

Java - ConsumerPerThread.java (compiled and run)
import org.apache.kafka.clients.consumer.*; import org.apache.kafka.common.errors.WakeupException; import org.apache.kafka.common.serialization.StringDeserializer; import java.time.Duration; import java.util.*; import java.util.concurrent.*; /** KafkaConsumer is NOT thread-safe: give every thread its own instance, same group.id. */ public class ConsumerPerThread { static class Worker implements Runnable { private final KafkaConsumer<String, String> consumer; Worker(Properties base) { this.consumer = new KafkaConsumer<>(base); } // built here, but used by exactly one thread @Override public void run() { try { consumer.subscribe(List.of("orders")); while (true) { for (ConsumerRecord<String, String> r : consumer.poll(Duration.ofMillis(500))) { System.out.println(Thread.currentThread().getName() + " " + r.partition() + "@" + r.offset()); } consumer.commitSync(); } } catch (WakeupException e) { // shutdown requested } finally { consumer.close(); } } void stop() { consumer.wakeup(); } // the only thread-safe method } public static void main(String[] args) throws Exception { Properties p = new Properties(); p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); p.put(ConsumerConfig.GROUP_ID_CONFIG, "billing"); p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); int threads = 3; // no point exceeding the partition count ExecutorService pool = Executors.newFixedThreadPool(threads); List<Worker> workers = new ArrayList<>(); for (int i = 0; i < threads; i++) { Worker w = new Worker(p); workers.add(w); pool.submit(w); } Runtime.getRuntime().addShutdownHook(new Thread(() -> { workers.forEach(Worker::stop); pool.shutdown(); })); } }

The producer is the opposite: KafkaProducer is thread-safe and should be shared.

Producer-side mistakes
  • **A new KafkaProducer per message or request.** Each instance opens connections, fetches metadata and has its own tiny batches. Create one and reuse it (Spring and your DI container make this a singleton bean).
  • **send(...).get() for every record.** It turns the pipeline into one request at a time. Use the callback, and get() only where you truly need synchronous confirmation.
  • **flush() after every send().** Same effect: no batching.
  • Ignoring the callback. A silent TimeoutException after 120 s is a lost record nobody noticed.
  • Copying an old configuration. max.in.flight.requests.per.connection=1, retries=3, acks=1 appear in years-old tutorials. Today's defaults (acks=all, idempotence on, 5 in flight, retries bounded by delivery.timeout.ms) are better and safer.
  • No key where order matters, or a key with very few distinct values (hot partition). See page 02, chapter 07.
  • **Exiting without close().** Buffered records never leave the process.
Consumer-side mistakes
  • Auto commit with asynchronous processing. The next poll() commits offsets of records another thread has not finished. A crash loses them. Disable auto commit as soon as processing leaves the polling thread.
  • Retrying a poison record in place with an infinite loop. The partition stops. Bound the retries, then send the record to a dead-letter topic and move on (page 07 covers retry and DLQ designs).
  • **A heavy onPartitionsAssigned** (loading large state) that blocks the poll thread past the poll interval.
  • Committing offsets for partitions you no longer own after a rebalance; the commit fails, and the work is duplicated by the new owner. Commit in onPartitionsRevoked.
  • Treating a group id as a name for "the topic" and reusing it across unrelated applications; they will split the partitions between them and each sees only part of the data.
12
Chapter Twelve - Interview

Questions You Should Be Able to Answer

Crisp answers for client-side Kafka questions. Defaults are for Kafka 4.0.

  • Q: What happens when you call producer.send()?
    The record is run through interceptors, serialized, assigned a partition, and appended to the in-memory batch for that partition in the RecordAccumulator; send() returns a Future immediately. A background Sender thread drains ready batches (full, linger.ms elapsed, or flush) and sends one request per broker. The callback runs when the broker responds or the delivery deadline passes.
  • Q: When does send() block?
    While waiting for topic metadata and while waiting for free buffer memory, bounded together by max.block.ms (60 s), after which it throws TimeoutException. It does not block for the network round trip.
  • Q: Explain acks and how it relates to min.insync.replicas.
    acks=0 fire and forget, 1 leader only, all every in-sync replica (default). acks=all is only as strong as min.insync.replicas: with the value 1 the leader alone satisfies it. Durable setup: RF 3, min.insync.replicas=2, acks=all.
  • Q: What is delivery.timeout.ms and how does it relate to retries?
    It is the overall deadline (default 120 s) from send() returning to success or failure, covering batching, request attempts and backoff. retries defaults to Integer.MAX_VALUE, so the delivery timeout is what actually bounds retrying. It must be at least request.timeout.ms + linger.ms.
  • Q: How does the idempotent producer prevent duplicates?
    The broker gives the producer a producer id and epoch; the producer numbers records per partition. The leader remembers the last sequences per producer and partition (metadata for the last five batches): the next sequence is appended, an already-seen one is acknowledged but not appended, a gap is rejected with OutOfOrderSequenceException. It is on by default since Kafka 3.0 and requires acks=all.
  • Q: Does idempotence guarantee no duplicates if my application restarts?
    No. A new producer instance gets a new producer id and sequence numbers restart, so the broker cannot recognise a re-sent business event. You need a stable transactional.id (for transactions) or an application-level event id deduplicated by the consumer.
  • Q: Can retries reorder messages? How do you prevent it?
    Without idempotence and with more than one request in flight, yes: a failed batch retried after its successor lands later. With idempotence on (the default) order is preserved with up to 5 in-flight requests, because the broker rejects out-of-sequence batches. The old advice of max.in.flight=1 is no longer needed.
  • Q: How do Kafka transactions work, and what is transactional.id for?
    The id is a stable identity registered with the transaction coordinator, which maps it to a producer id and epoch and fences older instances. beginTransaction, sends, and sendOffsetsToTransaction accumulate output and input offsets; commitTransaction makes the coordinator log the decision and write commit markers to every partition involved. read_committed consumers read only up to the last stable offset and skip aborted data.
  • Q: What does exactly-once in Kafka NOT guarantee?
    Side effects outside Kafka (databases, HTTP calls, email), consumers on read_uncommitted, cross-cluster copies, and duplicates produced upstream. It covers read-process-write cycles whose input and output are Kafka topics. For everything else use idempotent consumers.
  • Q: What is a zombie producer and how is it fenced?
    An old instance that stalled and resumes after a replacement started with the same transactional.id. Each initTransactions() raises the epoch; the broker rejects writes carrying an older epoch with ProducerFencedException, which the application must treat as fatal.
  • Q: Explain consumer groups and how partitions are assigned.
    Consumers sharing a group.id split the topic's partitions: each partition has exactly one owner in the group, so parallelism is capped by the partition count. Different groups are independent and each tracks its own offsets. On the classic protocol a group member runs the assignor (default list Range then CooperativeSticky); on the new protocol the broker does.
  • Q: Where are committed offsets stored and what exactly is stored?
    In the compacted internal topic __consumer_offsets, keyed by group, topic and partition. The value is the next offset to read (last processed plus one). Offsets of an empty group expire after offsets.retention.minutes (7 days).
  • Q: Auto commit vs manual commit: consequences?
    Auto commit (default every 5 s, inside poll()) is at-least-once only if every record of the previous poll is processed before the next poll; with asynchronous processing it can lose records. Manual commit after processing gives at-least-once; commit before processing gives at-most-once. Exactly-once needs a transaction or an idempotent consumer.
  • Q: What does auto.offset.reset do?
    It applies only when the group has no valid committed offset (new group, expired offsets, offset out of range): latest (default), earliest, none, or by_duration. It is not a "start from the beginning every time" switch.
  • Q: How would you replay events from yesterday at 09:00?
    Stop the group and run kafka-consumer-groups.sh --reset-offsets --to-datetime ... --execute, or in code use offsetsForTimes and seek. The consumers must be idempotent because a replay re-delivers records.
  • Q: What is consumer lag and how do you monitor it?
    Log end offset minus committed offset per partition. Read it with kafka-consumer-groups.sh --describe, the consumer metric records-lag-max, or an exporter; alert on sustained growth and on a single partition lagging. It is measured in records, not time.
  • Q: What triggers a rebalance and how do you reduce its impact?
    Members joining or leaving, a missed session (session.timeout.ms, 45 s), a missed poll (max.poll.interval.ms, 5 min), or subscription and metadata changes. Reduce impact with cooperative assignors, static membership (group.instance.id), smaller max.poll.records, and the KIP-848 protocol.
  • Q: Eager vs cooperative rebalancing?
    Eager revokes every partition from every member before reassigning (stop the world). Cooperative revokes only the partitions that must move in a first round and assigns them in a second, so the rest keep running. The Kafka 4.0 default assignor list starts with Range, which is eager.
  • Q: What is new in the consumer protocol in Kafka 4.0?
    KIP-848, the next-generation rebalance protocol, is generally available: the broker enables it automatically, but consumers opt in with group.protocol=consumer (default is classic). Assignment is computed on the broker (assignors uniform and range by default), heartbeat and session timeouts are broker-side, the rebalance is fully incremental, and client-side assignors are not supported in 4.0.
  • Q: What is static membership?
    Setting a stable group.instance.id per consumer instance. A restart within the session timeout reuses the same assignment without any rebalance, at the cost of delayed failover when an instance really dies.
  • Q: Why is KafkaConsumer not thread-safe and how do you scale processing?
    It keeps unsynchronised state (position, group membership, fetch buffers) and enforces single-threaded access, throwing ConcurrentModificationException. Scale with one consumer per thread (same group), or poll in one thread and hand records to workers with pause()/resume() and ordered offset commits. wakeup() is the only cross-thread method.
  • Q: Schema registry compatibility: BACKWARD vs FORWARD?
    BACKWARD (default): consumers on the new schema can read old data; deleting fields and adding fields with defaults is allowed; upgrade consumers first. FORWARD: consumers on the old schema can read new data; upgrade producers first. FULL is both; the TRANSITIVE variants check all history. BACKWARD is the default because it allows rewinding consumers to the start of the topic.
  • Q: A consumer is stuck on one record and lag grows on one partition. What happened?
    Probably a poison record: deserialization fails (RecordDeserializationException, next poll refetches it) or processing throws and the loop retries forever. Catch it, dead-letter the record, seek past offset + 1, and add bounded retries.
Scenario questions
ScenarioOutline of a good answer
"Payments consumer charged a customer twice after a deploy."At-least-once replay after a rebalance or crash. Make the handler idempotent: dedup table keyed by payment id in the same DB transaction as the charge record; call the payment provider with an idempotency key; commit the offset after the DB commit. Reduce replays with onPartitionsRevoked commits and cooperative rebalancing.
"Order events for one customer arrive out of order."Check the key (all events of a customer keyed by customer id), the producer config (idempotence on, in-flight at most 5), partition count changes, and multiple producers writing the same key. Ordering exists only inside a partition.
"Lag is growing and consumers keep rebalancing."Compare batch time with max.poll.interval.ms; lower max.poll.records; find the slow dependency; check pod restarts and CPU throttling (missed heartbeats); consider static membership, cooperative or KIP-848; scale up only if the partition count allows.
"Design exactly-once from topic A to topic B and a database."Kafka to Kafka: transaction with sendOffsetsToTransaction, read_committed downstream. The database cannot join the Kafka transaction, so write with an idempotent upsert or dedup table keyed by event id, or use the transactional outbox (page 07).
"A new service needs the last 30 days of events."New consumer group with auto.offset.reset=earliest or by_duration:P30D (retention must cover it), idempotent processing, backfill throttled by max.poll.records, and a compatible schema (BACKWARD mode makes old records readable).
13
Chapter Thirteen - Summary

Cheat Sheet and Recap

Cheat sheet
SettingDefault (Kafka 4.0)Remember
acksallDurable only with min.insync.replicas=2 on RF 3
enable.idempotencetrueNeeds acks=all, retries above 0, in-flight 5 or less; covers one producer session
retries / delivery.timeout.ms2147483647 / 120000The delivery timeout is the real limit; at least request.timeout.ms + linger.ms
request.timeout.ms30000Per attempt
linger.ms / batch.size5 / 16384linger changed from 0 to 5 in 4.0
buffer.memory / max.block.ms33554432 / 60000send() blocks on metadata or memory, then TimeoutException
max.in.flight.requests.per.connection5Order kept up to 5 with idempotence
compression.typenonelz4, snappy, zstd need extra jars
transactional.id / transaction.timeout.msnull / 60000Unique per instance; fenced on reuse; broker cap 15 min
enable.auto.commit / auto.commit.interval.mstrue / 5000Commits at the next poll()
auto.offset.resetlatestOnly without a valid committed offset
isolation.levelread_uncommittedUse read_committed with transactional producers
max.poll.records / max.poll.interval.ms500 / 300000A batch must fit the interval
session.timeout.ms / heartbeat.interval.ms45000 / 3000Classic protocol only
partition.assignment.strategyRange, CooperativeStickyRange wins by default: eager
group.protocolclassicconsumer opts in to KIP-848 (GA in 4.0)
group.instance.idnullSet for static membership
offsets.retention.minutes (broker)10080Offsets of an empty group expire after 7 days
∑ Kafka clients - what to remember
  • **send() only appends to a batch.** A Sender thread ships batches; the callback reports the real outcome, so always handle it.
  • Reliability is a stack: acks=all + min.insync.replicas=2 for durability, idempotence for safe retries, delivery.timeout.ms as the deadline, a stable transactional.id for atomic read-process-write.
  • Idempotence is per producer session. After a restart you need transactions or consumer-side deduplication.
  • Exactly-once means Kafka to Kafka. Everything with outside side effects needs an idempotent consumer.
  • A consumer group splits partitions; parallelism cannot exceed the partition count; different groups are independent.
  • Committed offset = next offset to read. Commit after processing for at-least-once; make processing idempotent.
  • Rebalances are triggered by three clocks: session timeout, poll interval, and membership changes. Keep batches short, use cooperative or KIP-848, and consider static membership.
  • KafkaConsumer is single-threaded; KafkaProducer is thread-safe and should be shared.
  • Schemas are contracts. Use a registry, BACKWARD compatibility by default, and fail builds, not production.
Version note

Checked on 2026-10-31 against the Apache Kafka 4.0 producer and consumer configuration references (all defaults in the tables), the 4.0 consumer rebalance protocol and upgrade documents (KIP-848 status, linger.ms change, removal of the string overload of sendOffsetsToTransaction, KIP-890 epoch bump), the 4.0 broker configuration reference (offsets.retention.minutes, group.consumer.*, transaction.max.timeout.ms), and the Confluent Schema Registry compatibility documentation. All Java samples were compiled with JDK 21 against kafka-clients 4.0.2, and the lab commands were run against the apache/kafka:4.0.0 image; the Confluent Avro configuration and the database handler were not run end to end. Newer releases (Maven Central lists kafka-clients up to 4.4.0 at the time of writing) may change defaults and client behaviour; re-check the configuration reference for your version.

Next in this section