Kafka's max.poll.interval.ms: The Rebalance Loop That Reprocesses the Same Batch Forever

Kafka · Intermediate · 7 min read · published

What this solves: Your Kafka consumer group keeps rebalancing every few minutes, commits fail with CommitFailedException, and the same messages get processed over and over while lag climbs. This happens when per-record processing is slow enough that your loop misses the poll deadline.

The Problem

A payments-enrichment consumer runs fine in staging. In production it hits this every few minutes, on every instance:

INFO  Member consumer-orders-3-8f2a sending LeaveGroup request
      because the poll timeout has expired
ERROR Offset commit cannot be completed since the consumer is not
      part of an active group ... you can address this by increasing
      max.poll.interval.ms or reducing max.poll.records

Symptoms: consumer lag climbs steadily to 400k, CPU sits at 15%, and the finance team reports the same order enriched three or four times. The group rebalances roughly every 5 minutes. Nothing crashed. No OOM. No broker errors.

The math: max.poll.records=500 (the default), each record makes one synchronous HTTP lookup averaging 600ms with a p99 of 2s. 500 × 0.6s = 300 seconds — exactly max.poll.interval.ms. So half the batches blow the deadline. And because eviction happens mid-batch, the commit at the end always fails, so the offsets never advance and the next generation of the group fetches the identical 500 records. It's not slow. It's a treadmill.

Why the Obvious Fix Falls Short

The first instinct — and there's a whole generation of StackOverflow answers pointing here — is to tune the timeouts. "The consumer is being kicked out, so raise session.timeout.ms and lower heartbeat.interval.ms."

That does nothing, because since Kafka 0.10.1 heartbeats live on a separate background thread from your processing loop. Your consumer was never failing its heartbeat; the broker knew it was alive the whole time. session.timeout.ms governs liveness. max.poll.interval.ms governs progress. They are two independent failure detectors, and the log message tells you exactly which one fired: "the poll timeout has expired."

The second instinct is the one the error message itself suggests: crank max.poll.interval.ms to 30 minutes. This stops the log spam, and that's the trap. You've now told the coordinator that a consumer that stops calling poll() should be tolerated for 30 minutes. When a pod actually deadlocks or its downstream dependency hangs, its 3 partitions are frozen for half an hour before anyone else can take them — with heartbeats cheerfully flowing the entire time. You traded a loud, self-inflicted problem for a silent, unbounded stall.

The third instinct — commit after every record — reduces the duplicate window but not the deadline. You still don't call poll() for 300 seconds, so you still get evicted; you just get evicted having made partial progress.

The real insight: poll() is not just "fetch messages." It is the consumer's liveness and progress signal, its rebalance-participation callback, and its offset-commit driver, all in one function. Any design where poll() is called rarely is fighting the client.

How It Actually Works

A consumer has two clocks running against it:

Once that LeaveGroup goes out, your commitSync() at the end of the batch arrives with a stale generation ID and the coordinator rejects it. Offsets stay put. You rejoin, get partitions back, fetch the same offsets, and repeat.

sequenceDiagram
    participant App as App thread
    participant HB as Heartbeat thread
    participant C as Group coordinator
    App->>C: poll() -> 500 records (gen 7)
    Note over App: processing... 600ms x 500
    loop every 3s
        HB->>C: Heartbeat (alive)
        C-->>HB: OK
    end
    Note over HB: t=300s poll deadline blown
    HB->>C: LeaveGroup (poll timeout expired)
    C->>C: Rebalance -> generation 8
    App->>C: commitSync(offset 12500)
    C-->>App: CommitFailedException (stale gen 7)
    App->>C: JoinGroup / SyncGroup
    C-->>App: same partitions, offset 12000
    Note over App,C: identical batch refetched -> duplicates, lag flat

The fix follows from the diagram: shrink the work done between two poll() calls, or decouple processing from polling entirely and use pause()/resume() so you can keep calling poll() cheaply while work happens elsewhere.

Before and After

// BEFORE: one poll() every ~300s. The batch size is set by a default
// nobody chose, and per-record latency is unbounded.
props.put("max.poll.records", 500);          // default
props.put("max.poll.interval.ms", 300_000);  // default

while (running) {
    var records = consumer.poll(Duration.ofSeconds(1));
    for (var r : records) {
        enrich(r);              // ~600ms sync HTTP, p99 2s
    }
    consumer.commitSync();      // often throws CommitFailedException
}
// AFTER: batch sized from measured latency, plus a hard budget so a slow
// downstream can never push us past the deadline mid-batch.
props.put("max.poll.records", 50);           // 50 x 2s p99 = 100s < 150s budget
props.put("max.poll.interval.ms", 300_000);  // unchanged: keep stall detection tight

final Duration BUDGET = Duration.ofMillis(150_000); // half the poll interval

while (running) {
    var records = consumer.poll(Duration.ofSeconds(1));
    long deadline = System.nanoTime() + BUDGET.toNanos();
    Map<TopicPartition, OffsetAndMetadata> done = new HashMap<>();

    for (var r : records) {
        if (System.nanoTime() > deadline) break;   // bail out, commit what's done
        enrich(r);
        done.put(new TopicPartition(r.topic(), r.partition()),
                 new OffsetAndMetadata(r.offset() + 1));
    }
    if (!done.isEmpty()) consumer.commitSync(done); // partial progress is real progress
    // unprocessed records are simply refetched next poll -- offsets guarantee it
}

For genuinely long per-record work (video transcode, LLM calls), go further: hand records to a bounded executor, consumer.pause(assignment) while it's saturated, keep calling poll() on a tight loop to stay alive, and resume() when capacity frees up. Commit only offsets the executor has confirmed.

When NOT to Use This

Gotchas

Key takeaway: Heartbeats keep you in the group but only poll() proves you're making progress — size max.poll.records so one batch always finishes well inside max.poll.interval.ms, or hand work off and use pause()/resume().

Real-world challenge

An order-enrichment service consumes from a 12-partition topic with 4 consumer instances. Logs show, roughly every 5 minutes per instance: 'Member consumer-3 sending LeaveGroup request because the poll timeout has expired', followed by 'Offset commit cannot be completed since the consumer is not part of an active group'. Downstream systems report duplicate enrichment records. Lag is growing even though CPU on the consumers is at 15%. What's happening and how do you fix it?

Diagnosis

  1. Low CPU + growing lag = the loop is blocked on I/O, not compute. Each record does a synchronous external lookup.
  2. LeaveGroup ... poll timeout has expired is unambiguous: the gap between two poll() calls exceeded max.poll.interval.ms. The background heartbeat thread was fine — that's why there's no session timeout message.
  3. Because the consumer left the group mid-batch, the final commitAsync/commitSync fails, so the offsets never advance. After rejoining, the same batch is fetched and reprocessed → duplicates, and lag never drops. That's the loop.

Measure before tuning: check records-lag, and instrument wall time of one batch:

long t0 = System.nanoTime();
var records = consumer.poll(Duration.ofMillis(500));
process(records);
log.info("batch={} ms={}", records.count(), (System.nanoTime()-t0)/1_000_000);

If ms is anywhere near max.poll.interval.ms, you've found it.

Fix, in order of preference

Also make reprocessing safe: dedupe downstream on (topic, partition, offset) or an idempotency key, because at-least-once means duplicates will happen during any rebalance, not just this one.