Back to the journalNOTES BY FAJAR
Software Engineering4 min read

Kafka Consumer Groups: Lag, Draining, and Rebalancing

Observe partition assignments, consumer lag, and rebalancing as members join a Kafka group.

PART 4 OF 16Kafka vs Redis Streams
  1. 01Kafka vs Redis Streams: Building a Stock Ticker Twice
  2. 02Kafka Partitions and Keys: Keeping Symbols in Order
  3. 03Redis Streams Fundamentals: XADD, Entry IDs, and MAXLEN
  4. 04Kafka Consumer Groups: Lag, Draining, and RebalancingYou are here
  5. 05Redis Streams Consumer Groups: Competing Consumers and PEL
  6. 06At-Least-Once in Kafka: Retries and Dead Letter Queues
  7. 07Redis Streams: Stuck Messages, XAUTOCLAIM, and Dead Letters
  8. 08Exactly-Once in Kafka: Idempotent Producers and Transactions
  9. 09Redis Pub/Sub vs Streams: Ephemeral or Durable
  10. 10Co-Partitioning: Same Key, Same Partition, Both Topics
  11. 11Writing a Custom KafkaJS Partition Assigner for Local Joins
  12. 12Kafka ISR vs Redis Cluster: Replication and Failover
  13. 13Stateful Streams: Edge vs Level Triggers and OHLC Windows
  14. 14Building a Live Market Dashboard with SSE and Next.js
  15. 15Benchmarking Kafka vs Redis Streams: Throughput and Latency
  16. 16Kafka or Redis Streams: How to Choose
In this article 5 sections

The keys in market-updates provide ordering per symbol. A consumer group divides the topic partitions between processes. This allows more consumers to share the load and recover partitions after a member fails.

I wanted to understand ownership changes while data continued to arrive. In my stock ticker experiment, I created a backlog and observed its removal. I then added a third consumer to see which partitions moved.

Partitions, not messages, get divided

A Kafka consumer group divides a topic's partitions among its members. Each partition is owned by exactly one consumer in the group at a time. With market-updates at 6 partitions:

  • 2 consumers in the group: 3 partitions each.
  • 3 consumers: 2 each.
  • 6 consumers: 1 each. A 7th consumer has nothing left to own and sits idle. Partition count is the hard ceiling on useful parallelism.

That single owner rule is why per key order survives having multiple consumers at all. A given key, say BBCA, always hashes to the same partition, and that partition has exactly one owner at any moment. Every BBCA tick is handled by the same consumer, in the order it was produced. Ownership can move between consumers, but a partition's internal order never gets rearranged.

My test harness makes this observable by listening to KafkaJS's GROUP_JOIN event, which fires on every member each time the group settles on an assignment:

typescript
function makeConsumer(label: string, assignments: Assignments): Consumer {
  const consumer = kafka.consumer({
    groupId: GROUP,
    sessionTimeout: 10000,
    heartbeatInterval: 3000,
  });
  consumer.on(consumer.events.GROUP_JOIN, (e) => {
    assignments.set(label, e.payload.memberAssignment);
  });
  return consumer;
}

The heartbeatInterval sets the heartbeat frequency. The coordinator uses sessionTimeout to detect a member that no longer sends heartbeats. A handler that blocks too long can cause the consumer to lose its assignment, even if the process remains alive.

The printed assignment gives three of the topic's six partitions to pod-1 and three to pod-2. It prints one line per member.

Committed offsets and what lag measures

Each partition stores records with increasing offsets. The log end offset identifies the next offset after the current log end. A consumer group commits the offset from which it should resume. After successful processing, this is normally the next record offset, not the offset of the last processed record.

The gap between those two numbers is lag:

text
lag = logEndOffset - committedOffset   (per partition, summed across the topic)

Zero lag means the committed offsets have reached the partition ends used in the calculation. Stable positive lag means a backlog remains. Increasing lag means the backlog is growing. My helper calculates lag from broker metadata:

typescript
export async function lagWithAdmin(
  admin: Admin,
  groupId: string,
  topic: string,
): Promise<LagReport> {
  const [topicOffsets, groupOffsets] = await Promise.all([
    admin.fetchTopicOffsets(topic),
    admin.fetchOffsets({ groupId, topics: [topic] }),
  ]);

  const committed = new Map<number, number>();
  const entry = groupOffsets.find((g) => g.topic === topic);
  for (const p of entry?.partitions ?? []) committed.set(p.partition, Number(p.offset));

  const perPartition = topicOffsets.map((t) => {
    const high = Number(t.high);
    const low = Number(t.low);
    let c = committed.get(t.partition) ?? -1;
    if (c < 0) c = low; // group never committed here yet
    return { partition: t.partition, high, committed: c, lag: Math.max(0, high - c) };
  });

  return { total: perPartition.reduce((s, p) => s + p.lag, 0), perPartition };
}

The c < 0 -> low fallback handles a partition with no committed group offset. It uses the earliest retained offset, low, instead of zero. This measures available history under the assumption that the group will start at that offset. The consumerLag() helper opens and closes its own admin connection.

Watching lag drain

To create a visible backlog, I produced 200,000 ticks across the 6 partitions before any consumer connected. The producer sent chunks of 10,000 ticks:

typescript
const CHUNK = 10000;
for (let sent = 0; sent < TOTAL; sent += CHUNK) {
  const msgs = sim.batch(Math.min(CHUNK, TOTAL - sent)).map(tickToKafka);
  await producer.send({ topic: TOPIC, messages: msgs });
}

Then pod-1 and pod-2 join the group. A short auto commit interval makes progress visible through committed offsets. A small delay in eachMessage lets me watch the backlog decrease:

typescript
await c.run({
  autoCommitInterval: 400, // commit often so lag tracks real progress
  eachMessage: async ({ message }) => {
    const tick = tickFromKafka(message.value);
    for (const rule of SAMPLE_ALERTS) evaluate(rule, tick);
    counter.n++;
    if (counter.n % 1000 === 0) await sleep(20); // gentle throttle
  },
});

While that runs, I poll consumerLag() every 400ms and print a row per sample:

typescript
for (let i = 0; i < 25; i++) {
  const { total } = await consumerLag(GROUP, TOPIC);
  console.log(`t+${(i * 0.4).toFixed(1)}s   lag=${total}   consumed=${counter.n}`);
  if (total === 0 && counter.n >= TOTAL) break;
  await sleep(400);
}

The backlog starts with all 200,000 ticks unconsumed. Each consumer processes its 3 assigned partitions, and lag falls as committed offsets advance. The final commit can occur after counter.n reaches TOTAL. A falling lag curve shows progress, while flat or rising lag needs investigation.

Rebalancing: what happens when membership changes

Once lag has drained to zero, I add a third consumer, pod-3, to the same group and let it settle before reprinting the assignment:

typescript
const c3 = makeConsumer("pod-3", assignments);
await c3.connect();
await c3.subscribe({ topic: TOPIC, fromBeginning: true });
await c3.run({ eachMessage: async () => { counter.n++; } });
await sleep(3500);
printAssignments(assignments);

The subscribe and run calls add a member and trigger a rebalance. The group assignment changes from 3 partitions per consumer to 2 partitions per consumer. If a member disconnects or times out, its partitions move to the remaining members.

mermaid
sequenceDiagram
    participant Pod1 as pod-1
    participant Pod2 as pod-2
    participant Pod3 as pod-3
    participant GC as "Group Coordinator"
    Note over Pod1,Pod2: "Owns 3 partitions each"
    Pod3->>GC: "Join the group"
    GC->>Pod1: "Revoke partitions, pause fetching"
    GC->>Pod2: "Revoke partitions, pause fetching"
    GC->>GC: "Recompute assignment across 3 members"
    GC->>Pod1: "New assignment: 2 partitions"
    GC->>Pod2: "New assignment: 2 partitions"
    GC->>Pod3: "New assignment: 2 partitions"
    Note over Pod1,Pod3: "GROUP_JOIN fires per member, fetching resumes"

The diagram shows an "eager" rebalance. Existing members temporarily stop consumption while the group replaces the assignment. Cooperative or incremental protocols can limit the partitions that move, subject to broker and client support. Frequent rebalances interrupt useful work, so monitor membership changes and handler delays.

The ordering guarantee survives the churn

A rebalance changes partition ownership but preserves the stored record order. With unchanged partitioning, BBCA ticks continue to map to the same partition. The new consumer resumes from the committed offset, so it may repeat records that the previous consumer processed without a commit.

Redis Streams consumer groups look superficially similar from the outside. XREADGROUP also spreads work across a named group, but it has no partition concept underneath and no per key ownership at all. Any consumer can pick up any entry, including two entries for the same symbol at the same time.

Redis handles unfinished deliveries through the pending entries list. The next post compares that recovery model with Kafka partition reassignment.

FILED UNDER

NEXT IN THIS SERIESRedis Streams Consumer Groups: Competing Consumers and PEL

THANKS FOR READING

Did this resonate?

A reaction or a conversation is always welcome.

Loading reactions…

Pass it along

Loading comments...

KEEP EXPLORING

One thought leads to another.

All writing
Back to all writingOne note at a time.