Stream Processing

Rebalancing: Everyone Stops So the Partitions Can Move

When a member joins or leaves a consumer group, partitions are redistributed. In the classic protocol every member gives up everything and waits for a new assignment, so the whole group stops processing. Worse, a member that is merely slow is declared dead, which triggers a rebalance, which makes everyone slower — a loop that produces the rebalance storm.

▶ Run the lab

The question this answers

The question

A consumer restarted and my whole group stopped for 20 seconds. Why does one member leaving affect everyone?

The guarantee — the property claimed, and its scope

After a rebalance completes, every partition of the subscribed topics is assigned to exactly one member of the group. During the rebalance, no member of the group is processing. Across the boundary, records processed but not committed before revocation will be processed again by the new owner — the exclusivity guarantee holds per epoch, not across epochs.

Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.

What a node knows — observation versus inference

The coordinator knows only that a member has not heartbeated within the session timeout. It cannot tell a crashed member from a paused, GC-stalled or slow one — Crashed or Just Slow: The Distinction You Cannot Make again, now with the whole group as the blast radius. A member, symmetrically, does not know its assignment has been revoked until it next talks to the coordinator, so it may be committing work for partitions someone else already owns.

A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.

What guarantee?What does a node know?How does it work?What can fail?How does it fail?Where is coordination?What holds under failure?How does it recover?How would you know?What is the simpler thing?
rebalancegroup coordinationstop-the-worldheartbeatduplicates

Why it is stop-the-world

Partition assignment must be mutually exclusive: two members processing the same partition means duplicate work and incoherent offsets. To guarantee exclusivity while the mapping changes, the classic protocol takes the simplest safe route — everyone releases everything, then everyone gets a new assignment. No member holds a partition during the transition, so no overlap is possible.

The cost is that the entire group stops, including the members whose assignments will not change at all. A twelve-member group where one pod restarts pauses all twelve. Depending on protocol and configuration this is typically seconds; with large groups, many partitions, or slow revocation handlers it can be tens of seconds, and every second is backlog accumulating on every partition.

This is a real distributed-agreement cost, and it is exactly what a work queue does not have. A queue worker dying affects only its own in-flight messages; a group member dying affects everyone. That difference is the price of the assignment guarantee, and it is the strongest argument for a queue when ordering and replay are not required.

One member leaves; three members stoptypical
group coordinatormember 1member 2member 3 (restarting) is down over this spanmember 3 (restarting)LeaveGroup: deliveredLeaveGrouprebalance: revoke all: deliveredrebalance: revoke allrebalance: revoke all: deliveredrebalance: revoke allassignment: deliveredassignmentassignment: deliveredassignmentSIGTERM, leaves group (crash) at t=1SIGTERM, leaves groupmembership change — rebalance begins (decide) at t=2membership change — rebalance beginsrevoke p0,p1 — STOPS PROCESSING (decide) at t=3revoke p0,p1 — STOPS PROCESSINGrevoke p2,p3 — STOPS PROCESSING (decide) at t=3revoke p2,p3 — STOPS PROCESSINGnew assignment computed at t=6new assignment computedassigned p0,p1,p2 — resumes from last COMMITTED offset (recover) at t=8assigned p0,p1,p2 — resumes from last COMMITTED offsetassigned p3,p4,p5 — resumes (recover) at t=8assigned p3,p4,p5 — resumest=1time →t=8
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arrivescrashrecoverdecide
Members 1 and 2 were healthy and their work was interrupted for five units because member 3 restarted. Typical: eager revocation is the classic protocol; cooperative protocols revoke only what actually moves.

The duplicate window at the boundary

Rebalancing produces duplicates by construction, and understanding exactly where they come from removes a great deal of confusion. A member has processed records up to offset 500 on partition 2, but has only committed offset 450 — commits are periodic, not per record. The rebalance revokes partition 2, and a new owner starts from 450. Records 451 to 500 are processed a second time.

The size of that window is the distance between "processed" and "committed", which is directly under your control through commit frequency — and is precisely the trade-off in Commit Before or After: There Is No Third Option. Commit more often and the window shrinks at the cost of throughput; commit less often and the window grows.

The revocation callback is where you shrink it deliberately: on revoke, stop fetching, finish or abandon in-flight work, and commit the true last-completed offset before releasing the partition. A group that does this well has a duplicate window of nearly zero on planned restarts. A group that ignores the callback reprocesses up to a full commit interval on every deploy, forever, and blames the broker.

Note the harder case the callback cannot fix: a member that was declared dead because it was *slow* never runs its revocation handler in time. It is still processing records for a partition the coordinator has already given away, and it may commit an offset for a partition it no longer owns. Some implementations reject that commit — which is a fencing check, and it is the only thing standing between you and two members writing incoherent positions.

TriggerFrequencyCostPreventable by
Deploy / rolling restarttypicalEvery releaseOne pause per member restartedStatic membership; graceful leave; cooperative protocol
Autoscale up or downtypicalContinuous, if aggressiveA pause per scaling eventScaling cooldowns; scaling to partition count and stopping
Processing exceeds poll intervalprotocolUnder load — exactly when it hurtsPause, plus reprocessing, plus more loadSmaller batches; longer max poll interval; background heartbeat
GC pause or CPU starvationtypicalSporadicSame as above, harder to attributeHeap tuning; heartbeat on a dedicated thread
Network blip to coordinatorassumptionRareFull rebalance for a healthy memberLonger session timeout, traded against slower failure detection
Topic metadata changeprotocolRareFull rebalance across the groupNothing — it is inherent
What each rebalance trigger costs

The rebalance storm

Here is the loop, and it is worth memorising because it explains a large fraction of "our stream processor fell over and we do not know why" incidents.

A member is considered alive only if it keeps talking to the coordinator. In protocols where liveness is tied to how often the member asks for records, a member that spends too long processing a batch stops asking, is declared dead, and its partitions are given away. It then rejoins, triggering another rebalance. Meanwhile the pause has increased everyone’s backlog, so the next batch each member fetches is larger, so processing takes longer, so another member exceeds the interval. Now two members are cycling. Then three.

The signature is unmistakable once you have seen it: rebalances every few seconds, lag climbing steadily, CPU high, and completed-work throughput near zero. The group is spending all its time agreeing on who owns what and none of its time processing. It does not recover on its own, because the mechanism that would let it catch up is the mechanism being interrupted.

The fixes are all about decoupling liveness from processing duration: heartbeat on a separate thread so liveness does not depend on the handler; cap the number of records per fetch so a batch cannot take too long; raise the maximum processing interval to something safely above your p99 batch time; and hand very long work to a side queue rather than blocking the consumer loop. This is the same conclusion as Visibility Timeout: The Message Is Hidden, Not Yours reached by a different route — never let "how long the work takes" be the signal for "is this process alive".

09:14:02  Attempt to heartbeat failed: group is rebalancing
09:14:02  Revoke partitions [3, 7, 11]
09:14:09  Assigned partitions [3, 7, 11, 14]      (7s pause)
09:14:41  Member consumer-4 failed: poll interval exceeded (processed 500
          records in 312s, max.poll.interval.ms = 300000)
09:14:41  Revoke partitions [3, 7, 11, 14]
09:14:50  Assigned partitions [1, 3, 7, 11, 14]   (9s pause, MORE partitions)
09:15:29  Member consumer-2 failed: poll interval exceeded
...
Throughput: ~0.  Lag: climbing.  CPU: 85%.  No handler errors anywhere.
Each pause increases every member's batch size, which causes the next timeout.
A rebalance storm in the consumer log

Protocols that make it hurt less

Two mitigations are worth understanding structurally, because they attack different halves of the problem.

Cooperative / incremental rebalancing removes the stop-the-world property. Rather than everyone revoking everything, the assignment is computed as a diff and only the partitions that actually move are revoked. A member whose assignment is unchanged never stops. This turns a group-wide pause into a per-partition handover and is a large improvement for big groups — at the cost of a more complex protocol, sometimes two rounds, and behaviour that is harder to reason about during an incident.

Static membership removes many rebalances entirely. Each member carries a stable identity across restarts, and the coordinator holds its assignment for a grace period rather than reassigning immediately. A pod that restarts within the window gets its own partitions back and no rebalance occurs. This is close to free for rolling deploys and is the highest-value setting most teams have not turned on. The trade is that a genuinely dead member’s partitions are unowned for the whole grace period, so you have chosen slower failure detection in exchange for fewer spurious rebalances — the same dial as every failure detector in No Heartbeat Does Not Mean Dead.

Neither removes the duplicate window. That belongs to the commit point, and no rebalancing protocol can fix a decision made in Commit Before or After: There Is No Third Option.

Key points

  • A membership change redistributes partitions; in the classic protocol every member revokes everything, so the whole group pauses.
  • Records processed after the last commit are reprocessed by the new owner — the duplicate window is the gap between processed and committed.
  • Long processing makes a member look dead, which triggers a rebalance, which increases everyone’s batch size, which causes more timeouts: the rebalance storm.
  • Static membership eliminates most deploy-time rebalances; cooperative rebalancing removes the stop-the-world pause. They solve different halves.
  • Never let processing duration be the liveness signal — heartbeat independently and cap batch size.

The chain, answered

Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.

How it works
  • A member joins, leaves, fails to heartbeat, or topic metadata changes; the coordinator marks the group as rebalancing.
  • Under eager rebalancing, all members revoke all partitions and run their revocation callbacks; under cooperative rebalancing, only moving partitions are revoked.
  • Members rejoin; a leader computes the new assignment using the configured strategy.
  • The assignment is distributed and members resume from the last committed offset for each newly held partition.
  • Any records processed after that committed offset by the previous owner are processed again.
What can fail at the boundary
  • A slow member is declared dead and its partitions are reassigned while it is still processing them.
  • The revocation callback is not implemented, so uncommitted work is silently reprocessed on every rebalance.
  • The revocation callback is slow, extending the group-wide pause for everyone.
  • A member commits an offset for a partition it no longer owns.
  • Repeated rebalances prevent any member from making enough progress to stop triggering them.
How it fails — what an operator sees
  • Rebalance storm: the operator sees rebalance events every few seconds, lag climbing monotonically, CPU at 85%, and completed-record throughput near zero. There are no handler exceptions, so error-rate dashboards are clean and the incident looks like an infrastructure failure.
  • Deploy-time backlog spike: every rolling deploy produces a step increase in lag proportional to the number of pods times the pause duration. Teams learn to deploy at night and never diagnose it.
  • Duplicate wave on every restart: downstream sees a burst of duplicate records exactly at each deploy, sized by the commit interval. Handlers that are idempotent absorb it invisibly; handlers that are not produce a small, regular stream of data errors.
  • Zombie member: a GC-stalled member wakes up, processes a batch and commits for partitions another member now owns. The operator sees an offset going backwards, or a commit rejection, and two members logging work for the same partition.
  • Cascading timeout after a dependency slows: a downstream API doubles its latency, batch processing exceeds the poll interval, and the group enters a storm. The root cause is external and the symptom is entirely internal, which sends the investigation in the wrong direction.
Where coordination is required
  • A rebalance is a small consensus round: the group must agree on membership and on a single assignment. That agreement is what guarantees exclusivity and is what costs the pause.
  • The trade sits on the No Heartbeat Does Not Mean Dead dial: short timeouts detect real failures fast and generate spurious rebalances; long timeouts avoid spurious rebalances and leave partitions unowned longer after a real failure.
  • Cooperative rebalancing reduces the *scope* of the agreement rather than its existence — fewer participants change state, so fewer are blocked.
What still holds under failure
  • After any rebalance, every partition has exactly one owner; assignment completeness is preserved even when members are lost.
  • No committed progress is lost; the group resumes from committed offsets, so failure costs duplicates rather than gaps.
  • Exclusivity is not preserved across the boundary: a slow previous owner may still be processing records the new owner is also processing.
How it recovers
  • Detect: rebalance rate per group as a first-class metric, alongside lag. A group rebalancing more than once per deploy is unhealthy.
  • Contain: to break a storm, reduce batch size and raise the maximum processing interval — both reduce the trigger immediately. Adding members makes it worse.
  • Recover: let the group stabilise before scaling; verify rebalance rate returns to zero before evaluating lag.
  • Reconcile: expect and absorb the duplicate wave; verify downstream deduplication handled it rather than assuming it did.
  • Verify: rebalance rate at zero in steady state, per-partition lag draining, and every partition owned.
How you would know
  • Rebalance count and duration per group — the metric that names this failure directly, and the one most often absent.
  • Time spent processing per batch (p99) against the maximum poll interval; the ratio is the storm risk.
  • Heartbeat failures and session timeouts, separately from processing errors.
  • Duplicate-processing rate around deploys, which measures the commit-window cost.
  • Assignment churn per partition: a partition changing owner frequently is a group that is not settling.
When it helps
  • Automatic failover: a dead member’s partitions are picked up without operator action, which is the whole reason the mechanism exists.
  • Elastic scaling up to partition count, where a new member takes a share of the work with no configuration change.
  • Rolling deploys, which work at all only because reassignment is automatic.
When it hurts
  • Long or highly variable processing times, which turn liveness detection into a false-positive generator.
  • Large groups with many partitions, where the pause is long and every membership change is expensive.
  • Aggressive autoscaling, which converts a helpful mechanism into a continuous one.
  • Consumers holding expensive in-memory state per partition, where every reassignment means rebuilding that state.
Simpler alternatives
  • Static membership with a generous grace period, removing rebalances for planned restarts entirely.
  • Cooperative / incremental rebalancing, removing the group-wide pause so only moving partitions stop.
  • Manual partition assignment: pin members to partitions and skip group coordination altogether. No rebalances, no automatic failover — you own the failover.
  • A work queue, if the ordering and replay guarantees are not needed. A worker dying costs its in-flight messages, not the whole pool’s throughput.

Everyone stops so the partitions can move

Everyone stops so the partitions can move
One member joins or leaves. Watch the pause, the lag it builds, and the window in which two members have both processed the same records.
assignment before → after
p00: c1 → c1p01: c1 → c1p02: c2 → c1p03: c2 → c2p04: c3 → c2p05: c3 → c2p06: c4 → c3p07: c4 → c3
partitions that move
5/8
members stopped
all 4
lag built by the pause
80k
records reprocessed
13k
backlog after the rebalancepeak 80k
capacity after
4,500/s
catches up in
never at this capacity
duplicate window
5 s of work
handler exceptions
0
In the classic protocol every member revokes everything, so the entire group stops — including the 0 members whose assignment does not change. That is why one restarting pod costs the whole group 20 seconds, and why every rolling deploy produces a step increase in lag proportional to pods × pause. Teams learn to deploy at night and never diagnose it.
Across the boundary, records processed but not committed before revocation are processed again by the new owner: roughly 5 seconds of work, about 13k records. The exclusivity guarantee holds per epoch, not across epochs. Idempotent handlers absorb this invisibly; non-idempotent ones produce a small, regular stream of data errors sized by your commit interval and timed to your deploy schedule. And a GC-stalled member can wake up, process a batch and commit for partitions another member now owns — so an offset going backwards, or a rejected commit, is the signature of a zombie.
simplifiedThe pause is a parameter here, not a simulation of the group protocol; real pause length depends on session timeouts, poll intervals and how long members take to revoke. The drain is the engine’s queue model seeded with the lag the pause created.

What people believe, and what is true

Claim

Rebalancing only affects the consumer that left.

Reality

In the classic protocol every member revokes everything, so the entire group stops — including members whose assignment does not change.

Claim

Rebalances are rare.

Reality

Every deploy, every autoscale event, every slow batch and every network blip triggers one. In many production groups they are the dominant source of duplicate processing.

Claim

A consumer that is slow is still healthy.

Reality

The coordinator cannot see slow — it sees silence. A slow member is indistinguishable from a dead one, and gets treated as dead.

Claim

Adding consumers will help the group catch up.

Reality

Each addition triggers a rebalance. During a storm, adding members deepens the storm and adds nothing past partition count.

Go deeper

Only the levels this lesson can honestly fill — a missing level is a claim nobody had.

Overview

When a consumer joins or leaves, the group redistributes partitions and pauses while it does. Work processed but not committed gets done again by the new owner.

Practical

Turn on static membership so deploys stop causing rebalances. Use cooperative rebalancing if available. Cap records per fetch and set the maximum processing interval above p99 batch time. Commit in the revocation callback. Alert on rebalance rate, not only on lag.

Advanced

The storm is a positive feedback loop with the same structure as One Retry per Tier Is Not One Retry — It Multiplies: the system’s response to overload consumes the capacity needed to recover from it. Each rebalance costs processing time, which increases backlog, which increases batch size, which increases the chance of the next timeout. The loop has no stable fixed point under load, which is why it does not self-heal and why adding capacity makes it worse. Breaking it means reducing the *trigger* rather than adding resources — smaller batches, longer intervals, liveness decoupled from work — which is the general shape of every fix for a congestion collapse.

Apply it

Build it, then break it
  • 🔧 Set the maximum poll interval below your p99 batch time and watch the group enter a storm. Then fix it with batch size alone and confirm the storm ends.
  • 🔧 Implement a revocation callback that commits the true last-completed offset per partition, and measure the reduction in duplicates across a deploy.
Reason about this
  • A downstream API doubles its latency and your stream processor stops entirely with no errors. Trace the causal chain and name the two settings you would change first.
  • Every rolling deploy produces a lag step and a burst of duplicate rows downstream. Give the two changes that address each symptom separately.
Interview questions
  • 💬 One consumer restarts and the whole group stops. Why?
  • 💬 Describe a rebalance storm: the trigger, the loop, and why adding consumers makes it worse.
  • 💬 Where exactly do the duplicates at a rebalance boundary come from, and how do you shrink the window?