How a Blocking Fraud-Check Call Triggered a Kafka Rebalance Storm and Duplicated 1,900 Orders
← Back
September 12, 2026Architecture9 min read

How a Blocking Fraud-Check Call Triggered a Kafka Rebalance Storm and Duplicated 1,900 Orders

Published September 12, 20269 min read

14:12 UTC. Consumer lag on order.created goes from flat to a wall in ninety seconds. PagerDuty pages the on-call engineer for inventory-service. By the time she has a terminal open, support is already reporting customers who got two order confirmation emails for one order.


the setup

Inventory-reservation-service consumes order.created off Kafka, runs each order through a third-party fraud-scoring API, and if it clears, reserves stock and publishes inventory.reserved. Three pods, one consumer group, twelve partitions. It had run this way for months without a single rebalance anyone noticed.

A deploy two days earlier added the fraud check itself, replacing a rule-based filter that had been flagging too many false positives. The call went straight into the message handler, on the same thread that calls poll().

consumer.py, as deployed
while True:
    records = consumer.poll(timeout_ms=1000)
    for record in records:
        order = json.loads(record.value)
        score = fraud_client.check(order)   # synchronous HTTP call
        if score.passed:
            reserve_stock(order)
            publish_reserved_event(order)
    consumer.commit()

max.poll.interval.ms was set to 45 seconds, not the client default of five minutes. That tuning came out of an earlier incident where a stuck consumer sat silently for four minutes before anyone noticed, and shortening the interval was the agreed fix for catching that faster. The fraud API's own latency at the time was a steady 150 to 300 milliseconds, so a batch of 500 records finished in a few seconds and nobody connected the two settings.

At 14:10 UTC a flash sale drove order volume up roughly 8x. The fraud vendor's API, under its own load from every merchant running a sale that day, slowed from 200ms to 4 to 6 seconds per call. A batch of 500 records that used to take four seconds now took closer to forty minutes at that rate, and the loop never reached consumer.commit() before the poll thread went silent for longer than 45 seconds.


the scramble

First theory: the fraud vendor was down and the client was hanging on dead connections. Their status page showed green, and a manual curl against their endpoint from a laptop returned a response in under five seconds. Slow, not down.

#incidents, 14:16 UTC
on-call: fraud vendor status page all green, checked their /health directly,
          getting responses back just slow (~5s), not timing out

Second theory: a broker problem, maybe a partition leader flapping between brokers and forcing reconnects. Broker metrics showed no leader elections in the last hour and all partitions sitting in ISR with no under-replicated warnings.

Third theory, and the one that ate the most time: the duplicate emails were a bug in the email service itself, retrying sends on a timeout. The email pipeline's own logs showed no retries. It had been asked to send the same order confirmation twice, from two separate calls, several minutes apart. Whatever was duplicating work was upstream of email entirely.


the hunt

Consumer lag on order.created was climbing in a sawtooth, not a straight line: up for a couple of minutes, a brief drop, then up again. That shape usually means partitions are being reassigned repeatedly, not that one consumer fell behind and stayed behind. The broker logs confirmed it.

kafka broker logs, filtered on the consumer group
[GroupCoordinator] Preparing to rebalance group inventory-reservation-service
  reason: Removing member consumer-2-a91f (session timed out)
[GroupCoordinator] Stabilized group inventory-reservation-service generation 47
[GroupCoordinator] Preparing to rebalance group inventory-reservation-service
  reason: Removing member consumer-1-c02e (session timed out)
[GroupCoordinator] Stabilized group inventory-reservation-service generation 48
[GroupCoordinator] Preparing to rebalance group inventory-reservation-service
  reason: Removing member consumer-3-f61b (session timed out)

Six rebalances in twelve minutes, one per pod, in rotation. The consumer-side logs explained why each one happened.

consumer-2 application log, 14:14:52 UTC
WARN [Consumer clientId=inventory-2] This member will leave the group
     because consumer poll timeout has expired. This means the time
     between subsequent calls to poll() was longer than the configured
     max.poll.interval.ms, which typically implies that the poll loop is
     spending too much time processing messages.

Each pod was blocked inside the fraud-check call for a batch of records, missed its 45-second poll deadline, and got fenced out of the group. Its partitions reassigned to whichever pod was free, which immediately started pulling its own batch of 500 records, hit the same slow API, and got fenced in turn a few dozen seconds later. Three pods took turns being evicted, none of them ever finishing a batch cleanly enough to commit past where the previous owner had already gotten to.


the find

Root cause: a synchronous, unbounded HTTP call inside the Kafka poll loop, combined with a max.poll.interval.ms that had been tuned down for a different failure mode. The setting that was supposed to catch a stuck consumer faster instead turned a slow dependency into a guaranteed eviction, and eviction mid-batch meant partitions kept changing hands before any consumer could commit offsets past the point where it had already produced side effects.

Every record fully processed before an eviction, fraud check passed, stock reserved, email sent, but not yet committed, got replayed by whichever pod picked up that partition next. The fraud check and stock reservation had no dedupe key, so replays produced a second reservation and a second confirmation email for the same order. Some orders got processed three times across the six rebalances before the storm settled.


the fix

The immediate fix was to get the fraud check off the poll thread entirely, so a slow dependency can never cost the consumer its group membership.

consumer.py, after
executor = ThreadPoolExecutor(max_workers=8)

while True:
    records = consumer.poll(timeout_ms=1000)
    if records:
        futures = [executor.submit(process_order, r) for r in records]
        for f in futures:
            f.result(timeout=8)   # bounded, independent of poll interval
    consumer.commit()

def process_order(record):
    order = json.loads(record.value)
    score = fraud_client.check(order, timeout=3)
    if score.passed and not already_reserved(order.id):
        reserve_stock(order)
        publish_reserved_event(order)

already_reserved(order.id) checks a Postgres table with a unique constraint on order_id, written in the same transaction as the reservation. That closes the actual hole: even if a rebalance replays a record, the reservation and the email only fire once.

The fraud client also got a hard 3-second timeout and a circuit breaker: past a threshold of consecutive slow responses, orders route to a manual review queue instead of blocking on the vendor at all. And max.poll.interval.ms went back up to two minutes, with a separate, purpose-built alert on the exact log line that started this incident, consumer poll timeout has expired, instead of relying on a short interval to surface a stuck consumer.


the aftermath

12 min From the first rebalance to the group stabilizing on its own
6 Rebalances, one per pod in rotation, before the storm settled
1,900 Orders that got reserved and confirmed more than once
45s The max.poll.interval.ms tuned for a different incident that made this one guaranteed

Nothing here was a crash. Every pod was healthy, the broker was healthy, and the fraud vendor was technically up the whole time. The failure was purely about time budgets: a poll loop that does unbounded synchronous work is one slow dependency away from evicting itself, and an eviction mid-batch is a replay waiting to produce duplicate side effects if nothing downstream is idempotent.

  • Any blocking call inside a Kafka poll loop inherits max.poll.interval.ms as its timeout, whether anyone intended that or not. Third-party API calls belong off that thread.
  • A tight poll interval tuned to catch one failure mode faster can make an unrelated failure mode worse. It's a tradeoff, not a free win, and it needs to be revisited whenever new work gets added to the loop.
  • At-least-once delivery means every consumer, not just the obvious ones, needs a dedupe key on anything with an external side effect. A rebalance is a normal, expected event, not an edge case.
  • "This member will leave the group because consumer poll timeout has expired" is a specific enough log line to alert on directly, rather than waiting for lag to climb far enough to page someone.

The dedupe check would have contained this to a lag spike with no customer impact. The timeout and circuit breaker on the fraud client are what stop the next slow dependency from turning into another rebalance storm at all.

Share this
← All Posts9 min read