Command Palette

Search for a command to run...

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.

Intermediate 55 min 4 lab steps 2 failure drills

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.

Consumer groups and rebalancingdiagram
Rendering diagram…

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. 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-ID
    email 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. 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=all
    docker 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. 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-run shows the plan first; --execute applies 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-OFFSET
    email payments 0 14520
    email payments 1 14493
    ...
  4. 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 + DeadLetterPublishingRecoverer does 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

SettingDefaultWhat it doesWhen to change it
acks (producer)allHow many replicas must confirm a write.Keep all for business events; 1 only for data you can lose.
enable.idempotence (producer)trueDeduplicates retried batches and keeps order.Leave on; it requires acks=all.
linger.ms / batch.size5ms / 16KBHow 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.ms300000Max time between polls before the consumer is kicked out.Raise or lower max.poll.records if processing a batch can take longer.
session.timeout.ms45000Heartbeat timeout before a consumer is declared dead.Lower for faster failure detection; combine with static membership for rolling deploys.
auto.offset.resetlatestWhere 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.

terminal
$ kubectl logs deploy/email-consumer | grep -i -m2 'poll'
── what you'll see ──
WARN consumer poll timeout has expired. This means the time between subsequent calls to poll() was longer than the configured max.poll.interval.ms, which typically implies that the poll loop is spending too much time processing messages.
INFO Revoking previously assigned partitions [payments-2, payments-3]

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.

terminal
$ kafka-consumer-groups.sh --bootstrap-server kafka:9092 --describe --group stock | sort -k6 -n | tail -2
── what you'll see ──
stock orders 6 884120 884120 0
stock orders 7 861044 902513 41469

Decide

Delivery semantics

SemanticsHowRiskUse for
At-most-onceCommit before processing, or acks=0Lost messages on crashMetrics, logs where gaps are fine
At-least-onceProcess, then commit; acks=all; retriesDuplicates on crash/rebalanceAlmost everything, with idempotent processing
Exactly-once (in Kafka)Idempotent producer + transactions, read_committed consumersComplexity; only covers Kafka-to-KafkaStream processing (Kafka Streams, Flink)
Effectively-once (end-to-end)At-least-once + idempotency keys / upserts in the sinkMust design the sink carefullyPayments, emails, external side effects

The bigger picture

Connects to

Prove it

Interview questions

01

How do consumer groups work, and what limits consumer parallelism?

02

What causes rebalances and how do you reduce their impact?

03

Can Kafka guarantee exactly-once delivery?

04

Consumer lag is growing. What do you check?

0/4 · 0%