◐ ai-teacher

⚡ Streaming & Kafka · Chapter 5

Consumer groups, rebalancing and offsets

Three mechanisms decide whether your consumer loses data, duplicates it, or quietly stops: where the offset is committed, when the group rebalances, and how long a slow poll is tolerated.

Study this lesson free in the app →

The basics

A consumer group is a set of consumer processes sharing one group.id that split a topic's partitions between them, so each partition is read by exactly one member and the group's progress survives restarts.

The problem it solves: one consumer can't keep up with a busy topic, and if it crashes someone has to carry on from where it stopped — without skipping events or redoing hours of work. The group gives you both: parallelism (one member per partition) and continuity (committed offsets stored in Kafka).

The mental model: a warehouse night shift with three loading bays. Each bay has a clipboard recording the last crate handled — the committed offset. When a worker joins or leaves, everyone stops while the supervisor reassigns bays — a rebalance. Write on the clipboard before unloading a crate and a collapse means a crate is skipped; write after and the next worker may unload one twice. Kafka gives you the same choice.

Words you must know 5

Committed offset
the group's saved position per partition.
Rebalance
reassigning partitions when members join, leave or are judged dead.
max.poll.interval.ms
longest allowed gap between poll() calls before you're ejected (default 5 min).
session.timeout.ms
how long without heartbeats before you're declared dead (default 45 s).
Poison pill
a record that always fails and stalls its partition unless sent to a dead-letter topic.

Explained

A consumer group is a promise: every partition is processed by exactly one member, and progress survives restarts.

Keeping that promise costs three things you have to configure — commit timing, rebalance behaviour, and poll timeouts. Get them wrong and the failures are subtle: not a crash, but silent gaps or silent doubles.

The night shift handover

A warehouse runs three loading bays with a rotating crew.

The clipboard at each bay records the last crate handled. A new worker picks up the clipboard and continues — that's the committed offset.

The handover happens whenever someone joins or leaves. Everyone stops, the supervisor reassigns bays, everyone restarts. Brief, disruptive, necessary — that's a rebalance.

The rule about the clipboard is where it gets interesting. Write it down before you unload the crate, and if you collapse mid-lift the next worker skips it entirely — data loss. Write it down after, and if you collapse the next worker redoes that crate — a duplicate.

There is no third option. You are choosing which failure you'd rather have, and the industry chose duplicates — because duplicates can be absorbed by idempotency, and gaps cannot be recovered at all.

The lifecycle of a consumer

poll() does double duty: it fetches records and tells the coordinator you're alive. That's why slow processing between polls looks identical to a dead consumer.

The three timeouts that actually matter

The classic production failure lives in the interaction of the last two. You fetch 500 records, each takes 2 seconds to process, so the next poll() is 16 minutes away. At 5 minutes the coordinator ejects you, rebalances your partitions to someone else — and when your original consumer finally finishes and tries to commit, the commit is rejected because it no longer owns those partitions.

The work was done. The offset was never recorded. Another consumer reprocesses all of it. From the outside this looks like a mysterious duplicate storm, and it repeats forever because each cycle takes just as long.

Fix: lower max.poll.records, raise max.poll.interval.ms, or move slow work off the poll loop.

Step through a rebalance

Calling close() on shutdown turns that 45-second gap into a sub-second one. It's one line, and it's the difference between graceful and grim during a deploy.

The duplicate storm

A payments consumer processes 500 records per poll. Each record calls an external API taking ~2 seconds.

Symptoms:

A consumer written for production

from confluent_kafka import Consumer, KafkaError

c = Consumer({
    "bootstrap.servers": BROKERS,
    "group.id": "billing",
    "enable.auto.commit": False,        # we decide when progress is recorded
    "auto.offset.reset": "earliest",
    "max.poll.records": 50,            # sized to the work per record
    "max.poll.interval.ms": 600_000,   # generous, but bounded
})

def on_assign(consumer, partitions):
    log.info("assigned %s", [(p.topic, p.partition) for p in partitions])

def on_revoke(consumer, partitions):
    log.info("revoked %s — committing what we finished", len(partitions))
    try:
        consumer.commit(asynchronous=False)   # last chance before losing them
    except Exception as err:
        log.warning("commit on revoke failed: %s", err)

c.subscribe(["ride_events"], on_assign=on_assign, on_revoke=on_revoke)

try:
    while running:
        msg = c.poll(1.0)
        if msg is None:
            continue
        if msg.error():
            if msg.error().code() != KafkaError._PARTITION_EOF:
                log.error("consume error: %s", msg.error())
            continue

        try:
            handle(msg)                       # MUST be idempotent
        except RetryableError:
            continue                          # don't commit; it'll be redelivered
        except Exception as err:
            dlq.send(msg, reason=repr(err))   # poison pill — park it, move on

        c.commit(msg, asynchronous=False)     # after the work, always
finally:
    c.close()                                 # leave the group immediately

Five production details in there: manual commit after work, a revoke hook that commits before losing partitions, a DLQ so one poison record can't stall a partition forever, retryable errors left uncommitted, and close() in a finally.

Rebalances are the hidden cost of scale

Every join, leave, crash, deploy or max.poll.interval breach pauses the entire group, not just the affected member. With the default eager protocol, everyone gives up all partitions and gets a new assignment — a stop-the-world pause of seconds.

At scale that adds up:

What to watch in production, in priority order:

  1. Lag per partition — not aggregate. One lagging partition means a hot key; the average hides it
  2. Rebalance rate — a group rebalancing constantly is a group not working
  3. Commit failure rate — rejected commits mean ejection, and duplicates
  4. Time in rebalancing state — if it's a meaningful share of uptime, look at timeouts and deploy strategy

Words to know

Consumer groupConsumers cooperating to read a topic; each partition assigned to exactly one member.
Group coordinatorThe broker that tracks group membership and assigns partitions.
Committed offsetThe recorded position a group resumes from after a restart or reassignment.
RebalanceReassignment of partitions triggered by membership change; pauses the group.
session.timeout.msHow long without a heartbeat before a member is declared dead.
max.poll.interval.msMaximum allowed time between poll() calls before ejection.
max.poll.recordsHow many records one poll() returns — the main lever on batch processing time.
Static membershipgroup.instance.id lets a restarting consumer reclaim its partitions without a full rebalance.
Cooperative rebalancingIncremental reassignment where unaffected members keep their partitions.
Poison pillA record that always fails processing, stalling its partition until it is routed to a DLQ.

In two minutes

Test yourself

1. Should you commit the offset before or after processing?

  1. Before — it is faster
  2. After — duplicates are recoverable, gaps are not
  3. It makes no difference
  4. Neither; use auto-commit
Show the answer

After — duplicates are recoverable, gaps are not — Commit-then-crash loses data silently. Process-then-commit risks a duplicate, which idempotent writes absorb.

2. A consumer processes 500 records at 2s each. Default max.poll.interval.ms is 5 min. What happens?

  1. It completes normally
  2. It is ejected mid-batch, rebalances, and reprocesses the same records forever
  3. Kafka increases the timeout
  4. Records are dropped
Show the answer

It is ejected mid-batch, rebalances, and reprocesses the same records forever — 16 minutes between polls exceeds the 5-minute limit. Its commit is rejected after ejection, so the work repeats indefinitely.

3. Why does adding more consumers to a constantly-rebalancing group make things worse?

  1. Each new member triggers another rebalance, pausing the whole group
  2. Kafka limits group size
  3. Offsets get corrupted
  4. It doesn't — it always helps
Show the answer

Each new member triggers another rebalance, pausing the whole group — Membership changes trigger rebalances, and a rebalance stops the entire group. More churn, less work done.

4. What does calling close() on shutdown achieve?

  1. Commits all offsets
  2. Sends a leave request so failover is immediate rather than after session.timeout.ms
  3. Deletes the group
  4. Flushes the producer
Show the answer

Sends a leave request so failover is immediate rather than after session.timeout.ms — Without it the coordinator waits out the session timeout (~45s by default) before reassigning — dead air on every deploy.

5. Lag is high on one partition and near zero on the others. Most likely cause?

  1. A slow broker
  2. A hot partition key sending disproportionate traffic to one partition
  3. Too few consumers
  4. Auto-commit is off
Show the answer

A hot partition key sending disproportionate traffic to one partition — Per-partition lag exposes skew that aggregate lag hides. Adding consumers can't help — a partition is read by one member.

6. What is the difference between session.timeout.ms and max.poll.interval.ms?

  1. They are aliases for the same setting
  2. session.timeout.ms limits batch size; max.poll.interval.ms limits heartbeats
  3. session.timeout.ms catches a dead process (no heartbeats); max.poll.interval.ms catches a live process that stopped calling poll()
  4. Both only apply to producers
Show the answer

session.timeout.ms catches a dead process (no heartbeats); max.poll.interval.ms catches a live process that stopped calling poll() — Heartbeats come from a background thread, so a process stuck in slow processing still heartbeats fine. max.poll.interval.ms is the check that catches it — and the one behind most ejection loops.

There are 8 questions, 3 puzzles and 7 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →