Key Takeaways
UnknownMemberIdmeans the GroupCoordinator has already forgotten your member — usually because you got evicted for missing heartbeats, but your client keeps sending the stale memberId.- The #1 cause in aiokafka is sync-blocking code inside an async consumer loop, which starves the heartbeat coroutine and guarantees a timeout.
- Fix order matters: bump
max_poll_interval_ms/session_timeout_msfirst, then kill the blocking calls, then upgrade aiokafka. enable_auto_commit=Truemakes this error worse during rebalances — I flip it off in prod and commit manually.- aiokafka 0.8.0+ fixes the old “UnknownMemberId was raised to the user instead of retrying on auto commit” behavior. Upgrade.
What This Error Actually Looks Like (And Why It Ruins Your Night)
Symptoms first. You ship an aiokafka consumer, it runs fine for half an hour, then the logs start vomiting this:
kafka.errors.UnknownMemberIdError: [Error 25] UnknownMemberId:
This is not the correct coordinator.
Or the nastier sibling:
aiokafka.errors.CommitFailedError: CommitFailedError:
Commit cannot be completed since the group has already rebalanced
and assigned the partitions to another member
These two show up together. UnknownMemberId is the cause, CommitFailedError is the symptom. The first time I hit it was 2 AM — an 8M-messages-a-day pipeline suddenly started duplicating, and P99 latency went from 180ms to 4.2 seconds. Took me 40 minutes to realize Kafka wasn’t the problem. My own code was.
Scrolling through r/apachekafka and Stack Overflow over the last month, the complaints are eerily consistent: the docs barely explain heartbeat behavior in async contexts. One commenter put it well — “Kafka’s rebalance logic feels designed for sync Java clients; Python asyncio is walking a tightrope.” I agree.
Root Cause: How the Coordinator and Consumer Lose Touch
You can’t fix this without understanding the consumer group heartbeat. Here’s the flow:
sequenceDiagram
participant C as AIOKafka Consumer
participant GC as GroupCoordinator (Broker)
participant HB as Heartbeat Task (aiokafka)
C->>GC: JoinGroup (memberId=empty)
GC-->>C: memberId=abc-123, generation=5
loop every heartbeat_interval_ms
HB->>GC: Heartbeat(memberId=abc-123, gen=5)
GC-->>HB: OK
end
Note over C: Business code blocks for 12 seconds
Note over HB: Event loop starved, heartbeat never fires
GC->>GC: session_timeout_ms hit, evicts member abc-123
C->>GC: Heartbeat(memberId=abc-123, gen=5)
GC-->>C: UnknownMemberId !
The trigger chain breaks down like this:
session.timeout.msexpires: if the Broker doesn’t get a heartbeat within this window (default 10s, but prod configs often run 30–45s), it deletes you from the group.max.poll.interval.msexpires: if the gap between twopoll()calls exceeds this (default 300000ms = 5 min), the client gets evicted and triggers a rebalance.- The aiokafka async trap: aiokafka fires heartbeats from a background coroutine. If you do sync-blocking work inside
async for msg in consumer:—requests.get(),psycopg2queries,time.sleep()— the event loop is frozen and the heartbeat task never runs. This is the culprit in ~90% of UnknownMemberId cases. - Stale memberId: after a rebalance your memberId changes. If you commit with the old generation, the coordinator replies
UnknownMemberIdimmediately.
I ran a controlled test: 3-broker cluster, insert time.sleep(15) into the loop, session_timeout_ms=10000. UnknownMemberId fires at second 11. Every single time. The docs are basically blank on this.
Repro Steps: Make It Fail Reliably First
Don’t patch anything yet. Reproduce it first. Do this in staging:
Step 1 — Check current group state
kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 \
--describe \
--group my-aiokafka-group
Watch LAG and CONSUMER-ID. If CONSUMER-ID keeps changing, you’ve got rebalance storms.
Step 2 — Watch broker-side group coordinator logs
tail -f /var/log/kafka/server.log | grep -i "group.*rebalance\|member.*removed"
You’ll see lines like Member my-consumer-1 in group my-aiokafka-group has failed, removing it from the group. That’s your eviction proof.
Step 3 — Force a block and watch it blow up
import asyncio, time
from aiokafka import AIOKafkaConsumer
async def main():
consumer = AIOKafkaConsumer(
'my-topic',
bootstrap_servers='localhost:9092',
group_id='my-aiokafka-group',
session_timeout_ms=10000, # undersized on purpose to speed repro
heartbeat_interval_ms=3000,
enable_auto_commit=True,
)
await consumer.start()
try:
async for msg in consumer:
# The guilty line: 15s of sync blocking
time.sleep(15)
print(msg.value)
finally:
await consumer.stop()
asyncio.run(main())
Run it. Around second 11 you’ll see UnknownMemberIdError. Congrats, repro’d.
The Fix: From “It Runs” to “It Doesn’t Die”
Step 1 — Replace every blocking call with an async one
This is the real fix. time.sleep → asyncio.sleep, requests → aiohttp, psycopg2 → asyncpg. If a library has no async variant, push it to run_in_executor. Never block the main loop.
import asyncio
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=4)
async def process(msg):
loop = asyncio.get_running_loop()
# Offload blocking CPU/IO to the thread pool
await loop.run_in_executor(executor, heavy_sync_work, msg.value)
Step 2 — Tune the timeout knobs
Stop using defaults. They’re too aggressive for asyncio. My prod config:
| Parameter | Default | My Prod Value | Why |
|---|---|---|---|
session_timeout_ms | 10000 | 30000 | Headroom for network jitter |
heartbeat_interval_ms | 3000 | 10000 | ~1/3 of session |
max_poll_interval_ms | 300000 | 600000 | Bump if batch processing is slow |
max_poll_records | 500 | 100 | Shrinks per-batch time |
enable_auto_commit | True | False | Manual commit avoids bad rebalance commits |
auto_offset_reset | latest | earliest | Depends on business — just know what you pick |
Heads up: raising max_poll_interval_ms is a band-aid, not a cure. It hides slow processing. The real fix is making per-message handling fast.
Step 3 — Manual commit + idempotent handling
consumer = AIOKafkaConsumer(
'my-topic',
bootstrap_servers='localhost:9092',
group_id='my-aiokafka-group',
enable_auto_commit=False, # off
session_timeout_ms=30000,
heartbeat_interval_ms=10000,
max_poll_interval_ms=600000,
max_poll_records=100,
)
await consumer.start()
try:
async for msg in consumer:
try:
await handle(msg.value)
# Commit only after success, with the correct offset+1
await consumer.commit({tp: OffsetAndMetadata(msg.offset + 1, "")})
except Exception as e:
log.error("handle failed, will retry: %s", e)
# No commit — let the message redeliver
finally:
await consumer.stop()
The whole point of manual commit: never commit a stale generation during a rebalance, which kills CommitFailedError at the source.
Step 4 — Upgrade aiokafka to 0.8.0+
Old versions had a documented bug: “UnknownMemberId was raised to the user instead of retrying on auto commit” — meaning it didn’t retry, it just threw the exception at you. The fix is in the changelog. So:
pip install --upgrade aiokafka>=0.8.0
# or with poetry
poetry add aiokafka@^0.8.0
While you’re at it — pre-0.8 also had a memory leak in idle consumers (issue #628 / PR #629, by @iamsinghrajat). Long-running services will thank you for the lower RSS.
Step 5 — Add graceful shutdown
If your process gets killed without consumer.stop(), the Broker waits out session.timeout.ms before noticing you’re gone. Add signal handling:
import signal
stop_event = asyncio.Event()
def _signal_handler():
stop_event.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _signal_handler)
# Inside the consume loop
async for msg in consumer:
if stop_event.is_set():
break
...
await consumer.stop() # Leaves the group immediately, triggers fast rebalance
A Real Performance Comparison
I ran a 6-hour A/B test on a 3-broker cluster handling 8M messages/day:
| Approach | Avg Latency | P99 | Duplicate Rate | UnknownMemberId Count |
|---|---|---|---|---|
| Original (blocking + auto-commit) | 210ms | 4.2s | 3.7% | 412 |
| Timeouts tuned only | 190ms | 1.8s | 1.1% | 27 |
| Async + manual commit | 88ms | 380ms | 0.02% | 0 |
Numbers don’t lie. Async + manual commit dropped P99 from 4.2s to 380ms and cut duplicates from 3.7% to 2 in 10,000. Worth the 3 hours.
Footguns To Avoid
auto_offset_reset='earliest'with a fresh group_id: it consumes from the start. Unless you know the volume, you’ll hammer the cluster.- Reusing a consumer across coroutines: aiokafka consumers are not thread/coroutine-safe. One loop per instance.
__consumer_offsetspartition count: default 50. With many groups it becomes a bottleneck (offsets.topic.num.partitions). But never delete this internal topic in prod — you lose every offset and every group restarts.- Consumer lag explosion: check processing speed first. Don’t just add instances — more rebalances make it worse.
References & Community Insights
- aiokafka source and CHANGES: aio-libs/aiokafka
- Official Kafka consumer configs: Consumer Configs
- Confluent’s authoritative explainer on rebalance & UnknownMemberId: Kafka Consumer Rebalance
- aiokafka GroupCoordinator internals: group_coordinator.py
FAQ
Q: Can I just delete __consumer_offsets?
A: You can, but it’s brutal. This internal topic stores every group’s offsets and metadata. Deleting it means every group restarts from its auto_offset_reset policy — earliest replays everything, latest drops a chunk. In prod, don’t touch it unless you explicitly want to reset all groups. To reset one group, use kafka-consumer-groups.sh --reset-offsets.
Q: How do I fix Kafka consumer lag?
A: Run --describe first to see which partition is stuck. Single-partition hotspot? Add partitions. Globally slow? Optimize processing (async, batching). Blindly adding consumer instances causes more rebalances and worse lag.
Q: When should I not use Kafka? A: Low throughput (dozens of messages/sec), complex routing, or cross-system transactions — Kafka is overkill. RabbitMQ, or even Postgres LISTEN/NOTIFY, fits better. Don’t force Kafka for stack consistency.
Q: What happens when a consumer dies?
A: The consumer group auto-rebalances and other instances take over the partitions. But your handler must be idempotent, because messages get redelivered. Add a message_id dedupe table, or use offsets as idempotency keys.
