Kafka CommitFailedException — Stop Rebalance Loops
CommitFailedException means a rebalance revoked your partitions mid-poll.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- ✓Basic Kafka consumer concepts
- ✓Familiarity with consumer groups
- ✓Access to consumer logs and metrics
- CommitFailedException means the group rebalanced and revoked your partitions before you committed — your offsets now belong to someone else
- The top cause is slow record processing exceeding max.poll.interval.ms (default 5 minutes), making the consumer look dead
- Faster polling (max.poll.records), async processing, and static membership with cooperative rebalancing stop the spiral
- Never ignore it and continue — the new owner reprocesses your records, so design every handler idempotent
Imagine movers splitting warehouse rooms and checking in regularly. One mover hits a packed room and goes quiet too long. The foreman assumes they quit and reassigns their rooms. When the slow mover finally reports done, the foreman says 'not your rooms anymore' — that's CommitFailedException. Fix it by clearing rooms faster, checking in more often, warning you'll be slow (a bigger max.poll.interval.ms), or issuing permanent name tags so absences don't trigger reassignment.
Your Kafka consumer logs CommitFailedException, then reprocesses 40,000 records it already handled — sending duplicate emails, double-counting metrics, and triggering fraud alerts on transactions that settled an hour ago. The rebalance that caused it took 90 seconds, reassigned all 12 partitions, and repeated 6 times before anyone noticed the loop.
This failure loves slow handlers: a consumer that calls a 2-second fraud API per record, processes 500 records per poll, and exceeds the 5-minute max.poll.interval.ms without calling poll again. The broker declares it dead, rebalances, and the revived consumer's commit lands on partitions it no longer owns. Each rebalance makes the backlog bigger, processing slower, and the next rebalance sooner — a death spiral.
The trap is catching the exception and continuing as if the commit succeeded. Offsets never committed, the new owner replays from the last commit, and your 'handled' records process twice. Combined with auto-commit enabled, teams get the worst of both worlds: commits firing mid-rebalance and duplicates everywhere.
By the end of this article you'll read rebalance logs like a timeline, size poll intervals from measured processing time, configure static membership and cooperative rebalancing, and build idempotent handlers plus transactional produces that make rebalances boring instead of catastrophic.
Why the Coordinator Revokes First and Asks Later
The group coordinator can't distinguish a dead consumer from a slow one — both stop calling poll. Its only evidence is silence past max.poll.interval.ms, and its only safe action is revocation: reassign the silent member's partitions to survivors so the group keeps making progress. Your consumer's late commit is then correctly rejected, because the new owner has already started fetching from the last committed offset and accepting yours would fork the offset history.
This design makes rebalances load-bearing for correctness. Offsets are per-partition progress markers shared by whoever owns the partition; two owners with divergent commit sequences would corrupt consumption guarantees for the whole group. The coordinator prefers duplicate processing (new owner replays from last commit) over lost processing (accepting a stale commit that skips records) — which is why every rebalance replays uncommitted work by construction, and why idempotent handlers are mandatory rather than nice-to-have.
Read rebalance logs as ownership transfers with timestamps: Revoked partitions [...] marks what you lost, Assigned partitions [...] marks the new map, and the gap between JoinGroup and SyncGroup is the stop-the-world pause (90s in this article's incident under eager rebalancing). Frequent revoke-assign cycles with no membership changes scream slow processing; cycles aligned with deploys scream missing static membership.
Size the Poll Loop: Records, Latency, and the 5-Minute Wall
The governing inequality is brutally simple: max.poll.records times worst-case seconds-per-record must fit inside max.poll.interval.ms with margin. Default 500 records at a 2-second downstream call needs 1,000 seconds against a 300-second wall — mathematically guaranteed rebalancing, no mystery, no broker fault. Every CommitFailedException of the slow-processing class is this arithmetic failing, and the fix is changing one of the three numbers: fewer records, faster records, or a longer wall.
Prefer fewer records first. Cutting max.poll.records to 50 turns the incident's 1,000s batch into 100s — inside budget with 3x margin — at the cost of more frequent polls (cheap) instead of rebalances (catastrophic). Speed up records second: timeouts on downstream calls, caching repeat verdicts, batching external calls. Lengthen the wall last: raising max.poll.interval.ms to 10 minutes accommodates genuinely slow work but delays failure detection for truly dead consumers — a real availability tradeoff, not a free knob.
Validate with production-like dependency latency, never mocks. The 50ms staging mock hid a 40x processing difference that only production traffic revealed. Load-test consumers with dependency latency injected at p99-plus, measure batch times at the 99th percentile (not the mean), and demand 2x margin under the interval before shipping.
Heartbeats vs Processing: Two Liveness Signals, Two Failures
Modern Kafka clients separate liveness into two channels: a background heartbeat thread governed by session.timeout.ms (dead in ~10s of silence) and the poll-call cadence governed by max.poll.interval.ms (dead in ~5 minutes without poll). Slow processing kills via the interval; GC pauses, event-loop blocks, and network stalls kill via heartbeats — even when processing itself is fast. Treating both as 'consumer slow' misdiagnoses half of all rebalance incidents.
Heartbeat deaths leave distinctive evidence: 'Heartbeat failed' and group-rebalancing warnings with healthy poll-batch timings, correlated with GC pause spikes, full event-loop lag metrics, or coordinator-connection resets. The consumer was doing fine work and got evicted for going quiet on the wrong channel. Fixes target the stall (GC tuning, unblocking the loop, connection resilience) plus modest session.timeout.ms headroom — not smaller poll batches, which change nothing for a heartbeat problem.
Monitor both channels independently. Track poll-batch p99 against the interval (processing health) and heartbeat success rate plus GC pause p99 (heartbeat health) on the same dashboard. When rebalances strike, the channel that degraded first names the cause — and the fix list for each channel shares almost nothing, which is why the distinction matters more than any single tuning value.
Static Membership and Cooperative Rebalancing for Calm Deploys
Every rolling deploy without static membership is a voluntary rebalance storm: each restarting consumer rejoins as a stranger, the coordinator revokes and reassigns, and with eager rebalancing the entire group pauses while every member reclaims partitions. Four consumers restarting sequentially means four stop-the-world pauses — during which lag grows, batches swell, and the slow-processing trap arms itself for the aftermath.
Static membership (group.instance.id set to a stable, unique-per-instance value) lets a restart rejoin as the same member and keep its partitions — no revocation, no reassignment, no storm. Kubernetes StatefulSets (stable pod names) or persisted instance IDs provide the stability; bare Deployments with random pod names need the ID plumbed from stable storage or the downward API. Pair it with the cooperativeSticky assignor so the rebalances that do occur migrate only the delta partitions while the rest keep consuming — turning 90s full pauses into sub-second partial ones.
Session timeout interacts here too: with static membership, a truly dead instance's partitions wait for session.timeout.ms before reassignment (availability cost), so keep it tight (10-30s) rather than inflating it as a rebalance band-aid. The combination — stable IDs, cooperative assignment, tight sessions — makes deploys invisible and reserves rebalances for genuine failures.
Idempotent Handlers: Replays Are Guaranteed, Duplicates Are Optional
Kafka's delivery contract under rebalancing is at-least-once: the new owner replays from the last committed offset, so every uncommitted record processes twice. This isn't a bug to eliminate — it's the protocol's chosen tradeoff (duplicates over data loss). Your handler design decides whether replays are harmless no-ops or $18,400 double-charge incidents. Idempotency is therefore not an optimization; it's the correctness layer the transport deliberately leaves to you.
Build guards keyed on the record's natural idempotency key (order ID, event ID): conditional writes (DynamoDB attribute_not_exists, SQL INSERT ... ON CONFLICT DO NOTHING), processed-set lookups with TTLs covering the maximum replay window, or state-machine transitions that refuse to re-apply terminal states. The guard and the mutation must be atomic — check-then-act across two calls races when two owners briefly overlap during the handoff.
Commit strategy complements but never replaces guards. commitSync after each batch gives clean replay boundaries (replay from batch start) at throughput cost; async commits need rebalance-listener hooks committing on revoke to shrink the replay window. Auto-commit on a timer fires mid-batch and widens replay unpredictably — disable it for any handler with side effects. But remember the hierarchy: guards make duplicates safe, commits merely make replays smaller.
Exactly-Once With Idempotent Producers and Transactions
For consume-process-produce pipelines (read order, write receipt to another topic), idempotent handlers alone leave a gap: the consume offset and the produced record commit separately, so a crash between them duplicates the produce or loses the offset. Kafka transactions close the gap by atomically committing consumed offsets and produced records together — the downstream sees the receipt exactly when the offset advances, and aborted transactions are invisible to read_committed consumers.
The setup has four mandatory pieces: enable.idempotence=true on the producer (sequence-checked, duplicate-free writes within a session), a unique transactional.id per producer instance (stable across restarts for fencing zombies), sendOffsetsToTransaction inside the consume loop coupling offsets to output, and isolation.level=read_committed on every downstream reader (otherwise uncommitted transactional records leak through). Miss any piece and you have complexity without the guarantee — the worst outcome.
Adopt transactions where duplicates cost money (payments, inventory, billing events) and accept their price: added end-to-end latency, transaction-coordinator load, and operational surface (stuck transactions need abort monitoring, zombie fencing needs stable IDs). For everything else — notifications, metrics, search indexing — idempotent handlers plus read-committed consumers deliver the practical safety at a fraction of the cost. Match the guarantee to the damage.
The Fraud Check That Rebalanced 12 Partitions 6 Times
- Size max.poll.records from measured per-record time, not defaults. Five hundred records times real-world latency must fit inside max.poll.interval.ms with margin — the arithmetic takes two minutes and prevents the entire spiral.
- Staging mocks hide production latency. A 50ms mock versus a 2s production dependency is a 40x processing difference — load-test consumers against production-like dependency latency, not mocks.
- Rebalances replay uncommitted work by design, so non-idempotent handlers convert every rebalance into duplicate side effects. Idempotency isn't optional for consumers — it's the cost of admission.
| File | Command / Code | Purpose |
|---|---|---|
| grep -E 'Revoked|Assigned|JoinGroup|SyncGroup|CommitFailed|Heartbeat failed' /va... | Why the Coordinator Revokes First and Asks Later | |
| awk '/poll-start/{s=$NF} /poll-end/{print $NF-s}' /var/log/app/consumer.log | so... | Size the Poll Loop | |
| grep -c 'Heartbeat failed' /var/log/app/consumer.log | Heartbeats vs Processing | |
| kubectl rollout restart statefulset orders-consumer | Static Membership and Cooperative Rebalancing for Calm Deplo | |
| kafka-consumer-groups --bootstrap-server $BROKERS --describe --group orders | aw... | Idempotent Handlers | |
| kafka-transactions --bootstrap-server $BROKERS --describe --all 2>/dev/null | he... | Exactly-Once With Idempotent Producers and Transactions |
Key takeaways
Common mistakes to avoid
5 patternsCatching CommitFailedException and continuing to process
Load-testing consumers against mocked dependencies
Raising max.poll.interval.ms instead of fixing batch size
Leaving auto-commit on for handlers with side effects
Reusing or randomizing group.instance.id across instances
Interview Questions on This Topic
Your consumer throws CommitFailedException. What just happened?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's Cloud. Mark it forged?
5 min read · try the examples if you haven't