Siddhant Deval
Siddhant Deval
backend15 min read

Consumer Patterns: Groups, Lag, Backpressure, and Rebalancing

Consumer lag is a diagnostic symptom, not a problem to solve by adding more consumers. This article teaches how to diagnose lag root causes — slow consumer logic, downstream DB backpressure, GC pause cascades, and rebalance storms — and applies the correct fix for each. It also covers cooperative sticky rebalancing, manual offset commit patterns, and RabbitMQ prefetch-based backpressure.

Consumer Patterns: Groups, Lag, Backpressure, and Rebalancing

The on-call alert fires at 11 p.m.: consumer lag on orders.created has crossed 500,000 messages and the SLO for fulfillment dispatch is 60 seconds from order creation. The engineer opens the consumer group dashboard, sees 4 running instances, and scales to 12. Lag continues growing. The topic has 4 partitions. The extra 8 instances are idle — Kafka has nothing to assign them. The actual cause is a downstream PostgreSQL write that degraded from 5ms to 340ms two hours ago when a batch job saturated the primary's I/O. The fix is not more consumers; it is fixing the database or applying backpressure at the consumer level.

Consumer lag is a symptom, not a problem. The root cause determines the correct fix — and adding consumer instances is almost never it.

Architectural Note

Series positioning: This is Part 6 of Distributed Messaging Systems. Part 5 established delivery guarantees and idempotent consumption. This article focuses on the consumer lifecycle: partition assignment, lag diagnosis, backpressure, and rebalance mechanics. The prerequisite mental model for partition-based parallelism is in Scaling: Partitioning, Sharding, and Replication.


1. Consumer Group Parallelism: The Hard Cap

1.1 Partition Assignment is One-to-One

A Kafka partition can be assigned to at most one consumer instance within a consumer group at any point in time. Parallelism is bounded by partition count — this is not a configuration; it is the data model.

TYPESCRIPT
// ❌ Scaling beyond partition count — consumers 7–12 are idle, wasting memory
const consumer = kafka.consumer({ groupId: 'fulfillment-service' })
// Topic has 6 partitions. Running 12 consumer instances: 6 active, 6 idle.
// Additional instances cannot help. They simply join the group and get no assignment.

// ✅ Correct scale-out: consumers = partitions (or less)
// If you need more parallelism: increase partition count first, then scale consumers
Consumers Partitions Active Idle Parallelism
3 6 3 0 3 (each handles 2 partitions)
6 6 6 0 6 (maximum)
12 6 6 6 6 (no gain — 6 idle)
6 12 6 0 6 (each handles 2 partitions, headroom to scale)
Pro Tip & Optimization

Provision partitions for your expected peak consumer count, not your current one. Partition counts can be increased but never decreased — plan 2–4× headroom. A topic you create today with 6 partitions is capped at 6-way parallelism forever unless you recreate it.

1.2 Partition Count and Ordering

Increasing partition count trades ordering scope for parallelism:

TYPESCRIPT
// ❌ Naive partition count increase breaks per-entity ordering
// Before: 4 partitions → all events for order-42 always land on partition 1 (hash("order-42") % 4 = 1)
// After:  8 partitions → hash("order-42") % 8 = 5 — different partition, different consumer
// Existing consumers processing order-42 events are now split across partition 1 AND 5
// → Out-of-order processing possible during the transition window

// ✅ Key-based partitioning preserves ordering within an entity's lifecycle
await producer.send({
  topic: 'orders.created',
  messages: [{
    key:   order.id,           // deterministic: same orderId → same partition
    value: JSON.stringify(order),
  }]
})
// Adding partitions: plan a cutover window where in-flight orders drain before the split takes effect

2. Diagnosing Consumer Lag

2.1 The Four Root Causes

Consumer lag (offset delta between the log head and the consumer's committed offset) has four distinct root causes, each requiring a different fix:

Root Cause Lag Pattern Symptom Fix
Slow consumer logic Steady, linear growth CPU high, processing time per message rising Optimize handler (batching, caching, async I/O)
Downstream backpressure Spiky, correlated with downstream metrics DB/API latency rising, consumer threads blocked Circuit breaker, reduce max.poll.records, fix downstream
Rebalance storms Sawtooth pattern (lag drops then spikes repeatedly) Frequent group rebalances in broker logs Increase max.poll.interval.ms, fix poll() starvation
Insufficient partition count Lag grows despite healthy consumers All partitions assigned, processing rate < produce rate Increase partitions, scale consumers to match

2.2 Measuring Lag

BASH
# Kafka consumer group lag per partition
kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --describe \
  --group fulfillment-service

# Output:
# GROUP               TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG  CONSUMER-ID
# fulfillment-service orders.created  0          4,120,441       4,122,831       2390 consumer-1
# fulfillment-service orders.created  1          4,118,882       4,122,832       3950 consumer-2
# fulfillment-service orders.created  2          4,119,991       4,122,830       2839 consumer-3
TYPESCRIPT
// ✅ Programmatic lag monitoring — emit to metrics on every batch
const consumer = kafka.consumer({ groupId: 'fulfillment-service' })

await consumer.run({
  eachBatch: async ({ batch, resolveOffset, heartbeat }) => {
    const lagAtBatchStart = Number(batch.highWatermark) - Number(batch.lastOffset())
    metrics.gauge('consumer.lag', lagAtBatchStart, {
      topic:     batch.topic,
      partition: batch.partition.toString(),
      group:     'fulfillment-service',
    })

    for (const message of batch.messages) {
      await processOrder(JSON.parse(message.value!.toString()))
      resolveOffset(message.offset)
      await heartbeat()   // prevents session timeout during long batch processing
    }
  }
})
Performance / Safety Warning

Missing heartbeat() inside a long batch processing loop is the most common cause of rebalance storms. The consumer must call poll() (or heartbeat() in kafkajs eachBatch) within max.poll.interval.ms (default 5 minutes). A 100,000-message batch that takes 8 minutes to process will trigger a session timeout, a rebalance, and redelivery of the entire batch.


3. Backpressure: Slowing the Consumer to Protect the Downstream

3.1 max.poll.records as a Backpressure Valve

TYPESCRIPT
// ❌ Default max.poll.records = 500 — floods slow downstream
const consumer = kafka.consumer({
  groupId: 'fulfillment-service',
  maxInFlightRequests: 1,
})
// Each poll returns up to 500 messages. If downstream DB takes 50ms each:
// 500 × 50ms = 25 seconds per batch → heartbeat timeout → rebalance

// ✅ Reduce batch size to match downstream throughput
const consumer = kafka.consumer({
  groupId: 'fulfillment-service',
  maxInFlightRequests: 1,
})
await consumer.connect()

await consumer.run({
  eachBatch: async ({ batch, resolveOffset, heartbeat, isRunning }) => {
    for (const message of batch.messages) {
      if (!isRunning()) break

      const start = Date.now()
      await fulfillmentDb.insert(JSON.parse(message.value!.toString()))
      const duration = Date.now() - start

      // Dynamic backpressure: if downstream is slow, yield before next message
      if (duration > 100) {
        await new Promise(resolve => setTimeout(resolve, duration * 0.5))
      }

      resolveOffset(message.offset)
      await heartbeat()
    }
  }
})

3.2 RabbitMQ Prefetch as Backpressure

In RabbitMQ, channel.prefetch() is the backpressure mechanism — the broker will not push more messages until the in-flight count drops below the limit:

TYPESCRIPT
// ✅ Prefetch creates a natural backpressure ceiling at the broker level
const channel = await connection.createChannel()
await channel.prefetch(5)   // broker holds back until < 5 unacked messages

await channel.consume('orders.created', async (msg) => {
  if (!msg) return
  try {
    await fulfillmentDb.insert(JSON.parse(msg.content.toString()))
    channel.ack(msg)    // releases one prefetch slot — broker sends next message
  } catch (err) {
    // Downstream is unhealthy — nack without requeue
    // Broker delivers to DLX, consumer gets a break
    channel.nack(msg, false, false)
  }
}, { noAck: false })
Prefetch In-flight messages DB throughput target Behaviour
1 1 at a time < 50 msg/s Safe; serial; low throughput
10 up to 10 100–500 msg/s Balanced
50 up to 50 500–2,000 msg/s High throughput, more memory
0 (none) unlimited ❌ Broker floods consumer

4. Rebalancing: Stop-the-World vs Cooperative

4.1 Eager (Stop-the-World) Rebalancing

The default eager rebalancing protocol revokes all partition assignments when any consumer joins or leaves, forces all consumers to stop processing, waits for re-assignment, then resumes. For 20 consumers with 100ms processing latency, this is a 2–5 second dead stop every time a consumer deploys.

4.2 Cooperative Sticky Rebalancing

The CooperativeStickyAssignor (Kafka 2.4+) only revokes partitions that need to move. Unaffected consumers continue processing throughout the rebalance:

TYPESCRIPT
// ✅ Enable cooperative sticky rebalancing — only moved partitions pause
import { Kafka, PartitionAssigners } from 'kafkajs'

const consumer = kafka.consumer({
  groupId: 'fulfillment-service',
  partitionAssigners: [PartitionAssigners.roundRobin],
  // kafkajs uses cooperative protocol when broker supports it (Kafka 2.4+)
  sessionTimeout:        30_000,   // 30s — how long before broker considers consumer dead
  heartbeatInterval:      3_000,   // 3s — must be << sessionTimeout
  maxWaitTimeInMs:        5_000,
})

consumer.on(consumer.events.GROUP_JOIN, ({ payload }) => {
  console.log('Assigned partitions:', payload.memberAssignment)
})

consumer.on(consumer.events.STOP, () => {
  // Consumer stopped — partitions released back to group coordinator
})

4.3 Rebalance Storm Prevention

TYPESCRIPT
// ✅ Tuning to prevent rebalance storms during slow processing
const consumer = kafka.consumer({
  groupId: 'fulfillment-service',
  sessionTimeout:      45_000,   // How long broker waits before declaring consumer dead
                                  // Increase if consumers are slow but healthy
  heartbeatInterval:    5_000,   // Must be < sessionTimeout / 3
  maxWaitTimeInMs:     10_000,   // Max time broker blocks waiting for new messages

  // kafkajs eachBatch: always call heartbeat() inside the loop
  // eachMessage: kafkajs calls heartbeat automatically between messages
})

// Key insight: sessionTimeout vs max.poll.interval.ms (Java client only)
// In kafkajs: sessionTimeout covers both — the heartbeat loop is internal
// In Java client:
//   sessionTimeout    = heartbeat timeout (background thread)
//   max.poll.interval.ms = time between poll() calls (processing timeout)
//   Increase max.poll.interval.ms for slow processors, not sessionTimeout
Crucial Requirement

The three-timeout relationship: heartbeatInterval < sessionTimeout / 3. If sessionTimeout=30s and heartbeatInterval=15s, a single missed heartbeat causes an immediate session timeout. Default values (sessionTimeout=30s, heartbeatInterval=3s) are correct for fast consumers — increase sessionTimeout and maxWaitTimeInMs proportionally when processing is deliberately slow.


5. Batch Processing vs Per-Message: When to Use Each

TYPESCRIPT
// Per-message: simple, lower throughput, automatic heartbeat (kafkajs)
await consumer.run({
  eachMessage: async ({ message }) => {
    await processOrder(JSON.parse(message.value!.toString()))
    // kafkajs sends heartbeat automatically between eachMessage invocations
    // Safe for processing < sessionTimeout / 2 per message
  }
})

// Batch: higher throughput, manual heartbeat required, enables bulk DB writes
await consumer.run({
  eachBatch: async ({ batch, resolveOffset, heartbeat, commitOffsetsIfNecessary }) => {
    // Bulk insert: one DB round-trip for the entire batch vs one per message
    const records = batch.messages.map(m => JSON.parse(m.value!.toString()))
    await fulfillmentDb.bulkInsert(records)

    // Commit the last offset in the batch
    resolveOffset(batch.messages[batch.messages.length - 1].offset)
    await commitOffsetsIfNecessary()
    await heartbeat()
  }
})
Mode Throughput Heartbeat DB round-trips Best for
eachMessage Lower Automatic 1 per message Simple pipelines, < 1,000 msg/s
eachBatch Higher Manual (required) 1 per batch Bulk inserts, analytics, > 5,000 msg/s

Summary

Concept Rule
Parallelism cap Consumer group parallelism is hard-capped at partition count — adding consumers beyond that count is waste, not scale.
Cooperative rebalancing Cooperative Sticky Assignor eliminates stop-the-world rebalances; use it by default on all Kafka client versions that support it.
Lag root cause Lag root cause determines fix: lag from slow logic → optimize consumer; lag from downstream → apply backpressure; lag from rebalance storms → increase session.timeout.ms and max.poll.interval.ms.

What's Next

Part 7: Request-Reply over Messaging — Correlation IDs, Temporary Queues, and When Not To covers the cases where a caller genuinely needs a response from a downstream service but the synchronous HTTP call is not available or not appropriate. The correlation ID pattern, reply-to queues, and the timeout contract make request-reply over messaging safe — but most teams should reach for HTTP/gRPC first.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Kafka#Consumer Groups#Backpressure#Consumer Lag#Rebalancing#RabbitMQ#Backend#Distributed Systems
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.