Bounded asyncio.Queue with Backpressure Under Load¶
An unbounded asyncio.Queue is a memory leak waiting for a slow consumer. The default asyncio.Queue() has maxsize=0, meaning every put() succeeds instantly no matter how far behind the consumers are — so a producer that reads from a fast source (a socket, a Kafka partition, a file) will happily accept items into the queue faster than they drain. Under sustained load the queue grows until RSS climbs into the container limit and the process is OOM-killed. The fix is a bounded queue whose put() blocks when full, turning the consumer's pace into backpressure that propagates all the way back to the source. This guide reproduces the growth, applies the bound, measures that backpressure is engaging, and pushes it to the ingest layer.
Prerequisites¶
- Python 3.11+. Examples use
asyncio.TaskGroup,asyncio.timeout(), andtime.perf_counter(); stdlib only. - A running event loop under
asyncio.run(); the queue is bound to that loop. - The basics of queue coordination. This is a detail page under Async Queue Management; skim that overview for how
put()/get()suspend on the loop and whattask_done()/join()do. For the broader producer/consumer model, see Concurrent Execution & Worker Patterns.
The core idea in one line: a bounded queue is a backpressure mechanism, and await put() is the throttle. Everything below is about confirming it actually throttles and that the throttle reaches the source rather than dead-ending at the producer.
1. Reproduce unbounded growth¶
Before fixing it, watch it break. An unbounded queue with a producer faster than the consumer grows without limit; the depth is the leak.
Verify: depth rises monotonically every sample (hundreds, then thousands of items) — the queue is holding everything the producer outran the consumer by. Watch RSS in parallel (ps -o rss= -p $(pgrep -f your_script)) and it climbs in lockstep. This is the OOM trajectory.
2. Set maxsize to make put() block¶
Change one argument. With maxsize=N, await put() suspends the producer when the queue holds N items, so the queue can never exceed N — memory is now bounded by N x item_size.
Verify: depth climbs to ~100 and then holds — it never exceeds maxsize. RSS plateaus instead of climbing. The producer is no longer racing ahead; it is being paced by the consumer through the full queue.
3. Confirm the producer awaits put() and is throttled¶
The bound only helps if the producer uses await put() (not put_nowait()). Prove the producer is genuinely suspending — not spinning, not dropping — by timing the call when the queue is full.
Verify: the put waits roughly 150 ms — the time until a slot opened. A near-zero wait would mean the queue was unbounded or you used put_nowait(). This measured wait is the backpressure signal in its rawest form: the producer paid time because the consumer was behind.
4. Measure queue depth and put-wait as metrics¶
Turn step 3's one-off measurement into a continuous gauge. Wrap put() to record wall time spent waiting and sample depth; these two numbers are the entire health story of a bounded queue.
Verify: with a slow consumer, avg_put_wait rises above zero and depth sits near maxsize — backpressure is engaging. With a fast consumer, avg_put_wait stays near zero and depth hovers low. Export both as gauges (queue_depth, queue_put_wait_ms) and alert when put-wait climbs: it is the earliest, cheapest signal that consumers have fallen behind, ahead of any memory or latency alarm.
5. Propagate backpressure to the ingest source¶
A blocked put() only helps if the producer's blocking actually slows the source. If the producer drains a socket into the queue, suspending on put() means it stops calling recv(), so the OS TCP receive window shrinks and the sender is throttled — backpressure reaches the wire. The anti-pattern is a producer that reads everything into memory first, or uses put_nowait() with a fallback buffer: that re-introduces the unbounded growth one layer up.
Verify: under a fast remote sender and a slow consumer, the queue stays near maxsize and the connection's send rate drops to match consumer throughput — observable as a smaller TCP receive queue (ss -tin) on the socket. The producer never buffers more than maxsize chunks. Contrast with a put_nowait() + overflow-list design: there, the overflow list grows unbounded and you are back to step 1.
Verification¶
A correctly bounded, backpressuring pipeline shows all of these:
- Bounded memory: RSS plateaus under sustained overload instead of climbing;
qsize()never exceedsmaxsize. - Depth oscillates near maxsize: under load the queue sits close to full and dips as consumers drain — the healthy steady state, not pinned at zero (consumer-starved) or growing (unbounded).
- Put-wait above zero under load: the
queue_put_wait_msmetric rises when consumers fall behind and returns to zero when they catch up. - Source throttled: the ingest source's intake rate falls to match consumer throughput — TCP receive window shrinks, Kafka poll rate drops, file read pauses.
- No secondary buffer: there is no overflow list, no
put_nowait()fallback, no read-into-memory step that re-creates unbounded growth upstream of the queue.
Pitfalls & edge cases¶
put_nowait()on a full queue raisesQueueFull. It does not block, so it gives you no backpressure. Use it only when you intend to shed load and explicitly catchasyncio.QueueFull— never as a "faster put."- Deadlock if consumers stop. If every consumer dies or blocks (an unhandled exception, a blocking syscall), the queue fills,
put()suspends forever, and the producer hangs. Supervise consumers under aTaskGroupso a consumer crash cancels the producer instead of deadlocking it; guardput()withasyncio.timeout()if you need a liveness ceiling. - Drain on shutdown. On SIGTERM, stop the producer first, then
await q.join()to let consumers finish in-flight items before cancelling them — otherwise you drop everything still buffered. Wrapping the producer cancel before thejoin()is the correct ordering. - Too-small maxsize starves consumers. If the bound is below your consumer concurrency, workers idle on empty
get()while the producer is throttled — you have added latency for no memory benefit. Sizemaxsizeat roughly2xconsumer count as a starting point, then tune from the depth metric. - Blocking work in the consumer defeats the bound. If a consumer runs CPU-bound or blocking work between
get()andtask_done(), it starves the loop, no slots free, and the producer blocks even though the queue "should" drain. Offload that work — see Async Queue Management on consumer starvation.
Frequently Asked Questions¶
How does a bounded asyncio.Queue apply backpressure?
When the queue holds maxsize items, await queue.put() suspends the producer until a consumer calls get() and frees a slot. The producer being suspended means it stops pulling from its source, so the slowdown propagates upstream — for a socket producer, it stops calling recv(), shrinking the TCP receive window and throttling the sender.
What maxsize should I use for a bounded queue?
There is no universal value. Start at roughly 2x your consumer concurrency, then tune from the queue-depth metric and the container memory limit, since total memory is about maxsize times average item size. Too small starves consumers; too large defers backpressure until memory is already under pressure.
Will put_nowait() give me backpressure?
No. put_nowait() raises asyncio.QueueFull immediately on a full queue instead of waiting, so it provides no throttling. Use await put() for backpressure; reserve put_nowait() for deliberate load shedding where you catch QueueFull and drop or divert the item.
How do I avoid a deadlock when the queue is full and consumers stop?
If all consumers die or block, the full queue makes put() suspend forever and the producer hangs. Supervise producer and consumers under a TaskGroup so a consumer crash cancels the producer, and optionally wrap put() in asyncio.timeout() to enforce a liveness ceiling instead of blocking indefinitely.
Related¶
- Async Queue Management — up to the overview for the full pattern catalogue and queue-health diagnostics.
- Concurrent Execution & Worker Patterns — the broader producer/consumer and worker-pool model backpressure fits into.
- Implementing a dead-letter queue with asyncio — what to do with the items that keep failing once the queue is bounded and flowing.