Follow send() Through the OS to recv()
Between send() returning in one process and recv() returning in another there are two kernels, two NICs, three copies, a congestion gate, a routing decision and at least one context switch — and every one of them is a place where bytes wait.
The problem
send(fd, buf, 4096, 0) and it returns 4096 in a few microseconds. The bytes have not reached the other machine — they have not even left yours. What did the call actually do, what happens after it returns, and where can the bytes get stuck between here and the other process’s recv()?Progressive depth
The same mechanism at different altitudes — start where you are.
Your program hands bytes to the kernel; the kernel transmits them when the receiver and the network allow; the other kernel collects them and wakes the other program, which copies them out. Every step can wait.
What send() promises, and what it does not
send() on a stream socket promises one thing: the bytes it reports as sent have been copied into the kernel’s send buffer for that socket. It does not promise they were transmitted, acknowledged, or read. That single fact explains most of the surprises in this lesson: the return value measures a copy into kernel memory, not delivery.
From there the bytes cross a fixed sequence of layers — transport, IP, the device queue, the NIC, the wire — and on the far side the same layers in reverse, ending in a copy out of the receiving kernel into the receiver’s user buffer. Each layer has its own state (sequence numbers, windows, routing cache, ring-buffer slots), its own queue, and its own way of saying "not now".
The picture below is the whole path at a glance. The rest of the lesson expands each side. The names in the ladders (sk_buff, qdisc, NAPI, softirq) are Linux; the structure — buffer, gate, queue, DMA, interrupt, wake-up — is what every mainstream kernel does, with different names.
send()returningnmeansnbytes were copied into the socket send buffer. Nothing more.- The kernel is free to transmit those bytes later, split them, or coalesce them with the next call — TCP is a byte stream, not a message queue; see TCP: A Reliable Ordered Byte Stream over an Unreliable Network.
- UDP is different: one
sendto()is one datagram, and the kernel either queues the whole datagram or fails; see UDP: Datagrams and the Contract You Choose.
The send side, layer by layer
The application calls send() — via write(), a language runtime, or a library; it all ends at the same system call. The CPU switches to kernel mode (see System Calls), the kernel looks up the descriptor in the process’s table (see File Descriptors) and finds a socket. If the socket’s send buffer has room, the kernel copies the user bytes into kernel-owned sk_buff structures and returns. If it has no room and the socket is blocking, the calling thread goes to sleep here; if non-blocking, the call returns EAGAIN.
TCP then decides how much of the buffered stream it may transmit right now. Two windows gate it: the receiver’s advertised window rwnd (see Flow Control: The Receive Window) and the sender’s congestion window cwnd (see Congestion Control: Protecting the Network). Whatever fits is cut into segments no larger than the MSS, each stamped with a sequence number (see Sequence Numbers, ACKs and Reassembly), and handed to IP. With TSO/GSO the kernel hands one large segment down and lets the NIC (or the driver, late) do the cutting, saving per-packet CPU.
IP consults the routing table (see The Routing Table and Longest-Prefix Match), chooses the outgoing interface and next hop, resolves the next hop’s link address (see ARP and Neighbor Discovery: From an IP to a Local MAC), and prepends the IP header. The packet is enqueued on the interface’s qdisc — the queueing discipline, fq_codel by default on most modern distributions — which decides ordering and can drop under load. The driver then places a descriptor pointing at the packet in the NIC’s transmit ring; the NIC reads the bytes by DMA, computes the checksum if offload is enabled, and puts the frame on the wire.
- Application: send(fd, buf, n)user mode; buf is in the process address space↓
- System call: mode switch~100–300 ns to enter; descriptor → socket lookup↓
- Socket send bufferCOPY #1 user → kernel sk_buff; blocks or EAGAIN when full↓
- TCP: gate and segmentmin(cwnd, rwnd) bytes allowed; seq numbers; retransmit timer↓
- IP: route and headerlongest-prefix match → interface + next hop; TTL↓
- qdisc: device queuefq_codel / pfifo_fast; can drop; TCP small queues limit depth↓
- NIC: TX ring + DMAdescriptor ring; NIC pulls bytes; TSO/checksum offload↓
- Wireframe on the medium; now the network’s problem
The receive side, layer by layer
The receiving NIC matches the frame’s destination MAC (see MAC Addresses: Identity for One Hop), writes the bytes by DMA into a pre-allocated buffer that the driver posted on the receive ring, and raises an interrupt. Linux uses NAPI: the first interrupt disables further interrupts for that queue and schedules a poll; the kernel then drains the ring in a batch (netdev_budget, 300 packets by default) before re-enabling interrupts. Under a high packet rate this turns thousands of interrupts per second into a polling loop, which is why a busy server shows CPU in softirq rather than in irq.
Each packet becomes an sk_buff and climbs the stack in software-interrupt context: link layer strips the Ethernet header, IP validates the checksum and decides "for me" or "forward", TCP matches the 4-tuple to a socket (see Ports: Addressing a Process, Not a Machine), checks the sequence number against what it expects, reassembles in-order data, and queues an ACK. In-order bytes are appended to the socket’s receive buffer; out-of-order segments wait in a separate queue until the gap is filled (see Head-of-Line Blocking). The receive window the kernel advertises back shrinks by exactly what it just queued.
Now the kernel looks for someone waiting. If a thread is blocked in recv() on that socket, it is marked runnable and the scheduler will run it within a time slice or on the next idle core (see Context Switching). If the socket is registered with epoll, the socket is added to the ready list and the thread blocked in epoll_wait() wakes instead (see I/O Multiplexing: select, poll, epoll, kqueue, IOCP). Either way the wake-up is a scheduler event, not a network one — this is where OS latency enters the path. When the thread runs, recv() copies the bytes from the receive buffer into the user buffer, frees the kernel memory, and the window can open again.
- Wire → NICMAC filter; DMA into RX ring buffer; interrupt↓
- NAPI poll (softirq)batch up to netdev_budget packets; GRO merges segments↓
- IP: for me?checksum; destination match or forward↓
- TCP: match socket, reassemble, ACK4-tuple lookup; seq check; out-of-order queue; rwnd shrinks↓
- Socket receive bufferkernel memory; bounded by SO_RCVBUF / autotuning↓
- Wake-upblocked recv() thread made runnable, or epoll ready list↓
- Scheduler runs the threadtime slice / idle core; 1–5 µs switch plus wait↓
- recv(): copy to userCOPY #2 kernel → user; buffer freed; window reopens
Where the copies are, and where the time goes
On a conventional path there are exactly two CPU copies of the payload — user → kernel on send, kernel → user on receive — plus two DMA transfers that the CPU does not perform. Everything else is header manipulation on a pointer. The copies matter at high throughput (a 10 Gbit/s stream is ~1.25 GB/s of memcpy per direction) and are what Zero-Copy: Serving a File Without Touching It removes for file-to-socket cases; for ordinary request/response traffic they are not the bottleneck.
Latency accumulates at the queues, not the copies. On the sender: waiting for send-buffer space, waiting for cwnd/rwnd to open, waiting in the qdisc behind other flows. In the network: serialization, propagation (~5 µs per km in fibre), router queueing. On the receiver: the interrupt-to-poll delay, the softirq run, and above all the scheduler: a woken thread runs only when a core is free. Under load the gap between "bytes in the receive buffer" and "application reads them" is frequently larger than the network RTT, and it shows up in the network’s tools as a shrinking receive window.
The rule for reading these layers in an incident: ss -ti on both ends tells you which queue holds the bytes. Send-Q non-zero on the sender and Recv-Q zero on the receiver means the network or the windows; Recv-Q growing on the receiver means the application is not reading — an OS problem wearing a networking symptom. See TCP Debugging: Reading the Handshake on the Wire and Combined Failure Simulator: Break a Layer, Watch It Propagate.
| Stage | Copy? | Who waits | Typical cost | Symptom when it stalls |
|---|---|---|---|---|
| send() syscall | user → kernel | calling thread | ~1–5 µs for 4 kB | send() blocks / EAGAIN; Send-Q at SO_SNDBUF |
| TCP window gate | no | bytes in send buffer | 0 to one RTT | Send-Q > 0, cwnd small or rwnd 0 in ss -ti |
| qdisc + NIC ring | DMA | packets | ~10–100 µs, more under bufferbloat | drops in tc -s qdisc; high softirq |
| Network | no | packets | 0.1 ms LAN → 150 ms intercontinental | retransmits, RTT variance |
| RX ring + NAPI | DMA | frames | ~10–50 µs | rx_dropped / rx_missed on the NIC |
| Receive buffer → wake-up | no | data for the app | 0 to a scheduler tick | Recv-Q grows; receiver advertises rwnd → 0 |
| recv() copy | kernel → user | calling thread | ~1–5 µs for 4 kB | rare — usually the app is simply not calling it |
QUIC moves most of this into the process
With TCP, everything from segmentation to ACKs to retransmission happens in the kernel; the application sees a byte stream. With QUIC (the transport under HTTP/3 and QUIC) the kernel only sees UDP datagrams. Loss detection, congestion control, flow control per stream, encryption and reassembly run in the application’s library — Chrome’s, Cloudflare’s, quiche, msquic. The ladder above still exists, but the "TCP" rung becomes user-space code, and the kernel rungs shrink to sendmsg()/recvmsg() on a UDP socket.
That has two consequences worth remembering. Deployment is faster — a congestion-control change ships with the app instead of a kernel upgrade — and the per-packet cost is higher, because each datagram crosses the syscall boundary individually unless the stack uses sendmmsg/recvmmsg batching or UDP GSO. Server-side QUIC stacks spend real engineering effort recovering CPU efficiency that TCP gets from TSO, GRO and kernel-resident state.
Windows and the BSDs implement the same structure with different names (Winsock, AFD, kqueue instead of epoll, different offload knobs). Do not assume SO_SNDBUF semantics, autotuning behaviour or ring-buffer sizes carry across; the shape of the path does.
Key points
send()returningnmeansnbytes were copied into the kernel send buffer, nothing more; delivery is asynchronous and gated bycwndandrwnd.- Two CPU copies (user → kernel, kernel → user) and two DMA transfers per payload; header work is pointer manipulation.
- Sending: syscall → socket buffer → TCP gate/segment → IP route → qdisc → NIC ring → wire. Receiving: NIC ring → NAPI/softirq → IP → TCP reassemble/ACK → receive buffer → wake-up → scheduler →
recv()copy. - Latency lives in queues and wake-ups, not in copies: the scheduler delay between data arriving and the application reading it is a first-class part of "network" latency.
ss -tion both ends locates the stall:Send-Qon the sender means network or windows;Recv-Qon the receiver means the application.- QUIC keeps the same structure but runs the transport rung in user space over UDP; other kernels keep the structure with different names.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does send() copy instead of transmitting directly from my buffer?
Because the kernel must keep the bytes until they are acknowledged, which may be seconds later after retransmissions, and it cannot trust your buffer to still exist or be unchanged. The copy decouples your program’s lifetime from the transport’s. Zero-Copy: Serving a File Without Touching It APIs exist precisely for the cases where that copy is the bottleneck, and they impose ownership rules in exchange.
▸Why is there a queue on the device at all — why not hand packets straight to the NIC?
The NIC drains at link rate and the CPU produces in bursts; a queue absorbs the mismatch, and a queueing discipline decides fairness between flows when the link is saturated. Without it a single bulk transfer would starve interactive traffic.
▸Why does the receiving kernel poll instead of taking one interrupt per packet?
At 1 Gbit/s small packets arrive at up to ~1.5 million per second; an interrupt each would consume the CPU. NAPI takes one interrupt, then polls a batch, then re-arms — amortising the cost across packets.
▸Why can the receive window go to zero when the network is fine?
Because the receive buffer is drained by the application, not by the network. If the application’s thread is busy, blocked, or not scheduled, data sits in the buffer, the kernel advertises less room, and the sender stops. Networking tools then show a "network" stall whose cause is entirely inside the receiving process.
send() to recv()
- Applicationsend()↓
- Socket APIlibc wrapper↓
- System callsendto → kernel↓
- Socket send buffersk_buff↓
- TCPseq · cwnd/rwnd↓
- IProute · header↓
- Device queueqdisc↓
- NICDMA · TSO↓
- Networklinks · routers
- Server NICDMA → rx ring↓
- Kernelinterrupt · NAPI↓
- IPchecksum · demux↓
- TCPreassembly · ACK↓
- Socket receive bufferrwnd↓
- Application wakesepoll_wait↓
- recv()copy to user
How it fails
What the failure looks like from inside real software.
- Application treats a successful
send()as delivery and discards its own copy; the connection dies and the data is gone. Symptom: "sent" records missing on the far side after a network blip. - A busy receiver stops calling
recv(); itsRecv-Qgrows, it advertisesrwnd0, the sender’sSend-Qfills, the sender blocks. Symptom: both processes look "stuck on the network" while the cause is one thread’s CPU work. - Nagle plus delayed ACK on a request/response protocol adds ~40 ms per small write. Symptom: tiny messages take 40–200 ms on a 1 ms network until
TCP_NODELAYis set. - RX ring overruns under a packet burst: the NIC has nowhere to DMA and drops. Symptom:
rx_missed_errorsclimbing inethtool -Swhile the application is idle. - All interrupts and softirq work pinned to one core: that core saturates at a fraction of line rate. Symptom: one CPU at 100%
siintop, the rest idle, throughput capped. - Non-blocking socket without an
EAGAINhandler: the program drops the unsent tail or spins. Symptom: truncated payloads under load, or 100% CPU with no progress.
Follow it through every layer
This lesson is one node of a longer journey. Zoom out, then zoom back in.