Internetjourneysocketkerneltcpip

Follow a Web Request Through Every Layer

The same GET / seen at fifteen rungs — browser, socket API, kernel, transport, IP, link, NIC, switch, router, NAT, ISP, internet, server network, server kernel, server process — with the exact state that changes at each one: which headers are added, which addresses are rewritten, and which are never touched.

ConceptualLinuxEducational model

The problem

The browser hands the kernel a few hundred bytes of HTTP text. Some milliseconds later a process on another continent wakes up holding the same bytes. What, precisely, touched them on the way, what did each thing change, and what did nothing change?

Progressive depth

The same mechanism at different altitudes — start where you are.

A parcel with two labels

Think of the request as a parcel. The inner label (IP address) says where in the world it is going and is never changed. The outer label (MAC) says which van picks it up next and is replaced at every depot. Depots (routers) stamp a counter (TTL) each time. The parcel contents are sealed (TLS) and only the recipient opens them. A reliable courier service (TCP) at both ends tracks what was sent and what was received and re-sends anything lost — the depots do not know or care.

The fifteen rungs

Here is the full ladder before we walk it. Notice how it is symmetric: the client side *adds* layers going down, the network *rewrites* only the outer ones, and the server side *removes* layers going up. The application data at the bottom of the client stack is bit-for-bit the data at the top of the server stack. Everything between is packaging.

Keep three facts in mind while reading. The IP header’s source and destination addresses do not change end to end — unless a NAT rewrites them, which is exactly why NAT is a special case worth its own lesson. The Ethernet header is rewritten at every router — it only ever names the next machine on the current link. And TCP state lives only at the two ends: no router in the middle knows or cares that a connection exists.

`GET /` from process to process
  1. Browserbuilds the HTTP request; picks an existing connection or opens one
  2. Socket API`connect()`, `write()` — descriptor + bytes into the kernel
  3. Kernelcopies bytes into the socket send buffer; returns immediately
  4. TCP / UDP / QUICsegments the stream, adds ports, seq/ack, tracks state
  5. IPadds src/dst IP, TTL, protocol; picks the route and the outgoing interface
  6. Ethernet / Wi-Fiadds src/dst MAC — dst is the gateway’s MAC, found by ARP/ND
  7. NICDMA from a ring buffer, computes the checksum, serialises bits onto the medium
  8. Switchreads dst MAC, forwards out one port; changes nothing
  9. Router (home, with NAT)TTL−1, rewrites src IP:port, new Ethernet header
  10. ISPmore routers: TTL−1 each, new link header each, IP payload untouched
  11. Internetautonomous systems exchanging routes with BGP
  12. Server networkedge router → load balancer → the host’s rack switch
  13. Server kernelNIC interrupt → IP → TCP demultiplex on the 4-tuple → socket receive buffer
  14. Server processwakes from `epoll_wait()`/`accept()`; `read()` returns the same bytes the browser wrote

Inside the client: browser → socket → kernel → transport

Linux

The browser has already resolved engineer-atlas.dev to, say, 203.0.113.10 and chosen a family. It asks for a socket: socket(AF_INET, SOCK_STREAM, 0) returns a file descriptor — an integer, say fd 37, indexing a per-process table whose entry points at a kernel socket object with two buffers behind it. connect(fd, 203.0.113.10:443) is the moment the kernel allocates an ephemeral source port (e.g. 54001 from Linux’s 32768–60999), creates a TCP control block in state SYN_SENT, and emits a SYN. connect() blocks (or, in non-blocking mode, returns EINPROGRESS) until the SYN-ACK arrives and the state becomes ESTABLISHED.

After TLS, the browser calls write(fd, buf, n) with the encrypted request. The system call copies the bytes into the socket’s send buffer (default ~16 kB growing to megabytes under autotuning on Linux) and returns. The application is done; nothing has left the machine yet. TCP now owns those bytes: it cuts them into segments no larger than the MSS (1460 bytes on a 1500-byte-MTU IPv4 path), stamps each with the next sequence number, the current acknowledgment number, a window, flags, and both ports, and keeps a copy until the peer acknowledges it. Sending is gated by the smaller of the receiver’s advertised window (rwnd) and the local congestion window (cwnd).

This is where the connection *is*. The kernel’s TCP control block — ESTABLISHED, the 4-tuple, snd.nxt, snd.una, rcv.nxt, the two windows, the RTT estimate and the retransmission timer — is the entire state of the conversation on this side. Routers hold none of it.

  • State at this rung: a descriptor in the process; a socket, a TCP control block and two buffers in the kernel; a source port claimed in the ephemeral range.
  • How it fails: EMFILE (descriptor table full), EADDRNOTAVAIL (ephemeral ports exhausted), ETIMEDOUT on connect(), ECONNREFUSED if a RST arrives.

Onto the wire: IP → link → NIC → switch

Each segment is handed to IP, which prepends a 20-byte IPv4 header (40 bytes for IPv6): source 10.0.0.4, destination 203.0.113.10, protocol 6 (TCP), TTL 64, total length, a header checksum. Then IP consults the routing table. 203.0.113.10 is not within 10.0.0.0/24, so the route is default via 10.0.0.1 dev wlan0 — the packet must go to the gateway, out of the Wi-Fi interface. The destination IP in the header stays 203.0.113.10; the *next hop* is a separate decision.

To reach 10.0.0.1 on the local link, the link layer needs its MAC address. The ARP cache (IPv4) or neighbor cache (IPv6) either has it or a broadcast who-has 10.0.0.1? fetches it. A 14-byte Ethernet (or the longer 802.11) header is prepended: source MAC = this laptop’s interface, destination MAC = the gateway’s MAC, EtherType 0x0800 (IPv4) or 0x86DD (IPv6). The frame is placed in a transmit ring in memory; the NIC reads it by DMA, appends a 4-byte frame check sequence, and serialises it as bits — or, on Wi-Fi, contends for airtime, encrypts with the network key, and transmits, retrying itself on a lost acknowledgment.

A switch receives the frame, reads only the destination MAC, looks it up in its MAC learning table, and copies the frame out of the one port where the gateway lives (or floods it to all ports if it has never seen that MAC). It decrements nothing, rewrites nothing, and has no concept of IP. To a switch the packet is opaque cargo with a label.

  • State added: IP header (addresses, TTL, protocol), link header (MACs, EtherType). State consulted: routing table, ARP/neighbor cache.
  • How it fails: no route (ENETUNREACH), ARP unanswered (packets silently queued then dropped, ping to the gateway fails), Wi-Fi retransmissions showing up as jitter, a switch loop flooding the segment.

Across the internet: router, NAT, ISP, autonomous systems

Educational model

The home router receives the frame addressed to *its* MAC, strips the Ethernet header, and looks at the IP header for the first time. It decrements TTL 64 → 63 (if it hit 0 it would drop the packet and send back ICMP *Time Exceeded* — the mechanism traceroute exploits), recomputes the IP header checksum, and looks up 203.0.113.10 in its own table: default via <ISP gateway> dev ppp0. Before forwarding it does one thing no ordinary router does: NAT. It rewrites the source 10.0.0.4:54001 to its public 198.51.100.7:62014, records the mapping in its translation table, and fixes up the IP and TCP checksums. Then it builds a *new* link header for the ISP-facing interface and sends.

Every subsequent router repeats the same three moves and none of the special ones: strip link header, TTL−1, longest-prefix lookup, new link header for the next hop. The destination IP is read at every hop and written at none. The TCP header is not even looked at — a core router forwards on the 20-byte IP header alone, in hardware, at hundreds of millions of packets per second. Somewhere the packet crosses from your ISP’s autonomous system into a transit provider’s and then into the hosting provider’s, along a path those networks agreed to via BGP. Nobody chose the path in advance; each AS chose its own exit.

Two things are worth tracing in your head: the TTL is a countdown that started at 64 and will read, say, 51 on arrival — the server can infer ~13 hops. And the MAC addresses in the frame that arrives at the server have nothing to do with your laptop; they belong to the last router and the server’s NIC.

  • State changed per hop: TTL, IP checksum, the whole link header. State changed once, by NAT: source IP and port, TCP checksum. State never changed: destination IP, ports (except by NAT), sequence numbers, payload.
  • How it fails: NAT table full (new connections silently dropped), an MTU black hole (large packets vanish, small ones pass), a BGP misconfiguration announcing someone else’s prefix.
The same packet at three points (educational model; addresses are documentation ranges)
hop         src MAC    dst MAC    src IP:port          dst IP:port        TTL
laptop      aa:..:01   gw:..:0a   10.0.0.4:54001       203.0.113.10:443   64
after NAT   gw:..:0b   isp:..:1f  198.51.100.7:62014   203.0.113.10:443   63
at server   r9:..:e3   srv:..:77  198.51.100.7:62014   203.0.113.10:443   51

Into the server: network → kernel → process

Linux

At the hosting provider’s edge the packet is very likely steered by a load balancer. An L4 balancer forwards it (possibly rewriting the destination to a backend’s private IP — a second NAT); an L7 balancer *terminates* the TCP connection and TLS entirely and opens a fresh connection to a backend — in which case the "server" your client shook hands with is the balancer, and the backend sees a request from the balancer’s IP with your address tucked into an X-Forwarded-For header.

On the final host the NIC writes the frame into a receive ring by DMA and raises an interrupt (or is polled under load). The kernel checks the destination MAC is its own, strips the link header, validates the IP header, sees protocol 6, and hands the segment to TCP, which demultiplexes on the 4-tuple (198.51.100.7, 62014, 203.0.113.10, 443): a lookup in a hash table of established sockets. If it is a SYN with no match it goes to the *listening* socket on port 443 instead and enters the accept queue. Data in sequence is appended to the socket’s receive buffer; an ACK is scheduled; out-of-order data is held aside.

The server process was asleep in epoll_wait() (or accept(), or recv()). The kernel marks the descriptor readable and wakes the thread. read(fd, buf, n) copies the bytes out of the receive buffer into the process’s memory — the *second* copy of the journey, mirroring write() on the client. The TLS library decrypts; the HTTP parser sees GET / HTTP/2; your handler runs. Every step of the ladder now runs in reverse for the response, with the server’s kernel as the sender and your laptop’s as the receiver — and the NAT translating the destination back to 10.0.0.4:54001 from the mapping it recorded on the way out.

  • State at this rung: a receive ring, a TCP control block on the server, a socket receive buffer, an accept queue, a readable descriptor in a sleeping process.
  • How it fails: accept queue overflow under a connection burst (SYNs dropped, clients retry after 1 s), RST when nothing listens (connection refused), a slow read()er letting the receive buffer fill so the advertised window drops to 0 and the client stalls.

What changed, what did not

Summarise the journey by field. This table is the answer to the interview question "what changes in a packet as it crosses the internet?", and it is also the map for debugging: a field that should not change but did means something in the middle is not a plain router.

Per-field behaviour end to end
FieldSet byChanged byRead by
Payload (HTTP)browsernobody (TLS-encrypted)server process
TCP seq / ack / flagsclient kernelnobodyserver kernel
TCP src portclient kernelNATserver kernel (4-tuple)
TCP dst portbrowser (443)L4 balancer, sometimesserver kernel
IP srcclient IP layerNATserver (reply address)
IP dstclient IP layerNAT on return, L4 balancerevery router
TTL / hop limitclient (64)every router (−1)routers (drop at 0)
Src / dst MACeach sender on a linkevery router (full rewrite)next NIC and switches

Key points

  • write() copies bytes into a kernel send buffer and returns; TCP owns delivery from there. The process is not involved in retransmission.
  • The connection exists only at the ends: a TCP control block in each kernel. Routers hold no per-connection state — unless one of them is a NAT.
  • IP source and destination stay fixed end to end; NAT is the exception that rewrites source (outbound) and destination (return).
  • The link header names only the next machine on the current link and is rebuilt at every router. The MAC that reaches the server is the last router’s.
  • TTL is decremented at every router and is the only IP header field that routinely changes; it is what makes traceroute and loop-protection possible.
  • The server kernel demultiplexes on the 4-tuple and appends in-order data to a receive buffer; read() is the second and last copy.
  • Every rung has a symptom: EADDRNOTAVAIL, ARP timeouts, NAT drops, MTU black holes, accept-queue overflow, zero windows.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why does the link header get rewritten at every hop when the IP header does not?

Because they answer different questions. The IP header says "where is this going" for the whole trip; the link header says "who on this cable should pick it up" for one hop. Reusing the link header across links would be like leaving the same courier’s name on a parcel for every leg of its journey.

Why does the kernel copy bytes in `write()` instead of sending them straight from my buffer?

TCP must retransmit unacknowledged data, possibly seconds later, so it needs a copy the application cannot overwrite. Zero-copy paths (sendfile, MSG_ZEROCOPY) exist and come with exactly that complication — see Zero-Copy: Serving a File Without Touching It.

Why do routers not look at TCP?

Forwarding must happen at line rate for every packet; the 20-byte IP header holds everything needed, and TCP state would have to be kept per connection, per router, for billions of connections. Middleboxes that do look (NATs, firewalls, L4 balancers) are exactly the ones that break things and need state tables.

Why does the client kernel pick the source port and not the browser?

Only the kernel knows which ports are free and can guarantee the 4-tuple is unique; the application does not care what the number is, only that replies find their way back to its socket.

Follow a request through every layer

zoom
Follow a request
One GET, every layer it crosses. Watch which fields each hop is allowed to touch.
Client
Network
Server
Packet stateEducational model
dst MAC (per hop)
src IP:port (NAT)
192.168.1.10:51234
dst IP:port
93.184.216.34:443
TTL
TCP seq
payload
GET / HTTP/1.1 (plaintext, in-process)
The browser builds `GET / HTTP/1.1` and hands it to its TLS library; the socket will only ever see ciphertext.
1/8 · browser

How it fails

What the failure looks like from inside real software.

  • EADDRNOTAVAIL from connect(): the client has exhausted ephemeral ports to one destination — typical of a service opening a new connection per request instead of pooling.
  • Silent drops after the NAT: the home router’s or CGNAT’s translation table is full; existing connections work, new ones hang in SYN_SENT.
  • MTU black hole: the handshake succeeds (small packets) but the first full-size data segment vanishes at a link with a smaller MTU whose ICMP *Fragmentation Needed* is filtered. Symptom: curl hangs after TLS, small responses work, large ones do not.
  • Accept queue overflow on the server: a burst of connections exceeds the listen backlog; the kernel drops SYNs, clients retry after 1 s, then 3 s. Latency spikes with an idle CPU.
  • Zero window: the server process stops reading (blocked on a database call), its receive buffer fills, it advertises window 0, and the client’s write() blocks — backpressure surfacing three layers up.
  • A TTL that expires in transit: a routing loop between two misconfigured routers; traceroute shows the same pair alternating until hop 30.

Follow it through every layer

This lesson is one node of a longer journey. Zoom out, then zoom back in.