How Kafka Consumer Group Rebalancing Actually Works: The Two Clocks (Part 1 of 2)
This is Part 1 of a two-part series on Kafka consumer group rebalancing. Part 1 (this post) builds the mental model - the two clocks, the coordinator, the JoinGroup/SyncGroup handshake, eager vs cooperative, and the new KIP-848 protocol. Part 2: Debugging Kafka Consumer Group Rebalancing puts that model to work on nine real production failures. Read them in order.
A consumer group starts flapping at 3am. Lag climbs, resets, climbs again - a
sawtooth crawling up the dashboard. On call, you do what everyone does: reach for
max.poll.interval.ms, double it, ship. Sometimes the sawtooth flattens and you
go back to bed. Just as often it does nothing, or the next incident is worse -
because the setting you changed had no connection to the thing that actually
fired.
That gap, between the config people reach for and the mechanism that broke, is what this post closes. Rebalancing gets learned as folklore - a bag of timeouts you tune by superstition - when underneath it is a small, legible machine.
Here is the claim this whole post is built to earn: a rebalance is never a mystery. It is always one of exactly four things -
- A member's session/heartbeat clock expired (the coordinator thinks the process died).
- A member's poll clock expired (the process is alive but not calling
poll()fast enough, so it removes itself). - A member intentionally joined or left (scale up, scale down, deploy).
- The partition count of a subscribed topic changed.
Two of those four are clocks. Almost every painful, recurring, "why is this group stuck rebalancing every five minutes" incident is a clock firing, and the fix depends entirely on which clock. So we are going to build the machine from the bottom up - the coordinator, the handshake, the two clocks, the assignors, and the new protocol - and by the end you'll be able to look at a rebalance and name its cause before you open a dashboard. The debugging playbook is Part 2; this part is the mental model it stands on.
The primer: a consumer group is a lease, not a connection
Start with what a consumer group is, because the usual one-liner ("it lets multiple consumers share a topic") hides the mechanism that matters.
A topic is split into partitions - the unit of parallelism and ordering. A
consumer group is a set of consumer instances that share a group.id and
agree to divide those partitions among themselves so that each partition is
owned by exactly one member of the group at a time. That exclusivity is the
entire point. It is what lets Kafka promise in-order processing per partition and
lets you scale out by adding members.
But "owned by exactly one member at a time" is a distributed-systems problem in disguise. Members are on different machines. They crash, GC-pause, get network-partitioned, and deploy in rolling waves. Nobody has a global view. So ownership can't be a static config - it has to be a lease that is granted, renewed, and revoked. Rebalancing is the protocol that re-grants those leases whenever the set of members or partitions changes.
Because a lease has to be renewable, every member is on a heartbeat. And because "the process is up" is not the same as "the process is making progress," Kafka watches two different things about each member. Those are the two clocks.
The two clocks (the one idea to keep)
This is the load-bearing section. Everything after it is detail.
Kafka is trying to answer two different questions about every member, and it uses two independent timers to answer them:
The reason this split exists is subtle and worth sitting with. In early Kafka the heartbeat was the poll loop - you were alive only if you polled. That coupled two unrelated things: a slow-but-healthy consumer (say, one doing a 30-second database write per batch) looked identical to a dead one, so it got kicked. So the heartbeat was moved to its own background thread. Now the heartbeat says "my process is running and my network is fine" on its own schedule, completely independent of how long your record processing takes.
That decoupling created the second clock. If heartbeats alone kept you in the
group, a consumer that polled once and then hung forever in a bad while loop
would keep its partitions and stall them indefinitely, because its heartbeat
thread is still cheerfully beating. So Kafka added max.poll.interval.ms: a
promise that you will come back and call poll() within this bound. Miss it, and
the client library itself sends a LeaveGroup - the member fires the poll clock
on itself.
The two clocks fail in opposite directions and take opposite fixes. The session clock fires when the process stops (crash, GC, network) - you fix it by making the process healthier or the timeout more forgiving. The poll clock fires when the process is healthy but slow - you fix it by processing less per poll or faster, not by touching the session timeout. Reach for the wrong knob and you either mask a real crash or make a slow consumer hold its partitions even longer while stalled.
Here are the knobs, what clock each belongs to, and their defaults - memorize the grouping, not the numbers:
| Config | Default | Clock | What it really controls |
|---|---|---|---|
heartbeat.interval.ms | 3s | Session | How often the background thread pings the coordinator. |
session.timeout.ms | 45s | Session | How long the coordinator waits, hearing nothing, before declaring the member dead. Must sit inside the broker's group.min/max.session.timeout.ms. |
max.poll.interval.ms | 5 min | Poll | Max wall-clock time allowed between two poll() calls before the member removes itself. |
max.poll.records | 500 | Poll (indirect) | How many records one poll() hands you to process - the biggest lever on whether you make the poll deadline. |
The relationship the docs bury: heartbeat.interval.ms should be no more than
about 1/3 of session.timeout.ms, so a member gets two or three chances to
heartbeat before the coordinator gives up on a single dropped packet. And
max.poll.interval.ms is essentially independent of the other two - it is a
statement about your code's throughput, not your network.
The coordinator: where the leases are held
A rebalance needs a single arbiter - something that decides, authoritatively, who is in the group and what generation we're on. That arbiter is the group coordinator, and it is not a special server. It is just one of your regular brokers, chosen deterministically.
Kafka stores consumer offsets in an internal compacted topic called
__consumer_offsets, which has 50 partitions by default. Your group is mapped to
one of them by hash(group.id) % 50, and the broker that leads that
partition is your group's coordinator. That's the whole election: no consensus
round, just "whoever owns your slice of the offsets topic runs your group." When
a consumer starts, it sends a FindCoordinator request to any broker, learns
which broker owns its group, and talks to that one for everything after.
This has two consequences worth banking for debugging:
- If that broker restarts or the partition leader moves, your coordinator
moves. Clients see a
NotCoordinator/CoordinatorNotAvailableblip, re-runFindCoordinator, and reconnect - usually with a short rebalance. A rolling broker restart therefore ripples through consumer groups even though no consumer changed. - The coordinator's memory of your group is the offsets topic. Group metadata (members, generation, committed offsets) is persisted there, so a coordinator that moves can reconstruct the group from the log.
The classic handshake: JoinGroup, then SyncGroup
Now the actual rebalance dance, in the "classic" protocol that every consumer used before Kafka 4.0 and that most still use today. A rebalance is a two-phase handshake orchestrated by the coordinator, and the group moves through a small state machine while it happens: Stable → PreparingRebalance → CompletingRebalance → Stable.
- TriggerSomething changes membershipA member joins, leaves, or a clock fires. The coordinator moves the group to PreparingRebalance and stops it being Stable.
- Phase 1 · JoinGroupAll members send their subscription and rejoinEach member sends what topics it wants. The coordinator waits up to the rebalance timeout (= max.poll.interval.ms) for stragglers, bumps the generation id, and picks ONE member as the group leader.
- CoordinatorElect a leader, return the full member list to itThe leader is an ordinary consumer. The coordinator itself does not compute the assignment - it just hands the leader everyone's subscriptions.
- Leader (client-side)Run the assignor, produce partition → member mapThe elected member runs the configured ConsumerPartitionAssignor (Range, RoundRobin, Sticky, CooperativeSticky) locally and decides who gets what.
- Phase 2 · SyncGroupEveryone asks for their assignment; leader submits the mapAll members send SyncGroup. The leader's request carries the full assignment; the coordinator stores it and answers each member with just its slice. Group → CompletingRebalance → Stable.
- MembersonPartitionsAssigned fires; consumption resumesEach member now owns its new partitions at generation N+1 and starts fetching. Any member still acting on generation N is fenced.
Three details in that flow explain a surprising amount of real-world behavior:
- The assignment is computed on a client, not the broker. In the classic protocol the coordinator is a dumb relay; an elected member runs the assignment algorithm. This is why all members must agree on an assignor, and why a bad custom assignor can wedge a whole group.
- The generation id is a fence. Every completed rebalance bumps a monotonic
generationId. Any request carrying a stale generation is rejected withIllegalGeneration, and that member must rejoin. This is how Kafka guarantees two members never both believe they own the same partition across a rebalance - the loser's offset commit simply fails. (That failure has a famous error message; it stars in Part 2.) - The coordinator waits for the whole group at JoinGroup, up to the rebalance
timeout, which for consumers equals
max.poll.interval.ms. So a single slow member can hold the entire group inPreparingRebalancefor minutes. One member's poll clock becomes everyone's stall.
Eager vs cooperative: the difference between a pause and a nudge
Now the single most important operational choice in the classic protocol, and the one most teams get wrong by inertia: how much of the group has to stop while the leases are re-cut.
Eager rebalancing: stop the world
The original protocol is eager. The rule is brutally simple: before a member rejoins at JoinGroup, it must revoke every partition it owns. All members, all partitions, released at once - then reassigned, then re-acquired. For the whole JoinGroup/SyncGroup window, nobody in the group is consuming anything, even the members whose partitions were never going to move.
RangeAssignor (the historical default), RoundRobinAssignor, and the original
StickyAssignor all run eager. That trailing word "stop the world" is not
hyperbole - it is literally what the assignor's RebalanceProtocol.EAGER
contract requires, straight from the Kafka source: "a consumer to always revoke
all its owned partitions before participating in a rebalance event."
Cooperative rebalancing: keep what you can, move only what must move
The cooperative protocol (Kafka's incremental cooperative rebalancing, via
CooperativeStickyAssignor) changes the rule to: keep your partitions through
the rebalance; only give up the specific ones that need to move to someone
else. It does this with two lightweight rebalances instead of one heavy one:
- First rebalance. Everyone rejoins but keeps consuming. The leader computes the new desired assignment and, by comparing it to the current one, works out exactly which partitions must change hands. Those - and only those - are marked for revocation.
- Revoke the movers. The members losing those specific partitions call
onPartitionsRevokedfor just that subset. - Second rebalance. A follow-up rebalance hands the now-free partitions to their new owners.
The partitions that weren't moving never stopped. On a 100-partition group where one new member joins and needs ~10 partitions, eager pauses all 100; cooperative pauses ~10 and leaves 90 flowing.
You cannot flip a running group from eager to cooperative in one step. Every
member must understand cooperative before any member starts using it, so the
supported path is a two-deploy rollout: first deploy the consumers with both
assignors configured - [RangeAssignor, CooperativeStickyAssignor] - so the
group still negotiates eager while every member gains cooperative support; then,
once all members are on that build, deploy again with CooperativeStickyAssignor
alone. Skip the intermediate step and you can wedge the group. Modern Kafka ships
[RangeAssignor, CooperativeStickyAssignor] as the default partition.assignment.strategy
precisely to make that first hop the out-of-the-box state.
The assignors themselves
Which member gets which partition is the assignor's job. The ones you'll meet:
- RangeAssignor - default in the classic protocol. Assigns per topic: lays each topic's partitions in a row and cuts contiguous ranges to members. Simple, but co-locates partition 0 of every topic on the same member (handy for joins across topics) and skews badly when partition counts aren't divisible by member count.
- RoundRobinAssignor - deals all partitions across all topics round-robin. More even than range, but reshuffles heavily on membership change.
- StickyAssignor - aims for balance and minimal movement between rebalances, but still eager (revokes all, then tries to give most back).
- CooperativeStickyAssignor - the sticky logic, but cooperative: keeps partitions in place and moves the minimum. The modern default choice.
The word "sticky" is the tell: a sticky assignor tries to give you back the same partitions you had, so you keep your local state (RocksDB stores, in-flight buffers, warm caches) instead of rebuilding it every rebalance. On a stateful consumer that difference is enormous.
Static membership: making restarts free
There's a whole class of rebalances that shouldn't have to happen at all: a pod restarts during a rolling deploy, is gone for four seconds, and comes back as "the same" consumer - yet the classic protocol treats the restarted process as a brand-new member and rebalances twice (once when it leaves, once when it rejoins).
Static membership fixes this. Give each consumer a stable
group.instance.id and it becomes a static member: the coordinator remembers
that identity across disconnects. When a static member drops and reconnects
within session.timeout.ms, the coordinator recognizes it, hands back the same
assignment, and does not rebalance at all.
The trade you're making: a static member that truly dies isn't noticed until its (deliberately larger) session timeout expires, so genuine failures take longer to detect. Static membership is a bet that your restarts vastly outnumber your crashes - which, for a service on a rolling-deploy pipeline, they do.
The new protocol: KIP-848 moves the brains to the broker
Everything above is the classic protocol, and it has one structural flaw that no
amount of tuning removes: the assignment is computed by a client, in a
synchronous, stop-and-wait handshake where the whole group moves in lockstep and
one slow member blocks everyone. Kafka 4.0 ships the replacement - the new
consumer group protocol from KIP-848, enabled with group.protocol=consumer.
Two things change fundamentally:
1. The assignment moves server-side. The broker (coordinator) now computes the assignment itself, instead of relaying to an elected client leader. There is no more JoinGroup/SyncGroup election; there is no leader to run a buggy assignor.
2. Rebalancing becomes fully asynchronous and heartbeat-driven. There is a
single RPC, ConsumerGroupHeartbeat, that does everything: it carries liveness,
it carries the member's current assignment, and the broker's response carries the
target assignment the member should move toward. Members reconcile toward that
target incrementally and independently, acknowledging each step in their next
heartbeat. No global barrier. No stop-the-world.
The mechanism that makes this safe is epochs. Instead of one generation id for
the whole group, there's a group epoch (bumped on every membership or
subscription change), a target-assignment epoch, and a member epoch per
member. A member reconciles until its epoch matches the group's; if it ever acts
on a stale epoch it gets a FencedMemberEpoch error and rejoins. Epochs are the
new, finer-grained fence that replaces the coarse generation id.
- BrokerMembership changes → group epoch bumpsThe coordinator recomputes the target assignment server-side and bumps the group epoch. No election, no client leader.
- HeartbeatMember learns its new target assignmentThe next ConsumerGroupHeartbeat response tells the member which partitions it should end up owning - not necessarily what it owns now.
- Member (reconcile)Resolve topic IDs, commit offsets, revoke, assignThe member reconciles gradually: resolve topic names from metadata, auto-commit if enabled, call onPartitionsRevoked for what it must give up, onPartitionsAssigned for what it gains - all while still heartbeating.
- Member (ack)Acknowledge the reconciled assignment via heartbeatThe member reports back the subset it actually reconciled. Its member epoch advances toward the group epoch.
- BrokerEpochs converge → group is StableWhen every member's epoch matches the group epoch, reconciliation is done. Server group states: Empty → Assigning → Reconciling → Stable.
For operators, three practical shifts fall out of this design:
session.timeout.msandheartbeat.interval.msbecome broker configs. In the new protocol they're controlled server-side (group.consumer.session.timeout.ms,group.consumer.heartbeat.interval.ms), not by each client. The session clock still exists - it just moved.- The poll clock does not go away.
max.poll.interval.msis still enforced client-side, because the whole point of it - "are you actually callingpoll()?" - is a property only the client can observe. The poll clock is the one that survives every protocol change, which is exactly why it's the one you end up debugging most. - Rolling deploys and scale-ups stop being stop-the-world by default. Because reconciliation is incremental and per-member, the new protocol behaves like cooperative rebalancing without you configuring an assignor for it. Cooperative became the floor, not an opt-in.
The classic protocol isn't going away tomorrow - most running clusters are still on it, and the two interoperate during migration - but the direction is clear: the brains move to the broker, the handshake dissolves into heartbeats, and the coarse global generation becomes fine-grained per-member epochs. What doesn't change is the framing you came here for.
The throughline
Read the whole machine back to front and it collapses to something you can hold in one hand. A consumer group is a set of partition leases. Leases must be renewable, so every member is watched by two clocks: a session/heartbeat clock asking is the process alive? and a poll clock asking is it keeping up? A rebalance is what re-cuts the leases, and it is triggered by exactly four things - one of those two clocks firing, an intentional join/leave, or a partition-count change.
Everything else is implementation of that idea. The coordinator is just the broker that holds your leases. The JoinGroup/SyncGroup handshake, generations, eager vs cooperative, sticky assignors, static membership, and the KIP-848 epochs are all answers to how do we re-cut leases without two members ever thinking they own the same partition, and without stopping more of the group than we have to. The protocols evolved; the two clocks did not.
That's the payoff. When a group starts rebalancing in a loop at 3am, you don't reach for a random config. You ask one question - which clock fired? - and the answer tells you which knob to touch. Walking that question through nine real production failures, with the exact symptoms, log lines, and fixes for each, is Part 2: Debugging Kafka Consumer Group Rebalancing.
If you want the ground underneath this - how partitions, offsets, delivery semantics, and streaming ingestion actually fit together - that's the ingestion and transport curriculum, which builds the streaming model this post assumes from the first partition up.