Distributed Systems

The Keyed Worker Pool: Parallel Kafka Consumers That Keep Order

Kafka orders records inside a partition. The moment you hand a batch to an executor, that guarantee is gone. Four interactive simulations of the machinery that gets parallelism back without losing order: mailbox dispatch, the low-watermark commit, pause-based backpressure, and the rebalance gap no broker setting can close.

August 24, 2026·12 min read
KafkaDistributed SystemsConcurrencyBackpressureRebalancingConsumer Groups

Kafka orders records inside a partition. The moment you hand a batch to an executor, that guarantee is gone — and the usual fix, adding partitions, buys parallelism in units nobody wants to manage. Ordering is only ever guaranteed within a partition consumed by a single thread.

Everything below keeps that invariant while running more than one thread: mailbox dispatch, the low-watermark commit, pause-based backpressure, and the rebalance gap no broker setting can close. Each one is running live. Drive the controls and watch where it holds, and where it quietly stops holding.

01

Dispatch: shared pool versus keyed mailboxes

Both modes poll the same partition, in offset order, with identical per-record latencies. Only the routing rule differs. A shared pool lets two records for the same key run concurrently; mailboxes never do.

IN KAFKA: the difference between concurrency=N capped by partition count and a dispatcher that decouples parallelism from partitions entirely

Partition 0 — log

B10
C11
A12
A23
B24
B35
C26
B47
D18
C39
C410
B511
B612
D213
D314
B715
A316
D417
C518
A419
B820
D521
A522
D623

Mailboxes

A [0] idleB [0] idleC [0] idleD [0] idle

Worker threads

Thread 0
idle
Thread 1
idle
Thread 2
idle
Thread 3
idle

Completion order, per key

A
B
C
D
Tick0
Done0/24
Order breaks0
Concurrent per key0

At most one thread drains a mailbox at a time. Order breaks stay at zero by construction.

The mailbox is the whole trick, and thread affinity is not part of it. Routing by hash(key) % threads also preserves order, but it couples unrelated keys onto the same thread — one slow fund stalls every other fund that hashes beside it. A mailbox per key drained by a shared pool holds the same invariant across tens of thousands of keys and a handful of threads.
02

The commit watermark

Records now complete out of offset order, so the consumer can no longer commit what it just finished. Tap cells to complete them in any order, then crash the consumer and see what survives.

IN KAFKA: the committed offset is a promise that everything below it is durably handled — the only safe value is the lowest offset still in flight

In flight — tap to complete

COMMIT 200
Committed200
In flight16
Redelivered
Lost

Leave one early offset incomplete and complete everything after it. The watermark pins — that is correct, and it is why a permanently failing key has to be quarantined on a timer rather than retried forever.

Committing the maximum completed offset is the failure that looks fine for months. Throughput is identical, lag graphs are identical, and the loss only appears on the one rebalance where a low offset happened to still be retrying. The low watermark trades a bounded window of redelivery for the guarantee that nothing is skipped — a good trade only if the handler tolerates redelivery, which is section 04.
03

Backpressure: pause, do not queue

Dispatch is non-blocking, so poll() will pull faster than the pool drains. The only bounded answer is to keep polling for heartbeats while refusing to fetch. Push fetch above drain and watch each mode find its ceiling.

IN KAFKA: pause() and resume() on assigned partitions — a paused partition returns no records but still satisfies max.poll.interval.ms

In-flight depth over time

HEAP CEILINGHIGH 24LOW 8
Fetching
In flight0
Peak0
Pauses0
Poll intervalOK

High mark 24, low mark 8, heap ceiling 72. The consumer keeps polling while paused, so the session never expires.

There is a second win hiding here. Once dispatch is non-blocking, max.poll.interval.ms stops being your processing budget — a handler that takes four minutes no longer triggers a rebalance, because the poll thread never blocked on it. Long-running work stops causing the rebalance storms that made everyone shorten their handlers in the first place.
04

Rebalance, and the write guard behind it

On revoke you stop dispatching, wait for in-flight work, and commit. If the wait times out, work from the old owner lands after the new owner has started the same offsets. Shorten the drain window until records leak, then turn the guard off.

IN KAFKA: onPartitionsRevoked and the bounded drain — the one window where two consumers hold the same offsets at once

Consumer A — revoked

200 · v1t-1
201 · v2t-2
202 · v3t-6
203 · v4t-3
204 · v5t-7
205 · v6t-2

Consumer B — new owner

not assigned

Write log — fund NAV row

Revoke the partition to begin.
state = v0
Leaked writes0
Redelivered0
Final state

A drain of 7 ticks outlasts every in-flight record. Drop it to 2 and three writes leak past the handoff.

This is why the guard is not decoration. Cooperative sticky assignment reduces how often the window opens; a longer drain reduces how wide it is. Neither closes it, because a JVM pause or a slow downstream call can outlast any timeout you choose. The only thing that holds is a write that refuses to apply a version it has already passed — after which redelivery is a throughput cost rather than a correctness one.

The common thread: every mechanism here converts an ordering problem into a bookkeeping problem. Mailboxes track which key is busy, the watermark tracks the lowest thing unfinished, pause tracks how much is outstanding, and the version guard tracks what the database has already seen. None of them ask the broker for a stronger guarantee than a partition can give.

Share this article