What Happens When the Receiver Is Slow
A fast sender and a slow reader: the receive buffer fills, the window closes, the send buffer fills, and the sender’s write() blocks, returns EAGAIN, returns false, or awaits — depending only on which I/O model it chose. Buffer in user space instead and it fails by running out of memory.
The problem
write() call do about it in each I/O model?The stall propagates backwards through the chain
Follow the bytes. The sender writes; the kernel transmits; the receiver’s kernel appends to the socket receive buffer. The receiving application reads slowly, so the buffer fills. The receiver’s kernel advertises a shrinking window with every ACK, and when the buffer is full it advertises zero (see Flow Control: The Receive Window). The sender’s TCP stops transmitting — not because of the network, which is fine, but because the receiver said stop. Now the sender’s socket send buffer fills, because the application keeps writing and nothing leaves. When it is full, write() can no longer copy, and the sender’s application is stopped by the receiver’s application, four queues and one network away.
While the window is zero the sender sends zero-window probes — one byte, with exponential backoff — so that it learns when the receiver frees space even if the window update is lost. Linux gives up and resets the connection only if the probes go unanswered entirely (a dead peer), not if the peer simply keeps answering with a zero window; a slow receiver can hold a sender indefinitely. ss -ti shows the state on both ends: Recv-Q at its buffer limit on the receiver, Send-Q at its limit on the sender, and a window of 0 in the sender’s rcv_space/snd_wnd. See The Buffer Chain for the chain.
This is the correct behaviour. Flow control exists so that a fast producer cannot overwhelm a slow consumer; the alternative is dropping data or growing memory without bound. The interesting question is only how the sending *application* experiences the stop.
- Receiver app reads slowlybusy, blocked, or doing a DB write per message↓
- Receive buffer fillskernel keeps data until read(); free space shrinks↓
- Advertised window → 0every ACK carries rwnd; sender must stop↓
- Sender TCP stops transmittingzero-window probes with backoff; data waits ACKed-or-not in the send buffer↓
- Send buffer fillsSO_SNDBUF reached; nothing leaves↓
- Sender write() cannot copyblocks / EAGAIN / returns false / awaits — the I/O model decides
What write() does, by I/O model
A blocking socket is the simplest: write() sleeps until there is room, and the calling thread is stalled for as long as the receiver is slow — which is fine in a thread-per-connection server (only that thread waits) and fatal in an event loop (everyone waits). A non-blocking socket returns a short count or EAGAIN; the caller must keep the unwritten remainder and wait for a writable event from epoll/kqueue before trying again. Every event-driven framework wraps this pattern; hand-rolled code gets it wrong by dropping the remainder or spinning.
Runtimes expose it in their own vocabulary. In Node.js, writable.write(chunk) always accepts the chunk (into the stream’s own buffer) and returns false when the buffered amount exceeds highWaterMark (16 KiB by default for net.Socket); the correct response is to stop writing until the 'drain' event. pipe() and stream.pipeline() do this for you. In Python asyncio, writer.write(data) buffers in the transport and await writer.drain() suspends the coroutine until the buffer is below the high-water mark; transport.pause_reading() is the same signal in the other direction. In Go, conn.Write blocks the goroutine (not the OS thread) until the data is copied into the kernel. In Java NIO, SocketChannel.write in non-blocking mode returns fewer bytes than offered, and you register OP_WRITE. Same mechanism, five spellings.
The rule shared by all of them: the signal that the consumer is slow must reach the producer, and the producer must stop generating. A write() that never blocks and never says no is not solving backpressure; it is hiding it in a buffer.
1// by hand: stop producing when write() returns false, resume on 'drain'2function writeAll(sock: net.Socket, chunks: Iterable<Buffer>) {3 const it = chunks[Symbol.iterator]()4 const pump = () => {5 for (let n = it.next(); !n.done; n = it.next()) {6 if (!sock.write(n.value)) { sock.once('drain', pump); return } // kernel + stream buffer full7 }8 sock.end()9 }10 pump()11}12// with pipeline: the source is paused automatically when the sink is full13import { pipeline } from 'node:stream/promises'14await pipeline(fs.createReadStream(path), sock)| I/O model | What write() does | What you must do | Failure if ignored |
|---|---|---|---|
| Blocking socket | sleeps until room | nothing (thread waits); set SO_SNDTIMEO | thread held indefinitely by one peer |
| Non-blocking socket (epoll/kqueue) | short write or EAGAIN | keep remainder; wait for writable event | dropped tail or 100% CPU spin |
| Node.js stream | buffers; returns false past highWaterMark | stop writing until 'drain' (or use pipeline) | process memory grows until OOM |
| Python asyncio | buffers in transport | await writer.drain() | transport buffer grows until OOM |
| Go net.Conn | blocks the goroutine | nothing; use deadlines | goroutine held; leak if unbounded spawning |
| Java NIO non-blocking | writes partial; returns count | register OP_WRITE; retry | dropped data or busy loop |
The failure mode: unbounded userspace buffering
The kernel’s buffers are bounded and push back. The failure appears when an application decides that pushing back is inconvenient and buffers in user space instead: a Node service that ignores the false from write(), an asyncio service that never calls drain(), a broker client with an unbounded outgoing queue, a proxy that reads the whole upstream response "to be safe" before writing any of it. Each slow consumer now costs the producer memory at the rate of the speed difference, indefinitely. The process grows, GC pauses lengthen (making everything slower, so buffers grow faster), and it dies of OOM or is killed by the OOM killer (see Memory Pressure, Swap and the OOM Killer). The kernel did its job; the application undid it.
The same shape exists at every scale: an unbounded Queue in front of a thread pool (The Thread Pool Server), a message broker that accepts publishes faster than consumers drain and grows its disk until it fails, an event-sourcing pipeline whose slowest stage silently sets the memory of every stage before it. The Software Architecture answer — bounded queues, backpressure-aware streams, drop or reject policies — is the same as the kernel’s, and it must be applied at each hop where someone might otherwise buffer.
The decision when the consumer is slow is always one of three: stop the producer (backpressure, the default and usually right), drop (acceptable for real-time data where late is useless: metrics, video frames), or buffer with a bound and then do one of the first two. Unbounded buffering is not a fourth option; it is dropping, later, all at once, with a core dump.
- Every buffer you add in user space needs a bound and a policy for hitting it.
- Symptom of unbounded buffering: RSS grows linearly under load and drops to normal only on restart;
ssshows the kernel buffers full. - The metric: bytes buffered in the application per connection (Node:
socket.writableLength; asyncio:transport.get_write_buffer_size()).
Key points
- A slow receiver fills its receive buffer, advertises a zero window, the sender’s TCP stops, its send buffer fills, and its
write()can no longer copy: backpressure across four queues and a network. - What
write()does at that point is the I/O model: blocks (blocking socket, Go),EAGAINand a writable event (non-blocking), returnsfalseand'drain'(Node),await drain()(asyncio), partial count andOP_WRITE(Java NIO). - The signal must reach the producer and the producer must stop generating; a write that never refuses is hiding the problem in memory.
- Unbounded userspace buffering converts a flow-control stall into an OOM, later and worse.
- Options when a consumer is slow: stop the producer, drop, or buffer with a bound and then do one of those. There is no fourth option.
- Diagnose from the kernel:
Recv-Qfull on the receiver andSend-Qfull on the sender with a zero window is an application problem on the receiving side.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does flow control stop the sender instead of the receiver just dropping what it cannot handle?
Because TCP promises every byte arrives in order; dropping would force retransmission, which costs the same bandwidth again and adds an RTT. Stopping the sender costs nothing on the wire and holds the data where it already is — in the sender’s memory, which the sender controls.
▸Why does Node’s write() accept data it cannot send?
For ergonomics: a write that could not be called without checking would make simple programs painful. The return value is the compromise — accept this chunk, and tell you that the next one should wait. The design works only if callers honour it, which is why pipeline exists.
▸Why is unbounded buffering so common if it is so dangerous?
Because it works perfectly in testing, where consumers are fast, and fails only in production under a slow client or a downstream outage — and the failure (OOM an hour later) looks unrelated to the cause. Backpressure bugs are latent by nature.
The receiver is slow
n = write(fd, buf, len) // sleeps until the kernel has room // thread parked in state S inside the syscall
How it fails
What the failure looks like from inside real software.
- A Node service streaming file downloads ignores
write()’s return: a few clients on slow links make RSS climb to the container limit; the pod is OOM-killed with no error log. - An asyncio WebSocket broadcaster calls
writer.write()in a loop withoutdrain(): one disconnected-but-not-yet-detected client accumulates the entire broadcast history in its transport buffer. - A reverse proxy reads the whole upstream response before writing any of it to a slow client: 50 large downloads to slow clients exhaust proxy memory while upstream is idle.
- Event-loop server uses a blocking
send()for large responses: one slow client blocks the loop; every connection stalls until that client reads. The symptom is periodic global latency spikes correlated with one IP. - A Kafka producer with an unbounded send queue during a broker slowdown: the producer process grows until OOM; messages are lost from memory, not from the broker.
- Sender sets a 10-second write timeout, times out on a slow-but-alive client, closes the connection mid-response: the client receives a truncated file and no error until it checks the length.