Timing Out Writes to Slow Clients¶
Read timeouts are common; write timeouts are rarely thought about until a client stops reading. A browser tab put to sleep, a stalled mobile network or a consumer stuck in its own processing still holds the connection open, and the server's writes have nowhere to go. asyncio then either buffers everything or waits forever, depending on whether the code calls drain(). Measured on Python 3.14 with an asyncio streams server sending 1 MiB messages every 10 ms to a client that connected and never read: without drain(), the transport buffered 472 MiB in 5 seconds and the server process grew from 21 MiB to 494 MiB. With await writer.drain() after each write, memory stayed flat at 22–23 MiB, but the handler was still blocked in drain() after 8 seconds — and would have been forever. With drain() wrapped in asyncio.timeout(2.0), the handler gave up after 2.00 s, having handed 5 MiB to the connection, aborted it and freed its resources. This guide bounds writes the same way reads are bounded.
Prerequisites¶
- Python 3.11+ asyncio streams.
- Back-pressure basics, from handling drain and write back-pressure in asyncio streams.
- The topic overview, Timeouts & Deadlines.
1. See what happens without drain¶
StreamWriter.write() never blocks: if the socket cannot take the data, the transport keeps it in memory. A handler that writes in a loop without drain() buffers without limit:
async def stream_updates(reader, writer):
while True:
writer.write(await next_update()) # 1 MiB each, never waits for the client
await asyncio.sleep(0.01)
Measured with a client that never read: after 5 seconds, transport.get_write_buffer_size() was 472 MiB and the process's resident memory had grown from 21 MiB to 494 MiB. One such client per worker is an out-of-memory kill waiting to happen; the server cannot tell this client from a slow but healthy one, because both look like a full socket.
Verify: a test with a non-reading client shows whether the write path's memory grows; it must not.
2. Add drain, and see it block¶
await writer.drain() waits while the transport's buffer is above its high-water mark — 64 KiB by default — which is the back-pressure that keeps memory bounded:
async def stream_updates(reader, writer):
while True:
writer.write(await next_update())
await writer.drain() # waits for the client to take the data
Measured: memory stayed at 22–23 MiB. But the handler stopped inside drain() and was still there after 8 seconds, holding its socket, its file descriptor, its task and anything it referenced. TCP will not resolve this on its own: as long as the client's machine acknowledges packets, the connection is healthy at the transport level, and a client that never reads leaves its receive window at zero indefinitely. With enough such clients, the server runs out of file descriptors or of connection slots instead of memory.
Verify: a test with a non-reading client shows the handler blocked in drain() — the behaviour step 3 fixes.
3. Put a timeout on drain¶
Bound how long a single write may wait for the client, and treat exceeding it as the client being gone:
WRITE_TIMEOUT = 2.0
async def send(writer: asyncio.StreamWriter, data: bytes):
writer.write(data)
try:
async with asyncio.timeout(WRITE_TIMEOUT):
await writer.drain()
except TimeoutError:
writer.transport.abort() # drop buffered data, close immediately
raise SlowClient(writer.get_extra_info("peername")) from None
Measured: the handler gave up 2.00 s after the client stopped reading, having handed about 5 MiB to the kernel's socket buffers and the transport, and aborted the connection. abort() rather than close() is deliberate: close() would try to flush the buffer to a client that is not reading, and wait. Choose the timeout from how long a healthy client can legitimately stall — a few seconds for interactive streams, longer for bulk downloads to slow networks.
Verify: with a non-reading client, the handler exits within the write timeout and the connection's file descriptor is closed.
4. Apply the same rule to WebSockets and HTTP streams¶
Libraries built on asyncio transports inherit the problem. In websockets, send() waits for the transport to drain, so a non-reading client blocks the sender; bound it the same way:
async def push(ws, message: str):
try:
async with asyncio.timeout(2.0):
await ws.send(message)
except TimeoutError:
await ws.close(code=1008, reason="client too slow") # or drop: transport.abort()
websockets' keepalive pings also notice clients whose application has stopped reading, because the pong must be sent by the client's WebSocket code — the defaults close such a connection after about 40 seconds, as covered in tuning WebSocket ping-pong heartbeats. For broadcast servers, never let one slow client's send hold up the others: give each client its own bounded queue and sending task, as in handling WebSocket backpressure with slow consumers. Streaming HTTP responses run on the server's transport, so their write timeouts come from the ASGI server's settings rather than from application code.
Verify: every long-lived connection type has a write timeout or a per-client queue with a size limit.
5. Count slow clients¶
A write timeout is a decision to drop a client, so it should be visible:
async def send_counted(writer, data: bytes):
writer.write(data)
try:
async with asyncio.timeout(WRITE_TIMEOUT):
await writer.drain()
except TimeoutError:
slow_client_disconnects.labels(endpoint="updates").inc()
log.info("dropped slow client", extra={"peer": writer.get_extra_info("peername"),
"buffered": writer.transport.get_write_buffer_size()})
writer.transport.abort()
raise
A steady trickle is normal — clients go to sleep and lose networks. A sudden rise means either clients degraded together, such as a mobile network problem, or the server's messages grew larger or more frequent than clients can absorb. Keep read timeouts alongside, as in adding read timeouts to asyncio streams; a client that neither reads nor writes needs both to be detected.
Verify: slow-client disconnects appear as a metric, and a load test with non-reading clients shows them dropped at the configured timeout.
Verification¶
Writes are bounded when:
- Every write loop awaits
drain(), so memory is bounded by back-pressure. drain()runs under a timeout, and a timeout aborts the connection.- WebSocket and other long-lived sends have the same bound or per-client queues.
- Slow-client disconnects are counted and visible.
Diagnostic Hook: when a streaming server's memory climbs with the number of connected clients but not with traffic, check transport.get_write_buffer_size() per connection. A single non-reading client accumulated 472 MiB in 5 seconds here because the write loop never called drain().
Pitfalls & edge cases¶
write()withoutdrain(). Measured: 472 MiB buffered in 5 s.drain()without a timeout. Measured: blocked past 8 s; forever in practice.close()on a stuck client. It tries to flush;abort()does not.- One slow client in a shared broadcast loop. It holds up every other client.
Frequently Asked Questions¶
What happens if an asyncio client stops reading?
Without drain(), the server buffers everything it writes: 472 MiB in 5 s here. With drain(), the writer blocks indefinitely instead.
How do I put a timeout on an asyncio write?
Wrap await writer.drain() in asyncio.timeout(), and on TimeoutError call writer.transport.abort(). The handler gave up after 2.00 s with memory flat.
Should I close or abort a connection to a slow client?
Abort. close() tries to flush buffered data to a client that is not reading; abort() discards it and releases the connection at once.
Does TCP keepalive detect a client that stops reading?
No. The client's machine still acknowledges packets, so the connection stays healthy at the TCP level. Only an application-level write timeout or ping detects it.
Related¶
- Timeouts & Deadlines — up to the topic overview.
- Timing out database queries — the same layering for a different resource.
- Resilience, Cancellation & Error Handling — the section overview.