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.
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.
| Stage | Runs on | What it does | Can fail with |
|---|---|---|---|
| Interceptors (optional) | Caller thread | ProducerInterceptor.onSend() can modify or observe the record | Exceptions are logged and ignored |
| Metadata wait | Caller thread | Needs the topic's partition count and the leaders. Fetched once, then cached and refreshed on errors | TimeoutException: topic not present in metadata after max.block.ms |
| Serializer | Caller thread | key.serializer and value.serializer turn objects into byte[] | SerializationException |
| Partitioner | Caller thread | Keyed: murmur2(key) % partitions. Unkeyed: sticky batching (chapter 02) | Invalid explicit partition |
| Size checks and append | Caller thread | Rejects a record larger than max.request.size (1 MiB) or buffer.memory; takes memory from the pool; appends to the batch for that partition | RecordTooLargeException; TimeoutException when memory stays full for max.block.ms |
| Drain and send | Sender thread | Collects ready batches, groups them per broker into one request, tracks in-flight requests, retries retriable errors | Reported through the callback or Future |
| Callback | Sender thread | Runs when the broker answers or the delivery deadline passes | Anything you throw is swallowed after logging, so keep it short |
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.
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.
- 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 metricsbuffer-available-bytesandbufferpool-wait-time, and at broker request latency, before touching timeouts. Raisingmax.block.msonly makes the application hang longer.
- A
ProducerRecordhas a topic, an optional partition, an optional key, a value, optional headers, and an optional timestamp (default: the time ofsend(),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.
KafkaProduceris 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.
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).
| Setting | Default (Kafka 4.0) | What it controls | Tuning note |
|---|---|---|---|
batch.size | 16384 (16 KiB) | Maximum bytes of one batch (per partition). Memory is allocated in this unit | It 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.ms | 5 | How long to wait for more records before sending a non-full batch | Changed 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.type | none | Compression of the whole batch: gzip, snappy, lz4, zstd | Larger batches compress better. The broker stores the batch as received, and consumers decompress |
buffer.memory | 33554432 (32 MiB) | Total memory for unsent batches across all partitions | Size it above batch.size x active partitions or batches will wait on each other |
max.request.size | 1048576 (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.ms | 60000 | How long send() and a few other calls may block | See chapter 01 |
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.
| Case | What the producer does | Why |
|---|---|---|
| Record has a key | murmur2(key bytes) modulo the partition count | Same key, same partition: per-key order and co-location |
| No key | Sticky: fill one partition's batch, then switch. With partitioner.adaptive.partitioning.enable (default true) brokers that are slower get fewer records | Fewer, fuller batches than round-robin per record |
Explicit partition on the record | Used as given | You own the balance |
partitioner.ignore.keys=true (default false) | Keyed records are treated as unkeyed | Rarely wanted; breaks per-key ordering |
- 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).
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.
- Leave
batch.sizeandbuffer.memoryalone until metrics tell you otherwise. - Set
compression.type=zstdorlz4for JSON or text payloads; keepnonefor payloads that are already compressed (images, Avro with its own codec). - Raise
linger.ms(and thenbatch.size, e.g. 64-128 KiB) when the goal is throughput. Checkbatch-size-avg,records-per-request-avgandcompression-rate-avgin the producer metrics to see whether batches are actually filling.
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.
| Setting | Default (Kafka 4.0) | Meaning |
|---|---|---|
acks | all | 0: 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 |
retries | 2147483647 | How many times a retriable failure is retried. The docs recommend leaving it alone and using delivery.timeout.ms instead |
delivery.timeout.ms | 120000 | Upper bound on the time from send() returning to success or failure: batching delay, in-flight time and all retries together |
request.timeout.ms | 30000 | How long to wait for the answer to one request before treating it as failed and retrying |
retry.backoff.ms / retry.backoff.max.ms | 100 / 1000 | Wait between attempts, growing exponentially up to the maximum |
max.in.flight.requests.per.connection | 5 | Unacknowledged requests the client sends on one connection before waiting |
| Kind | Examples | What the producer does |
|---|---|---|
| Retriable | NotLeaderOrFollowerException, NetworkException, TimeoutException on a request, NotEnoughReplicasException | Refresh metadata if needed, wait for the backoff, resend, until delivery.timeout.ms |
| Non-retriable | RecordTooLargeException, SerializationException, InvalidTopicException, TopicAuthorizationException | Fail the record immediately; your callback gets the exception |
| Deadline passed | TimeoutException whose message says the records expired after delivery.timeout.ms since batch creation | Fail 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.
- 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).
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.
| Idempotence | max.in.flight | Retries | Per-partition order after a failure |
|---|---|---|---|
| On (default) | 1 to 5 | Yes | Preserved |
| Off | 1 | Yes | Preserved, low throughput; duplicates possible |
| Off | greater than 1 | Yes | May be reordered, duplicates possible |
| Off | any | No (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.
- The producer refuses to start when
delivery.timeout.msis smaller thanrequest.timeout.ms + linger.ms. A subtler error is setting a longrequest.timeout.msand 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.
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).
InitProducerId request. The PID is unique across the cluster and has an epochEvery 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).
| Situation | Protected? | Why |
|---|---|---|
| Producer retries after a lost ack or leader change | Yes | Same PID and sequence: broker deduplicates |
| Reordering caused by retries with up to 5 in flight | Yes | Sequence gaps are rejected |
| Application crashes and restarts, then re-sends the same business event | No | A 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 event | No | Different PIDs |
| The record already failed (callback got an exception) and you send it again yourself | No | A new send() is a new sequence number |
| Duplicates produced upstream and read by your consumer | No | This 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).
- 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.
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.
| Piece | Role |
|---|---|
transactional.id | A 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 fencing | Each 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.ms | Producer setting, default 60000. A transaction open longer is aborted by the coordinator. The broker caps it with transaction.max.timeout.ms (default 15 minutes) |
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_committedconsumer of that partition untiltransaction.timeout.msexpires. The consumer docs say so directly: inread_committedmodepoll()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:
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.
- 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.
"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.
| Scenario | Covered? | What to do instead |
|---|---|---|
Kafka to Kafka, same cluster, read_committed downstream | Yes | Transactions (or Kafka Streams processing.guarantee=exactly_once_v2) |
| Consumer writes to a database, calls an HTTP API, sends an email, charges a card | No | Idempotent consumer: dedup key plus business write in one DB transaction (chapter 06) |
Downstream consumers left on read_uncommitted | No | They see aborted records. Set isolation.level=read_committed |
| Two clusters (mirroring, cross-cluster copy) | No | Transactions do not span clusters; design for idempotence at the destination |
| Upstream sources writing duplicates before Kafka | No | Deduplicate by business key |
| A consumer that also commits offsets itself while a transactional producer uses the same group | No | Offsets of a consume-transform-produce loop go only through sendOffsetsToTransaction |
- 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 downstreamread_committedconsumers are not delayed.
- Two running instances with the same
transactional.idfence each other forever: eachinitTransactions()kills the other withProducerFencedException. 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 forOutOfOrderSequenceExceptionandAuthorizationException; for any otherKafkaExceptioninside a transaction, callabortTransaction(), rewind the consumer to its committed offsets and try again.
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.
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.
| Setting | Default (Kafka 4.0) | Effect |
|---|---|---|
max.poll.records | 500 | Most records returned by one poll(). The unit of work between two polls |
fetch.min.bytes / fetch.max.wait.ms | 1 / 500 | The broker answers when it has at least this many bytes or the wait expires |
fetch.max.bytes | 52428800 (50 MiB) | Upper bound on the data of one fetch response (soft limit) |
max.partition.fetch.bytes | 1048576 (1 MiB) | Upper bound per partition per fetch (soft limit: a bigger record is still delivered) |
max.poll.interval.ms | 300000 (5 min) | Longest allowed gap between two poll() calls before the member is considered failed |
isolation.level | read_uncommitted | Set 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 |
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_offsetspartition 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 partitions | Rebalance style |
|---|---|---|
RangeAssignor | Per topic, contiguous ranges per member. Co-partitions two topics so partition 0 of both go to one consumer | Eager |
RoundRobinAssignor | Round-robin over all subscribed partitions, most even | Eager |
StickyAssignor | Even, and keeps existing ownership where possible | Eager |
CooperativeStickyAssignor | Same as sticky, but moves partitions in two phases | Cooperative (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.
- 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).
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).
- 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.
- 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, soauto.offset.resetdecides: the default is **latest**, and the consumer silently jumps to the end and skips everything produced meanwhile. Setearliestfor consumers that must not miss data, ornoneto fail loudly, and alert on lag so that downtime is noticed long before expiry.
| Value | Behaviour when no committed offset exists or it is out of range | Typical use |
|---|---|---|
latest (default) | Start at the end; only new records are read | Real-time dashboards where history does not matter |
earliest | Start at the oldest retained record | Anything that must process every event; first run of a new group |
none | Throw an exception to the application | Safety-critical consumers that want to decide explicitly |
by_duration:PnDTnHnMn.nS | Start 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().
| Strategy | How it works | Guarantee |
|---|---|---|
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 poll | At-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 errors | At-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 processing | commitSync() right after poll(), then process | At-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 offset | Effectively once into that database; Kafka's committed offset is then informational |
**sendOffsetsToTransaction** (chapter 05) | Offset commits atomically with Kafka output | Exactly-once Kafka to Kafka |
seek(partition, offset),seekToBeginning(...),seekToEnd(...)set the position. The nextpoll()fetches from there.offsetsForTimes(Map<TopicPartition, Long>)returns, for each partition, the earliest offset whose timestamp is at or after the given time (ornullif no such record). Combine it withseek()to "replay from yesterday 09:00". It uses the.timeindexfiles of page 02.beginningOffsets()andendOffsets()give the first retained and the next-to-be-written offsets.- From outside the application:
kafka-consumer-groups.sh --reset-offsetswith--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.
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 it | How |
|---|---|
| Command line | kafka-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 exporters | Tools 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.
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.
- Dedup key: prefer a business event id that the producer put in the payload or a header.
topic-partition-offsetalso 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.
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.
| Trigger | Example |
|---|---|
| A member joins | A pod starts, a deploy rolls out a new instance |
| A member leaves cleanly | consumer.close(), graceful shutdown |
| A member is declared dead: session timeout | JVM crash, node lost, no heartbeat for session.timeout.ms |
| A member is declared dead: poll interval | Processing between two poll() calls took longer than max.poll.interval.ms |
| The subscription or topic metadata changes | Partitions added to a topic, a regex subscription matches a new topic |
| Setting | Default | Who watches it | What happens when it expires |
|---|---|---|---|
heartbeat.interval.ms | 3000 | Consumer 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.ms | 45000 | Broker: no heartbeat for this long | Member 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.ms | 300000 | Client: time between poll() calls | The client leaves the group itself. Later commits fail with CommitFailedException because the member no longer owns the partitions |
max.poll.records | 500 | Bounds the work between two polls | Lower 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.
max.poll.interval.ms (5 minutes) expires. The consumer leaves the group and its partitions move to another membercommitSync()CommitFailedException; the batch is processed again by the new owner (duplicates)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).
ConsumerRebalanceListener.onPartitionsRevokedis where you commit what you have processed and flush state for partitions you are about to lose;onPartitionsAssignedis where you load state orseek(). With cooperative rebalancing,onPartitionsRevokedreceives only the partitions that really move, andonPartitionsLostis called when the ownership was already lost (session expired), where committing is no longer possible.
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).
Kafka 4.0 shipped a redesigned group protocol. What the 4.0 documentation says, and what we saw in the lab:
| Aspect | Classic protocol | New protocol (group.protocol=consumer) |
|---|---|---|
| Status in 4.0 | Still 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 assignment | A client (the group leader member) runs the assignor | The broker; assignors configured with group.consumer.assignors, default uniform,range |
| Rebalance shape | Join/Sync barrier; eager unless cooperative sticky is chosen | Fully incremental: no global synchronisation barrier, members move independently via heartbeats |
| Heartbeat and session | Client settings heartbeat.interval.ms, session.timeout.ms | Controlled 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 selection | partition.assignment.strategy on the client | group.remote.assignor (optional) names a server-side assignor; partition.assignment.strategy is not usable |
| Regex subscription | Evaluated on the client | subscribe(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.
- 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 setssession.timeout.ms,heartbeat.interval.msorpartition.assignment.strategyor callsenforceRebalance(): the 4.0 docs list these as no longer usable with the new protocol.
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.
| Format | Strengths | Weaknesses |
|---|---|---|
| String / JSON (Jackson) | Human readable, no tooling, easy to debug | Large, no enforced schema, field renames break consumers silently, numbers and dates are ambiguous |
| Avro | Compact binary, schema evolution rules are first class, reader and writer schemas resolved at read time | Needs the writer schema to read, i.e. a registry or embedded schemas |
| Protobuf | Compact, strong tooling and code generation, field numbers give safe evolution | Evolution rules need care (renumbering is a break); schema distribution as for Avro |
| JSON Schema | Keeps JSON on the wire with validation | Larger than binary formats; compatibility rules are subtle |
| Raw bytes | Zero overhead | You own the format and the versioning |
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.
- A record that the deserializer cannot decode makes
poll()throwRecordDeserializationException, and **the nextpoll()fetches the same record again**: the consumer is stuck forever on one bad message. The exception carriestopicPartition()andoffset()(checked against the 4.0.2 client): catch it, log or dead-letter the raw bytes if you can, thenseek(topicPartition, offset + 1)to move past it. Libraries such as Spring Kafka'sErrorHandlingDeserializer(page 04) wrap this pattern for you.
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.
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.
| Mode | Guarantee | Allowed changes (Avro) | Upgrade first |
|---|---|---|---|
| BACKWARD (default) | A consumer on the new schema can read data written with the previous schema | Delete fields; add fields with a default | Consumers, then producers |
| FORWARD | A consumer on the old schema can read data written with the new schema | Add fields; delete fields that had a default | Producers, then consumers |
| FULL | Both of the above, against the previous version | Add or delete only fields with defaults | Any order |
BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, FULL_TRANSITIVE | Same, but checked against all earlier versions, not just the last | As above | As above |
| NONE | No checking | Anything | Create 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.)
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.
- Register schemas from CI (a Maven or Gradle plugin, or the registry REST API), set
auto.register.schemas=falseon producers, and run the registry's compatibility check in the pull request. A breaking schema change then fails the build instead of a consumer.
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.
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.
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.
try-with-resourcescallsclose(), which waits for in-flight batches, so records are not lost at exit. A bareSystem.exit()aftersend()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,snappyandzstdneed their own jar (chapter 02).
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.
commitSync()with no arguments commits the position of every assigned partition, that is, the offset after the last recordpoll()returned. Ifprocess()throws midway, the loop dies before the commit, and the batch is replayed: at-least-once.onPartitionsRevokedcommits the work done for partitions about to leave. Without it, a rebalance replays up to one batch for those partitions.wakeup()is the onlyKafkaConsumermethod that may be called from another thread. It makes the blockedpoll()throwWakeupException, which we treat as a normal shutdown.auto.offset.reset=earliestapplies only to a group with no committed offsets.
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.
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.
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).
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.
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.
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:
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:
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:
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:
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:
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:
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:
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.
docker rm -f kafkaremoves the container, the topics and the group state.
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.
| Symptom | Most likely cause | First check |
|---|---|---|
| Duplicate records downstream after a deploy or crash | At-least-once consumer without idempotence; producer restarted and re-sent | Is the consumer idempotent (chapter 07)? Do events have a stable id? |
| Records missing after a consumer crash | Offset committed before processing, or auto commit with asynchronous processing | Where is the commit relative to the work? |
| Consumer silently skips history after a long outage | Offsets expired, auto.offset.reset=latest | offsets.retention.minutes, the reset policy, lag alerts |
| Constant rebalancing, lag grows, throughput near zero | Processing slower than max.poll.interval.ms; unstable pods; session timeout too low | Time per batch versus max.poll.interval.ms; max.poll.records |
CommitFailedException or "member no longer part of the group" on commit | The 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 access | One consumer used by several threads | Who calls poll(), commit*() or seek()? |
| One partition lags, the rest are fine | Hot key, or a poison record the consumer retries forever | Per-partition lag; the offset the consumer is stuck at |
send() hangs for 60 s then TimeoutException | Unreachable cluster, wrong topic name, or buffer full | max.block.ms, buffer metrics, broker health |
| Out-of-order records within a key | Idempotence off with max.in.flight above 1, or key not set, or partitions added | Producer config as printed in the startup log |
ProducerFencedException | Two instances with the same transactional.id, or a zombie after a pause | Unique ids per instance; treat as fatal |
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.
max.poll.interval.msFixes, 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.
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):
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.
The producer is the opposite: KafkaProducer is thread-safe and should be shared.
- **A new
KafkaProducerper 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, andget()only where you truly need synchronous confirmation. - **
flush()after everysend().** Same effect: no batching. - Ignoring the callback. A silent
TimeoutExceptionafter 120 s is a lost record nobody noticed. - Copying an old configuration.
max.in.flight.requests.per.connection=1,retries=3,acks=1appear in years-old tutorials. Today's defaults (acks=all, idempotence on, 5 in flight, retries bounded bydelivery.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.
- 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.
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 aFutureimmediately. A background Sender thread drains ready batches (full,linger.mselapsed, 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 bymax.block.ms(60 s), after which it throwsTimeoutException. It does not block for the network round trip. - Q: Explain
acksand how it relates tomin.insync.replicas.acks=0fire and forget,1leader only,allevery in-sync replica (default).acks=allis only as strong asmin.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.msand how does it relate toretries?
It is the overall deadline (default 120 s) fromsend()returning to success or failure, covering batching, request attempts and backoff.retriesdefaults toInteger.MAX_VALUE, so the delivery timeout is what actually bounds retrying. It must be at leastrequest.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 withOutOfOrderSequenceException. It is on by default since Kafka 3.0 and requiresacks=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 stabletransactional.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 ofmax.in.flight=1is no longer needed. - Q: How do Kafka transactions work, and what is
transactional.idfor?
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, andsendOffsetsToTransactionaccumulate output and input offsets;commitTransactionmakes the coordinator log the decision and write commit markers to every partition involved.read_committedconsumers 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 onread_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 sametransactional.id. EachinitTransactions()raises the epoch; the broker rejects writes carrying an older epoch withProducerFencedException, which the application must treat as fatal. - Q: Explain consumer groups and how partitions are assigned.
Consumers sharing agroup.idsplit 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 afteroffsets.retention.minutes(7 days). - Q: Auto commit vs manual commit: consequences?
Auto commit (default every 5 s, insidepoll()) 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.resetdo?
It applies only when the group has no valid committed offset (new group, expired offsets, offset out of range):latest(default),earliest,none, orby_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 runkafka-consumer-groups.sh --reset-offsets --to-datetime ... --execute, or in code useoffsetsForTimesandseek. 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 withkafka-consumer-groups.sh --describe, the consumer metricrecords-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), smallermax.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 withgroup.protocol=consumer(default isclassic). Assignment is computed on the broker (assignorsuniformandrangeby 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 stablegroup.instance.idper 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, throwingConcurrentModificationException. Scale with one consumer per thread (same group), or poll in one thread and hand records to workers withpause()/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,seekpastoffset + 1, and add bounded retries.
| Scenario | Outline 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). |
Cheat Sheet and Recap
| Setting | Default (Kafka 4.0) | Remember |
|---|---|---|
acks | all | Durable only with min.insync.replicas=2 on RF 3 |
enable.idempotence | true | Needs acks=all, retries above 0, in-flight 5 or less; covers one producer session |
retries / delivery.timeout.ms | 2147483647 / 120000 | The delivery timeout is the real limit; at least request.timeout.ms + linger.ms |
request.timeout.ms | 30000 | Per attempt |
linger.ms / batch.size | 5 / 16384 | linger changed from 0 to 5 in 4.0 |
buffer.memory / max.block.ms | 33554432 / 60000 | send() blocks on metadata or memory, then TimeoutException |
max.in.flight.requests.per.connection | 5 | Order kept up to 5 with idempotence |
compression.type | none | lz4, snappy, zstd need extra jars |
transactional.id / transaction.timeout.ms | null / 60000 | Unique per instance; fenced on reuse; broker cap 15 min |
enable.auto.commit / auto.commit.interval.ms | true / 5000 | Commits at the next poll() |
auto.offset.reset | latest | Only without a valid committed offset |
isolation.level | read_uncommitted | Use read_committed with transactional producers |
max.poll.records / max.poll.interval.ms | 500 / 300000 | A batch must fit the interval |
session.timeout.ms / heartbeat.interval.ms | 45000 / 3000 | Classic protocol only |
partition.assignment.strategy | Range, CooperativeSticky | Range wins by default: eager |
group.protocol | classic | consumer opts in to KIP-848 (GA in 4.0) |
group.instance.id | null | Set for static membership |
offsets.retention.minutes (broker) | 10080 | Offsets of an empty group expire after 7 days |
- **
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=2for durability, idempotence for safe retries,delivery.timeout.msas the deadline, a stabletransactional.idfor 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;
KafkaProduceris thread-safe and should be shared. - Schemas are contracts. Use a registry, BACKWARD compatibility by default, and fail builds, not production.
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.
- 04 Kafka with Spring - the same producer and consumer machinery behind
KafkaTemplate,@KafkaListener, error handlers and retry topics. - 07 Event-Driven Patterns - outbox, idempotent consumers, sagas and dead-letter design.
- Kafka Architecture - partitions, replication and the broker side of everything on this page.
- Messaging Fundamentals - delivery guarantees and idempotent consumers in general.