Consumer Groups & Rebalancing
The idea in one sentence
Section titled “The idea in one sentence”A consumer group is a set of consumers that share a topic’s partitions between them — each partition is handled by exactly one consumer in the group — and rebalancing is the process of reassigning partitions when membership changes.
Groups divide the work
Section titled “Groups divide the work”Every consumer declares a groupId. Kafka gives each partition to exactly one consumer within a group, so a topic with 6 partitions can be processed by up to 6 consumers in parallel. Add a 7th consumer and it sits idle — partitions, not consumers, are the ceiling on parallelism.
Different groups are independent: analytics and billing each get their own full copy of the stream, tracking their own offsets. Within one group, the partitions are divided.
You can inspect a group, its members, and its lag from the CLI:
# Show members, assigned partitions, and lag for a groupkafka-consumer-groups.sh --bootstrap-server localhost:9092 \ --describe --group billingflowchart TB
subgraph t["Topic orders: 3 partitions"]
p0["orders-0"]
p1["orders-1"]
p2["orders-2"]
end
subgraph g1["Group billing"]
c1["consumer A"]
c2["consumer B"]
end
p0 --> c1
p1 --> c1
p2 --> c2
t --> g2["Group analytics (own offsets, full copy)"] Rebalancing
Section titled “Rebalancing”When a consumer joins, leaves, or dies — or partitions are added — the group must rebalance: redistribute partitions among the current members. Under the classic protocol this is a stop-the-world step: every member gives up all of its partitions and waits for a brand-new assignment, even members whose own assignment doesn’t end up changing. On large groups that pause hurts.
Correcting the record: KIP-848 and JavaScript clients
Section titled “Correcting the record: KIP-848 and JavaScript clients”Kafka 4.0 shipped the next-generation consumer rebalance protocol (KIP-848) as generally available. It moves assignment logic to the broker-side group coordinator and makes rebalances incremental — only the partitions that actually move are revoked, and the rest keep processing.
# section: consumer# Client opt-in: the new consumer group protocol (KIP-848), GA since Kafka 4.0.# @confluentinc/kafka-javascript takes this as a TOP-LEVEL rdkafka key, NOT nested# under kafkaJS:{} -- see "Reading AI-generated code" below for the exact error# you get if you put it in the wrong place.group.protocol=consumer# The classic (older) protocol is still available, and is still the CLIENT default:# group.protocol=classicProving group.protocol=consumer with a real client and a real broker
Section titled “Proving group.protocol=consumer with a real client and a real broker”This ran against apache/kafka:4.3.1 (single-broker KRaft) with @confluentinc/[email protected]:
// @runimport pkg from '@confluentinc/kafka-javascript';const { KafkaJS } = pkg;
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');const prefix = process.env.TOPIC_PREFIX ?? '';const topic = `${prefix}orders`;
const kafka = new KafkaJS.Kafka({ kafkaJS: { brokers } });
const admin = kafka.admin();await admin.connect();await admin.createTopics({ topics: [{ topic, numPartitions: 1 }] }).catch(() => {});await admin.disconnect();
const consumer = kafka.consumer({ 'group.protocol': 'consumer', // KIP-848 opt-in: top-level key, not under kafkaJS:{} kafkaJS: { groupId: `${prefix}kip848-group`, fromBeginning: true },});await consumer.connect();await consumer.subscribe({ topic });
const producer = kafka.producer();
let done = false;const run = consumer.run({ eachMessage: async ({ message }) => { if (done) return; done = true; console.log('consumed with group.protocol=consumer:', message.value?.toString()); await consumer.disconnect(); await producer.disconnect().catch(() => {}); process.exit(0); },});
await new Promise((r) => setTimeout(r, 2000)); // let JoinGroup/assignment settle firstawait producer.connect();await producer.send({ topic, messages: [{ value: 'hello-kip848' }] });
void run;setTimeout(() => { console.error('timeout'); process.exit(1); }, 25000);The real, trimmed CLI output — kafka-consumer-groups.sh has no --describe protocol column, but --list --type does, and it is the honest way to see which protocol a group actually negotiated:
$ kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list --type classic,consumerGROUP TYPEkip848-group2 Consumerclassic-protocol-group ClassicA group that never set group.protocol (or set it to classic) shows up as Classic; a group that set group.protocol: 'consumer' shows up as Consumer — proven directly, not assumed.
Classic (eager) vs KIP-848 (incremental): measured, not assumed
Section titled “Classic (eager) vs KIP-848 (incremental): measured, not assumed”Two consumers (A, B) were started in a group on a 6-partition topic, a producer sent a message every 30ms, and a third consumer (C) joined and then left. Every message’s exact processing timestamp was logged. On this Mac, local broker, tiny workload:
Classic protocol (KafkaJS, round-robin, always eager): when C left the group, both remaining members re-joined — including B, whose final partition assignment ([1,3,5]) was identical before and after:
[A] REBALANCING t=...286034[A] GROUP_JOIN t=...286072 memberAssignment={"orders":[0,2,4]} duration=38[B] REBALANCING t=...286066[B] GROUP_JOIN t=...286072 memberAssignment={"orders":[1,3,5]} duration=6
# gap between the LAST message consumed before REBALANCING and the FIRST after:A: 45ms stop-the-world pauseB: 14ms stop-the-world pause (even though B's assignment never changed!)KIP-848 consumer protocol (@confluentinc/kafka-javascript, group.protocol=consumer): across the same two membership changes (a member joining, then leaving), partitions 0 and 1 stayed on consumer A uninterrupted for the entire ~18-second run — no gap larger than ordinary producer jitter (~150ms, seen throughout, not just at the rebalance) — while partitions 2–5 moved between the other members one at a time:
A partition 0: consumed continuously from t=...464489 to t=...482256 (never revoked)A partition 1: consumed continuously from t=...464520 to t=...482286 (never revoked)A partition 5: revoked ~469171 -> picked up by C at ~470366 (~1s later, matches group.consumer.assignment.interval.ms=1000, the broker default)A partition 2: revoked ~474155 -> picked up by C at ~475518That is the concrete difference “incremental” buys you: under classic, every member stops and rejoins on any membership change; under KIP-848’s consumer protocol, a member whose partitions aren’t moving keeps consuming through the rebalance.
KafkaJS’s classic protocol, honestly: there is no cooperative-sticky
Section titled “KafkaJS’s classic protocol, honestly: there is no cooperative-sticky”It would be convenient if “classic vs KIP-848” mapped to “eager vs cooperative” inside a single client, letting you soften classic’s stop-the-world with a cooperative-sticky assignor before ever touching group.protocol. KafkaJS 2.2.4 does not offer that option. Its entire assignor registry is one assignor:
$ node -e "console.log(require('kafkajs').PartitionAssigners)"{ roundRobin: [Function: assigner] }kafka-consumer-groups.sh --describe and this course’s own probe both confirm every KafkaJS group negotiates groupProtocol: "RoundRobinAssigner" — eager, stop-the-world, full-group rejoin on every membership change, full stop. If you need cooperative-sticky-style incremental rebalancing on a JavaScript client today, the real options are: (a) accept classic’s eager rebalances (fine for small groups), or (b) move to @confluentinc/kafka-javascript with group.protocol: 'consumer' (KIP-848, broker-driven, incremental — proven above). There is no cooperative-sticky assignor to configure inside KafkaJS; claiming otherwise is exactly the kind of confident-but-wrong detail AI code generation produces from stale training data.
session.timeout.ms / heartbeat.interval.ms move to the broker under KIP-848
Section titled “session.timeout.ms / heartbeat.interval.ms move to the broker under KIP-848”Under the classic protocol, sessionTimeout and heartbeatInterval are client-side KafkaJS consumer options (defaults 30000 / 3000ms) — the client decides its own liveness cadence, and a network partition or a busy client can lie about being alive for the whole session timeout. KIP-848 moves this to broker-controlled config, verified directly against a running 4.3.1 broker:
/opt/kafka/bin/kafka-configs.sh --bootstrap-server $BOOTSTRAP \ --describe --entity-type brokers --entity-name 1 --all \ | grep -E "group\.consumer\.(session|heartbeat)" || truegroup.consumer.heartbeat.interval.ms=5000 sensitive=false synonyms={DEFAULT_CONFIG:...}group.consumer.session.timeout.ms=45000 sensitive=false synonyms={DEFAULT_CONFIG:...}group.consumer.min.session.timeout.ms=45000group.consumer.max.session.timeout.ms=60000Those are real key names, verified against the running broker, not guessed from a KIP wiki page: group.consumer.session.timeout.ms (default 45s, not classic’s 30s) and group.consumer.heartbeat.interval.ms (default 5s, not classic’s 3s). A client using the consumer protocol can still request a session timeout inside the broker’s configured min/max window, but the broker, not the client, is the source of truth for how long a silent member is tolerated.
Which protocol is the default in Kafka 4.3.1?
Section titled “Which protocol is the default in Kafka 4.3.1?”Two different defaults, verified separately, and easy to conflate:
- Client-side default:
classic. A consumer that never setsgroup.protocolnegotiatesClassic— proven above (default-protocol-groupshowed up asClassicin--list --typewith nogroup.protocolkey set at all), and it matcheslibrdkafka’s own release note (“It defaults toclassicif not provided”). - Broker-side: all three protocols are enabled by default, so a client that does opt in succeeds without any broker reconfiguration:
group.coordinator.rebalance.protocols=classic,consumer,share synonyms={STATIC_BROKER_CONFIG:...=classic,consumer,share, DEFAULT_CONFIG:...=classic,consumer,streams}One more honest finding this course didn’t have before: on this same broker, group.coordinator.rebalance.protocols itself logs as deprecated in 4.3.1 —
WARN The config `group.coordinator.rebalance.protocols` is deprecated and will beremoved in Kafka 5.0. ... all protocols will always be enabled and controlled solelyby feature versions (group.version, streams.version, share.version) via kafka-features.sh.So: nothing to configure on the broker to make KIP-848 available in 4.3.1 (it already is), but the client still has to ask for it, and by default it doesn’t.
Reading AI-generated code
Section titled “Reading AI-generated code”🐛 Spot the bug
import pkg from '@confluentinc/kafka-javascript';const { KafkaJS } = pkg;
const kafka = new KafkaJS.Kafka({ kafkaJS: { brokers: ['localhost:9092'] } });
const consumer = kafka.consumer({kafkaJS: { groupId: 'orders-group', groupProtocol: 'consumer', // "opting in" to KIP-848},});
await consumer.connect();Show the answer
The exact, real error thrown at consumer.connect():
KafkaJSError: The 'groupProtocol' property is not supported. at #kafkaJSToConsumerConfig (.../kafkajs/_consumer.js:502:13) ... code: -186, type: 'ERR__INVALID_ARG'@confluentinc/kafka-javascript exposes two config surfaces: a kafkaJS: {...} block that mimics KafkaJS’s own option names (groupId, sessionTimeout, autoCommit, …), and the raw librdkafka config keys as top-level properties on the same object ('group.protocol', 'max.in.flight', …). group.protocol is a librdkafka-native key with no kafkaJS:{} equivalent — the compatibility layer explicitly rejects it if you nest it. This is a realistic AI-generated bug: an assistant that has seen a hundred kafkaJS: { ... } blocks reasonably (but wrongly) guesses every new option belongs there. The fix is the one-line move shown earlier in this page: kafka.consumer({ 'group.protocol': 'consumer', kafkaJS: { groupId: '...' } }).
Mental Model
Section titled “Mental Model”Think of a consumer group’s rebalance as “who owns the lease on each partition, and how much do we disrupt existing leases to update it.” Classic protocol: every lease is torn up and reissued on any membership change, because assignment is computed client-side by whichever member becomes group leader, and the safest way to ship a client-computed assignment is to have everyone stop first. KIP-848 moves assignment computation to the broker’s group coordinator, which already knows the full membership and can compute a diff — so it only revokes what actually needs to move, and tells each member individually. Same goal (one partition, one owner), fundamentally different mechanism for getting there.
Trade-offs
Section titled “Trade-offs”- Classic protocol — simple, works with any client that speaks it (including unmaintained KafkaJS), but every rebalance is stop-the-world for the whole group regardless of how small the actual membership change was. Fine for small, stable groups; painful for large groups with frequent scaling/deploys.
- KIP-848 consumer protocol — incremental, broker-driven, meaningfully shorter disruption windows for unaffected partitions (proven above) — but it requires a client that implements it (KafkaJS does not), a Kafka 4.0+ broker, and it is a newer code path with less production mileage than classic.
- Broker-side session config (KIP-848) vs client-side (
classic) — broker-side means one place to tune liveness detection for every member instead of trusting each client’s local timer, but it also means the broker’sgroup.consumer.min/max.session.timeout.mswindow constrains what a client can even ask for.
Common Misconceptions
Section titled “Common Misconceptions”- “KIP-848 isn’t available for JavaScript yet.” Wrong as of
librdkafka2.12.0 (Oct 2025) via@confluentinc/kafka-javascript— proven above. It’s just not the default, and KafkaJS specifically never gained it. - “KafkaJS has a cooperative-sticky assignor, I just need to configure it.” No — verified directly against the installed package: KafkaJS 2.2.4 ships exactly one assignor, round-robin. There is nothing to configure.
- “More consumers in a group always means more parallelism.” No — partition count is the ceiling; extra consumers beyond the partition count sit fully idle (see the quiz below).
- “
kafka-consumer-groups.sh --describeshows me the group’s protocol.” It doesn’t have a protocol column — use--list --type classic,consumerinstead, as proven above.