Unit 2.2 · Kafka and Messaging
Producers, Consumer Groups, Rebalancing, and Delivery Semantics
acks and idempotent producers, how consumer groups share partitions, what triggers a rebalance, committing offsets, at-least-once vs exactly-once, retries, dead-letter topics, and consumer lag.
Start here
The mental model
A CONSUMER GROUP is a team reading one topic together. Kafka hands each partition to exactly one team member, so 6 partitions and 3 consumers means 2 partitions each. Add a fourth consumer and the partitions are reshuffled (a REBALANCE). A seventh consumer on a 6-partition topic sits idle: partitions are the unit of parallelism.
The hardest question in messaging is: what happens if a consumer crashes halfway through a message? If it committed the offset before processing, the message is lost (AT-MOST-ONCE). If it commits after, the message is processed again on restart (AT-LEAST-ONCE). Almost every real system chooses at-least-once and makes processing IDEMPOTENT, so doing it twice has the same effect as doing it once.
Go deeper
How it works inside
01Producer guarantees: acks, retries, idempotence
acks=0 sends without waiting (fast, lossy). acks=1 waits for the partition leader to write it; if the leader dies before followers copy it, it's lost. acks=all waits until all in-sync replicas have it (Unit 2.3), which is the durable choice and the default since Kafka 3.0.
Retries can create duplicates: the broker wrote the batch, but the acknowledgement was lost, so the producer sends it again. enable.idempotence=true (default in modern clients) gives each producer an ID and sequence numbers, so the broker discards duplicates and preserves order even with retries. TRANSACTIONS go further: atomically write to several partitions and commit consumer offsets in one step. That's what Kafka Streams' exactly_once_v2 uses for read-process-write pipelines within Kafka.
02Consumer groups and rebalancing
Consumers with the same group.id coordinate through a broker called the GROUP COORDINATOR. Each consumer sends heartbeats; a consumer that stops heartbeating (session.timeout.ms) or doesn't call poll() within max.poll.interval.ms (default 5 minutes) is considered dead, and its partitions are reassigned.
Classic 'eager' rebalancing stops ALL consumers while partitions are reassigned. COOPERATIVE rebalancing (CooperativeStickyAssignor) only moves the partitions that need to move, and Kafka 4.0's new consumer group protocol (KIP-848) moves assignment to the broker and makes rebalances incremental by default. STATIC MEMBERSHIP (group.instance.id) lets a consumer restart (a rolling deploy) without triggering a rebalance at all, if it comes back within the session timeout.
03Committing offsets
With enable.auto.commit=true (the default), the client commits the offsets returned by the last poll() every 5 seconds. A crash can then re-deliver up to 5 seconds of messages, or, if your code processes asynchronously in other threads, SKIP messages that were committed but never processed. For important work: disable auto-commit and commit after processing (synchronously per batch, or asynchronously with a final sync commit on shutdown).
04Poison messages, retries, and dead-letter topics
Kafka has no built-in per-message retry or dead-lettering. If one malformed record makes your consumer throw, a naive loop retries forever and the partition is stuck while lag grows. The standard pattern: catch the error, retry a few times with backoff (or send to orders.retry.5m topics for delayed retry), then publish the record with error headers to a DEAD-LETTER TOPIC (orders.dlq), commit, and move on. Alert on DLQ volume and give someone a tool to inspect and replay it. Spring Kafka, Kafka Connect, and most frameworks implement this for you.
05Consumer lag
LAG = latest offset in the partition − the group's committed offset. It's the single most important Kafka metric: it's how far behind reality your consumers are. Growing lag means consumers are slower than producers (scale out up to the partition count, speed up processing, or fix a stuck partition). Measure it with kafka-consumer-groups.sh --describe, kafka-exporter or Burrow into Prometheus, and KEDA can autoscale consumers on it (Kubernetes course, cluster autoscaler and KEDA). Alert on lag in TIME (how old is the oldest unprocessed event) where possible, not just message counts.
Do it
Hands-on lab
- 1
Create a 6-partition topic and start two consumers in one group
Run each consumer in its own terminal. Then describe the group to see who owns what.
terminal$ docker exec kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic payments --partitions 6# terminal A and B:docker exec -it kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic payments --group email# terminal C:docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group email── expected output ──GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-IDemail payments 0 0 0 0 console-consumer-5c1e...email payments 1 0 0 0 console-consumer-5c1e...email payments 2 0 0 0 console-consumer-5c1e...email payments 3 0 0 0 console-consumer-a93b...email payments 4 0 0 0 console-consumer-a93b...email payments 5 0 0 0 console-consumer-a93b... - 2
Create lag and watch it drain
Stop both consumers, produce 100,000 records with the perf tool, and describe the group: the lag is exactly the unread records. Restart a consumer and watch LAG fall to 0.
terminal$ docker exec kafka /opt/kafka/bin/kafka-producer-perf-test.sh --topic payments --num-records 100000 --record-size 200 --throughput -1 --producer-props bootstrap.servers=localhost:9092 acks=alldocker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group email | awk '{s+=$6} END {print "total lag:", s}'── expected output ──100000 records sent, 61349.7 records/sec (11.70 MB/sec), 212.33 ms avg latency, 498.00 ms max latency.total lag: 100000 - 3
Replay by resetting offsets
A bug in the email consumer sent wrong emails for the last hour. Fix it, stop the group, and rewind.
--dry-runshows the plan first;--executeapplies it. Resetting only works while the group has no active members.terminal$ docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group email --topic payments \--reset-offsets --to-datetime 2026-09-27T09:00:00.000 --execute── expected output ──GROUP TOPIC PARTITION NEW-OFFSETemail payments 0 14520email payments 1 14493... - 4
A safe consumer loop (Python)
At-least-once with manual commits, a DLQ for poison messages, and an idempotency check. Here the language matters a little: the same structure applies in Java (Spring Kafka's
DefaultErrorHandler+DeadLetterPublishingRecovererdoes it for you).email_consumer.pywhole filepython from confluent_kafka import Consumer, Producer import json c = Consumer({"bootstrap.servers": "localhost:9092", "group.id": "email", "enable.auto.commit": False, "auto.offset.reset": "earliest", "partition.assignment.strategy": "cooperative-sticky"}) dlq = Producer({"bootstrap.servers": "localhost:9092"}) c.subscribe(["payments"]) while True: msg = c.poll(1.0) if msg is None or msg.error(): continue try: event = json.loads(msg.value()) if not already_sent(event["payment_id"]): # idempotency key send_receipt(event) mark_sent(event["payment_id"]) except Exception as e: # poison message dlq.produce("payments.dlq", msg.value(), key=msg.key(), headers={"error": str(e), "src.offset": str(msg.offset())}) dlq.flush() c.commit(message=msg, asynchronous=False) # after processing
Operate it
Knobs that matter
| Setting | Default | What it does | When to change it |
|---|---|---|---|
| acks (producer) | all | How many replicas must confirm a write. | Keep all for business events; 1 only for data you can lose. |
| enable.idempotence (producer) | true | Deduplicates retried batches and keeps order. | Leave on; it requires acks=all. |
| linger.ms / batch.size | 5ms / 16KB | How long and how much to batch before sending. | Raise linger (10–50ms) and batch size for throughput; compress with zstd or lz4. |
| max.poll.interval.ms | 300000 | Max time between polls before the consumer is kicked out. | Raise or lower max.poll.records if processing a batch can take longer. |
| session.timeout.ms | 45000 | Heartbeat timeout before a consumer is declared dead. | Lower for faster failure detection; combine with static membership for rolling deploys. |
| auto.offset.reset | latest | Where a group starts with no committed offset. | earliest for new services that must process history; know which one you picked. |
3am practice
Failure drills
Each drill is a real failure mode. Read the scenario and the output, decide what's wrong, then reveal the diagnosis.
Drill #1
Endless rebalancing, lag climbing
After a release that calls a slow external API per message, the consumer group rebalances every few minutes and lag grows without limit.
Drill #2
One partition stuck, the rest fine
Lag is zero on 11 of 12 partitions and growing on partition 7. The consumer logs the same error every second.
Decide
Delivery semantics
| Semantics | How | Risk | Use for |
|---|---|---|---|
| At-most-once | Commit before processing, or acks=0 | Lost messages on crash | Metrics, logs where gaps are fine |
| At-least-once | Process, then commit; acks=all; retries | Duplicates on crash/rebalance | Almost everything, with idempotent processing |
| Exactly-once (in Kafka) | Idempotent producer + transactions, read_committed consumers | Complexity; only covers Kafka-to-Kafka | Stream processing (Kafka Streams, Flink) |
| Effectively-once (end-to-end) | At-least-once + idempotency keys / upserts in the sink | Must design the sink carefully | Payments, emails, external side effects |
The bigger picture
Connects to
System Design · Delivery Semantics
At-most-once, at-least-once, exactly-once — the contract between producer and consumer, and where the lies live.
System Design · Idempotency
The property that makes retries, replays and at-least-once safe: same request → same result, every time.
System Design · Dead Letter Queue (DLQ)
The parking lot for poison messages: retried N times, failed, and now visible to a human.
Kubernetes · Cluster Autoscaler & KEDA
Scale consumers on Kafka lag instead of CPU.
Observability · Retry storm
What happens when retries multiply load, the same danger as consumer retry loops.
AWS · SQS
Visibility timeouts and DLQs: how a managed queue solves the same problems.
Prove it
Interview questions
How do consumer groups work, and what limits consumer parallelism?
What causes rebalances and how do you reduce their impact?
Can Kafka guarantee exactly-once delivery?
Consumer lag is growing. What do you check?