TCP/IP & the OSI Model
What really happens to your bytes between fetch() and the server: layers, encapsulation, IP addresses and subnets, routing hop by hop, ports and sockets, MTU — and where the neat layer picture leaks in production.
The 30-second version
Here's one line of JavaScript:
const res = await fetch("https://example.com/");Your code just sent a request and got an answer. But between the two machines there might be 15 boxes, two oceans' worth of cable, and a Wi-Fi radio. None of them knows what HTTP is. So how does it work?
What happens to that one line
Roughly, in order:
- Find the address. The name
example.comis looked up with DNS and turned into an IP address, a number like203.0.113.10. (The DNS topic covers this step.) - Open a connection. Your OS opens a TCP connection to that address on port 443, the "door" where web servers wait for HTTPS. (The TCP & UDP topic covers how.)
- Wrap and send. Your request is wrapped in a few headers and cut into packets. Each packet travels on its own, hopping from router to router until it reaches the server.
- Unwrap and answer. The server's OS strips the headers off again, hands the request to the web server program, and the answer comes back the same way.
This topic is about steps 3 and 4: how bytes actually travel. We'll use plain http:// in most examples, because the encryption that https adds (TLS, see the TLS topic) is one more wrapper and doesn't change the picture.
Letters, envelopes and mailbags
That nesting has a name: encapsulation. It's the single most important idea in networking, and §03 animates it byte by byte.
What you'll be able to do after this topic
- Explain what "L4" and "L7" mean when someone says "L7 load balancer" (§02).
- Read an address like
10.0.0.0/16and know which machines are in it (§04). - Debug the classic "works on my laptop, not in Docker" bug in under a minute (§06).
- Understand how one server port handles 10,000 clients at once (§07).
- Read a real packet capture (§09), and know where the clean layer picture breaks in production (§10).
Four layers that run, seven that we name
Why layers at all? Because one giant program that handles Wi-Fi radio and HTTP and lost packets would be impossible to change. With layers, Wi-Fi can be swapped for fibre without touching your browser, and HTTP/2 can replace HTTP/1.1 without touching a single router.
The four layers, bottom to top
| Layer | Its one job | Addresses things by | Example |
|---|---|---|---|
| Link | Move data across one hop (one cable, one Wi-Fi hop) | MAC address | Ethernet, Wi-Fi |
| Internet | Move a packet from machine to machine, anywhere in the world | IP address | IPv4, IPv6 |
| Transport | Deliver data to the right program on that machine (and, for TCP, make it reliable) | port | TCP, UDP |
| Application | Decide what the bytes mean | URL, hostname | HTTP, DNS, gRPC |
Read the "job" column from the bottom row up and you have the whole story of §01: an application message, delivered to a program (transport), on a machine (internet), one hop at a time (link).
Same thing, two maps
The OSI model splits the same territory into seven layers. Two of its extra layers (Session, Presentation) never became separate pieces of software on the internet; in practice they're part of the application. The bottom one, Physical, is the actual cable or radio signal.
Where you'll hear the OSI numbers
You'll almost never need the OSI model to build something, but you'll hear its numbers every week:
- "L3" = the IP layer. "An L3 problem" means routing or addressing.
- "L4" = TCP/UDP. An "L4 load balancer" forwards connections by IP address and port, without reading the data inside.
- "L7" = the application. An "L7 load balancer" or "L7 firewall" reads the HTTP request (path, headers) and decides based on that. The load balancing topic covers the trade-off.
- "L2" = the link layer: MAC addresses, switches, Wi-Fi.
Nobody says "L5" or "L6" with a straight face.
The most important row: IP is "best effort"
Look again at what the internet layer promises. It's best effort: each router does its best to forward each packet, but a packet may be:
- dropped, because a router's queue was full or a Wi-Fi frame was damaged,
- duplicated, which is rare, but it happens,
- reordered, because packet 2 took a faster path than packet 1,
- delayed by any amount.
And IP doesn't tell anyone when this happens. That's not a bug. It's the design choice that made the internet cheap and scalable: routers stay simple and forget each packet the moment they've forwarded it.
Encapsulation: envelopes inside envelopes
Your code only touches the top layer
Here's an HTTP request sent by hand, with no HTTP library (new to Go? see the Go primer). HTTP really is just text written into a TCP connection:
conn, err := net.Dial("tcp", "example.com:80") // open a TCP connection
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// The HTTP request is just text. We write it ourselves:
fmt.Fprint(conn, "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
io.Copy(os.Stdout, conn) // print whatever the server sends backNotice what's not in that code: no IP addresses in headers, no packet sizes, no MAC addresses, no resending. You write bytes to a socket, and the operating system does every layer below. Now let's watch it do that.
Down, across, and up
Step through it. The colours match the layers from §02: purple application, cyan transport, amber internet, pink link.
curl http://example.com/) writes 74 bytes of HTTP text to a socket (the OS's handle for a connection). That's all your code ever does. Everything below happens inside the operating system.The names change as the data goes down: the transport layer makes a segment, the internet layer makes a packet, the link layer makes a frame. People mix these words up all the time ("packet" is often used for all of them), but if someone says "frame" they mean the link layer.
The numbers
| Header | Size | The fields that matter for this topic |
|---|---|---|
| Ethernet | 14 B header + 4 B trailer | destination MAC, source MAC, "what's inside" (IPv4 or IPv6) |
| IPv4 | 20 B (IPv6: 40 B) | source IP, destination IP, TTL (a hop counter, §05), "what's inside" (TCP or UDP) |
| TCP | 20 B (usually 32 B with common options) | source port, destination port, sequence number (which byte of the stream this is), flags (open / close / "got it" markers) |
| UDP | 8 B | source port, destination port, length, checksum |
Two worked examples:
- A tiny request, the 74 bytes of HTTP that
curl http://example.com/sends: 74 + 20 + 20 + 14 + 4 = 132 bytes on the wire, counting a minimal 20-byte TCP header (with the usual 12 bytes of options it's 144, as in the real capture in §09). 58 of those are headers. That's 44% overhead. - A full-size packet in a big download: Ethernet carries at most 1500 bytes of IP packet (that limit is the MTU, more in §08). After 20 B of IP and 20 B of TCP, 1460 B are left for your data. The frame is 1518 B, so headers are only about 4%.
That's why many small messages cost far more than one big one, and why protocols try to batch.
IP addresses, subnets and /24
/24 in 192.168.1.0/24 tells you where the split is.An IPv4 address is just a 32-bit number
192.168.1.37 looks like four numbers, but it's really one 32-bit number (32 binary digits, each 0 or 1) written in four pieces of 8 bits (one byte each). Each piece goes from 0 to 255. That's 2³² ≈ 4.3 billion possible addresses, which turned out to be far too few for every phone and laptop on Earth. More on that in a moment.
What /24 means
A subnet is a block of addresses that share their network part. We write it in CIDR notation: an address, a slash, and how many leading bits are the network part.
Worked example, 192.168.1.0/24:
- 24 bits are fixed: the first three numbers,
192.168.1. - 8 bits are free: the last number, from 0 to 255. That's 2⁸ = 256 addresses.
- Two are reserved:
.0names the network itself, and.255is the broadcast address ("everyone on this subnet"). - So 254 machines can get an address:
192.168.1.1to192.168.1.254.
Every bit you take away from the prefix doubles the block: /23 = 512 addresses, /16 = 65,536, /8 = about 16.7 million. Every bit you add halves it: /30 = 4 addresses (2 usable), and /32 is exactly one address.
You'll also see the same split written as a subnet mask: 255.255.255.0 is the same as /24 (24 one-bits followed by 8 zero-bits). (255 in binary is 11111111: eight one-bits.) Older tools and Windows settings show masks; cloud consoles and Kubernetes use CIDR.
When the cut falls inside a byte
/8, /16 and /24 are easy because the split lands between the dotted numbers. Other prefixes split a number in the middle, so you have to look at its bits. Worked example, 10.0.5.0/26:
- 26 = 24 + 2: the first three numbers are fixed, plus the first 2 bits of the last number.
- That leaves 6 host bits: 2⁶ = 64 addresses, from
.0(bits00|000000) to.63(bits00|111111). - Network address
10.0.5.0, broadcast10.0.5.63, so 62 usable hosts. - The next address,
.64, is01|000000. A network bit changed, so it belongs to the next block,10.0.5.64/26.
For any address and prefix, drag the slider and watch the purple network bits grow:
Why the split matters: direct, or through a router?
Every machine knows its own address and prefix. Before sending a packet, it asks one question: is the destination in my subnet?
- Yes → it can reach that machine directly on the local network (same Wi-Fi, same switch).
- No → it sends the packet to its router, which forwards it onward. That's §05.
This is why a wrong prefix causes weird bugs. If a machine thinks it's in a /16 but is really in a /24, it will try to reach "neighbours" directly that are actually behind a router, and those packets go nowhere.
Addresses with special meanings
Some ranges are reserved. You'll meet these constantly:
| Range | Meaning |
|---|---|
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | Private: for home, office and cloud networks. Never routed on the internet; NAT translates them (§06). |
127.0.0.0/8 (usually 127.0.0.1) | Loopback: "this machine". Packets never leave it. localhost is its name. |
0.0.0.0 | As a listen address: "every interface on this machine" (§06). Not a destination you connect to. |
169.254.0.0/16 | Link-local: a machine picks one itself when nobody hands it an address. Clouds put their metadata service (where a cloud VM asks "who am I, and what are my credentials?") at 169.254.169.254. |
100.64.0.0/10 | Carrier-grade NAT: your ISP (internet provider) shares one public address among many customers. |
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 | Reserved for documentation. This topic uses them for every "public" address, so no example points at a real machine. |
IPv6, briefly
IPv6 fixes the shortage with 128-bit addresses, written as eight groups of four hex digits: 2001:0db8:0000:0000:0000:0000:0000:0001. Leading zeros can be dropped, and one run of all-zero groups can be written as ::, so that's 2001:db8::1.
The ideas carry over unchanged: CIDR prefixes (a normal network is a /64), routers, subnets. A few equivalents to remember: ::1 is loopback, fe80::/10 is link-local, and :: as a listen address means "every interface". With so many addresses, IPv6 doesn't need NAT, but it still needs a firewall.
Checking addresses in code
import (
"fmt"
"net/netip"
)
// inside a function:
subnet := netip.MustParsePrefix("192.168.1.0/24")
ip := netip.MustParseAddr("192.168.1.37")
fmt.Println(subnet.Contains(ip)) // true
fmt.Println(subnet.Contains(netip.MustParseAddr("192.168.2.10"))) // false
fmt.Println(ip.IsPrivate(), ip.IsLoopback()) // true falseRouting: one hop at a time
Your laptop's routing table
Every machine has a routing table, even your laptop. On Linux, ip route shows it (macOS: netstat -rn). A typical one at home:
$ ip route
default via 192.168.1.1 dev wlan0
192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.37Read it line by line:
192.168.1.0/24 dev wlan0: "Addresses in my own subnet: send them directly, out of the Wi-Fi interfacewlan0." This is the "same subnet → direct" rule from §04.default via 192.168.1.1: "Everything else: hand it to 192.168.1.1." That router is your default gateway, usually your Wi-Fi box.defaultis short for0.0.0.0/0, a prefix with zero fixed bits, which matches every address.
Your laptop got both lines automatically when it joined the Wi-Fi. A service on the router called DHCP handed it an address, a prefix and the gateway.
The rule: most specific match wins
A router on the internet has many more rows. When several rows match, it picks the one with the longest prefix, the most specific one. Worked example: a packet for 203.0.113.10 arrives at a router with this table:
| Prefix | Next hop | Matches 203.0.113.10? |
|---|---|---|
0.0.0.0/0 (default) | upstream provider | ✓ (matches everything) |
203.0.0.0/8 | router A | ✓ (first 8 bits match) |
203.0.113.0/24 | router B | ✓ (first 24 bits match), longest, so it wins |
198.51.100.0/24 | router C | ✕ |
The packet goes to router B. This is called longest-prefix match, and it's how roughly a million table rows (the approximate size of the full IPv4 internet table today) can cover billions of addresses: broad rows for "roughly that way", specific rows where it matters.
Who writes the tables of the internet's big routers? Networks (ISPs, clouds, universities) announce to their neighbours "I can reach 203.0.113.0/24" using a protocol called BGP, and routers build their tables from those announcements. When someone announces the wrong thing by mistake, large parts of the internet can become unreachable for hours. That really happens.
TTL: the packet's self-destruct counter
What if two routers' tables point at each other by mistake? A packet would bounce between them forever. To prevent that, every IP packet carries a TTL counter. The sender sets it (Linux and macOS use 64, Windows 128), and every router subtracts 1. When it reaches 0, the router drops the packet and sends a small error message back to the sender using ICMP, the internet layer's "error and status" protocol.
That error message is what makes the most useful network tool possible. Watch both:
203.0.113.10 isn't in my subnet 192.168.1.0/24 → use the default route 0.0.0.0/0 → send to my gateway 192.168.1.1.
traceroute: seeing the path yourself
traceroute exploits TTL on purpose: it sends a probe with TTL 1 (the first router drops it and replies), then TTL 2, then 3, and so on, until the destination itself answers. Each reply reveals one router and the round-trip time (RTT) to it.
traceroute example.com # Linux, macOS
tracert example.com # Windows
mtr example.com # Linux/macOS: a live, continuously updating tracerouteHow to read the output:
- Each line is cumulative. "88 ms" at hop 5 means the round trip from you to hop 5, not the time between hops 4 and 5.
- A big jump between two hops usually means distance. Light in fibre covers about 200 km per millisecond, so crossing an ocean adds tens of milliseconds and no software can fix that.
* * *in the middle is usually harmless: that router just doesn't reply to probes. Only worry if every line after a point is stars.- One slow hop with normal hops after it isn't a problem either. Routers answer probes with low priority, but they forward real traffic at full speed.
NAT, localhost and 0.0.0.0
127.0.0.1 means "only this machine may talk to me".NAT: sharing one public address
§04 said private addresses like 192.168.1.37 are never routed on the internet. Yet your laptop browses the web fine. The trick is NAT: your home router keeps a table and rewrites addresses and ports on every packet that crosses it.
Three consequences you'll run into:
- Nobody can connect in by default. NAT only has table rows for conversations started from inside. To run a server at home you add a manual "port forwarding" row, or use a tunnel service (e.g. ngrok or Cloudflare Tunnel, which relay traffic in through a connection your machine opened outward).
- Idle connections get forgotten. NAT rows expire after a period of silence, often minutes, sometimes less. A database connection that sits idle behind NAT can be silently dead when you next use it. TCP keepalive probes and application heartbeat messages exist partly to keep those rows alive.
- You might be behind two NATs. Many ISPs, especially mobile ones, put customers behind carrier-grade NAT, so even your router's "public" address is shared.
One machine, several addresses
A machine doesn't have an IP address. Each interface has its own. Your laptop has at least two:
lo(loopback), address127.0.0.1: a fake interface that only leads back to the same machine.wlan0oren0(Wi-Fi), address192.168.1.37: the one other devices can reach.
When a server starts, it binds its listening socket to an address and a port. The address decides which interfaces it hears:
| Listen on | Who can connect |
|---|---|
127.0.0.1:3000 (or localhost:3000) | Only programs on this same machine |
192.168.1.37:3000 | Anyone who can reach that one interface |
0.0.0.0:3000 | Any IPv4 address on any interface ("all of them") |
[::]:3000 | Any IPv6 address; on Linux, usually IPv4 too |
// Pick ONE of these two lines.
// Option A: only this machine can connect.
ln, err := net.Listen("tcp", "127.0.0.1:3000")
// Option B: every interface (no host part = all addresses).
// ln, err := net.Listen("tcp", ":3000")
if err != nil {
log.Fatal(err)
}
defer ln.Close()
// net/http follows the same rule:
// http.ListenAndServe(":3000", handler) → all interfaces
// http.ListenAndServe("127.0.0.1:3000", handler) → this machine only"Works on my laptop, not in Docker"
A container gets its own network stack, using a Linux kernel feature called namespaces. Inside, it has its own lo with its own 127.0.0.1, plus its own eth0 with an address like 172.17.0.2. When you run docker run -p 8080:3000, Docker's port publishing forwards host port 8080 to the container's eth0, port 3000.
If your server listens on 127.0.0.1 inside the container, it's listening on the container's private loopback, which nothing from outside can ever reach. Toggle it:
The fix is one word: listen on 0.0.0.0 (or leave the host out). Many dev tools default to localhost for safety. Vite, for example, needs --host to listen on all interfaces. So this bug shows up the first time you containerize something.
Ports, sockets and the 4-tuple
Ports: apartment numbers
A port is a 16-bit number, 0 to 65535, in the TCP or UDP header. If the IP address is the building, the port is the apartment. Some numbers are conventions everyone knows:
| Port | Usually |
|---|---|
| 22 | SSH |
| 53 | DNS |
| 80 / 443 | HTTP / HTTPS |
| 5432 / 3306 / 6379 | Postgres / MySQL / Redis |
On Linux, ports below 1024 need extra privileges to listen on, which is why dev servers like to use 3000, 5173 or 8080.
Your side has a port too
When you connect to 203.0.113.10:443, your packets also need a source port, so the replies can find their way back to your program. You don't choose it. The OS picks a free ephemeral port from a range (on Linux, 32768–60999 by default; check with cat /proc/sys/net/ipv4/ip_local_port_range).
So every connection has four addresses, the 4-tuple:
src IP src port dst IP dst port
192.168.1.37 51234 → 203.0.113.10 443Two connections can share three of the four values, but never all four. Open two browser tabs to the same site and the only difference is the source port. (Strictly, the protocol, TCP or UDP, is a fifth value, so you'll also hear "5-tuple".)
One listening socket, many connected sockets
A server has two kinds of sockets:
- The listening socket, bound to port 443. It never carries any request data. Its only job is to accept new clients.
- One connected socket per client, created each time a client is accepted. Each is a separate file descriptor with its own 4-tuple.
When a packet arrives, the kernel looks up its 4-tuple and hands it to the matching socket. Try it:
| socket | src IP | src port | dst IP | dst port |
|---|---|---|---|---|
| fd 3 · LISTEN | * | * | 203.0.113.10 | 443 |
So what limits a server to N clients? Not port numbers: the server uses one port for all of them. The real limits are the number of open file descriptors the OS allows (see ulimit) and memory, which on a modern Linux box allows hundreds of thousands of connections.
A complete echo server and client
Here's the whole thing in a few lines. The server repeats back every line it receives; the client sends one line and prints the answer. You can mix them: the Go client works with the Node server and vice versa.
package main
import (
"bufio"
"fmt"
"log"
"net"
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:9000") // ← bind + listen
if err != nil {
log.Fatal(err)
}
for {
conn, err := ln.Accept() // ← waits for a client; returns a NEW socket
if err != nil {
log.Println(err)
continue
}
go handle(conn) // one goroutine per connection
}
}
func handle(conn net.Conn) {
defer conn.Close()
log.Printf("new connection: %s → %s", conn.RemoteAddr(), conn.LocalAddr())
sc := bufio.NewScanner(conn)
for sc.Scan() { // one line at a time
fmt.Fprintf(conn, "echo: %s\n", sc.Text())
}
}package main
import (
"bufio"
"fmt"
"log"
"net"
)
func main() {
conn, err := net.Dial("tcp", "127.0.0.1:9000") // ← OS picks our source port
if err != nil {
log.Fatal(err)
}
defer conn.Close()
fmt.Println("me:", conn.LocalAddr(), "server:", conn.RemoteAddr())
fmt.Fprintf(conn, "hello\n")
reply, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
log.Fatal(err)
}
fmt.Print(reply)
}Run a client twice (here the Go one, then the Node one) and the output shows the ephemeral port changing:
$ go run client.go
me: 127.0.0.1:44884 server: 127.0.0.1:9000
echo: hello
$ node client.mjs
me: 127.0.0.1:44888 server: 127.0.0.1:9000
echo: helloThe errors you'll see
| What happened | Error you'll see (Go, then Node) |
|---|---|
| Nobody listening on that address and port | dial tcp 127.0.0.1:9000: connect: connection refusedconnect ECONNREFUSED 127.0.0.1:9000 |
| Another program already listens there | listen tcp 127.0.0.1:9000: bind: address already in uselisten EADDRINUSE: address already in use 127.0.0.1:9000 |
| No free source port left (port exhaustion, §11) | connect: cannot assign requested addressconnect EADDRNOTAVAIL … |
| No answer at all (firewall silently drops, wrong IP) | dial tcp …: i/o timeout (when you set a dial timeout)connect ETIMEDOUT … (after a long OS default) |
"Connection refused" is actually good news when debugging: the machine is reachable and answered "nobody's home on that port". A timeout means packets vanished somewhere: a firewall, a wrong route, or a machine that's down.
Down to the wire: MAC, ARP and MTU
MAC addresses: names for the next hop
Every network card has a MAC address, 48 bits written in hex: 3c:22:fb:1a:7e:09. The link layer (Ethernet, Wi-Fi) uses it to deliver a frame to one device on the same local network. A switch (or a Wi-Fi access point) learns which MAC sits behind which of its ports and forwards each frame there. It never looks at IP addresses.
MAC addresses never cross a router. As you saw in §03, each router throws the old frame away and builds a new one with its own MAC as the source and the next router's as the destination. IP addresses stay the same for the whole trip (unless a NAT rewrites them, §06); MAC addresses change at every hop.
ARP: "who has 192.168.1.1?"
Your laptop wants to send a packet to 203.0.113.10. The routing table (§05) says: "not local, send it to the gateway 192.168.1.1". But Ethernet and Wi-Fi need a MAC address to put on the frame. Where does that come from?
ARP:
- The laptop broadcasts to everyone on the local network: "Who has 192.168.1.1? Tell 192.168.1.37."
- The router replies, to the laptop only: "192.168.1.1 is at a4:91:b1:0c:22:5e."
- The laptop caches the answer for a while, so the next packets go straight out.
You can see the cache with ip neigh on Linux (arp -a on macOS and Windows):
$ ip neigh
192.168.1.1 dev wlan0 lladdr a4:91:b1:0c:22:5e REACHABLENote what the laptop does not do: it never asks for the MAC of 203.0.113.10. That server is on another network, and its MAC is meaningless here. You only ever ARP for machines in your own subnet, which most of the time means the gateway.
MTU: the biggest packet a link will carry
Every link has a maximum packet size, its MTU. Typical values:
| Link | MTU |
|---|---|
| Ethernet, most Wi-Fi | 1500 bytes |
| Loopback on Linux | 65,536 bytes (it's just memory) |
| Data-center "jumbo frames" | ~9000 bytes |
| VPN tunnels, home DSL (PPPoE), cloud overlay networks | less than 1500, e.g. 1492, 1450 or 1420, because they add their own headers |
That last row is where trouble starts. Your laptop's link is 1500, the server's link is 1500, but a VPN tunnel in the middle can only carry 1420. What happens to a 1500-byte packet when it reaches that tunnel?
There are two possible answers:
- Fragmentation. In IPv4, the router can cut the packet into smaller fragments, each with its own IP header, and the receiver glues them back together. It works, but it's slow, and losing any one fragment means losing the whole packet. IPv6 routers never do this.
- Drop and tell. If the packet has the Don't Fragment (DF) bit set, the router drops it and sends an ICMP message back: "too big, my limit is 1420". Linux and most other systems set DF on TCP traffic by default.
The second answer is the basis of path MTU discovery: the sender learns the smallest MTU on the path and sends smaller packets from then on. TCP helps too. When a connection opens, each side announces its MSS (maximum segment size), normally the local MTU minus 40 bytes of headers: 1500 − 20 (IP) − 20 (TCP) = 1460. You'll see mss 1460 in packet captures (§09).
The PMTUD black hole
Path MTU discovery depends on one thing: the ICMP "too big" message making it back. Some networks block all ICMP "for security". When that happens:
- The connection opens fine. The handshake packets are tiny.
- Small requests and responses work.
- The first full-size packet disappears without a trace, and so does every retry. The download stalls forever.
The symptoms look like an application bug ("only big responses hang", "works on home Wi-Fi, not on the VPN"), but the cause is three layers down. Fixes, in order of preference: let ICMP "fragmentation needed" (IPv4) and "packet too big" (ICMPv6) through; have the tunnel rewrite the MSS in TCP handshakes to a smaller value ("MSS clamping"); or lower the MTU on the affected interface.
Seeing it: ip, ss and tcpdump
The commands below are Linux (they're also what you'll have inside most containers and servers). macOS equivalents are listed at the end of the section.
ip addr: my interfaces and addresses
$ ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
inet6 ::1/128 scope host
3: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP
link/ether 3c:22:fb:1a:7e:09 brd ff:ff:ff:ff:ff:ff
inet 192.168.1.37/24 brd 192.168.1.255 scope global dynamic wlan0
inet6 fe80::3e22:fbff:fe1a:7e09/64 scope linkEverything from earlier sections is here: the loopback interface lo with 127.0.0.1/8 and ::1 (§04), the Wi-Fi interface with its MAC (link/ether, §08), its IPv4 address and prefix 192.168.1.37/24 with the broadcast address, an IPv6 link-local address, and each interface's MTU.
ip route: where packets go
You met this in §05. default via … is the gateway; the other lines are directly connected subnets. To ask "which route would a packet to X use?", there's a handy shortcut:
$ ip route get 203.0.113.10
203.0.113.10 via 192.168.1.1 dev wlan0 src 192.168.1.37ss: sockets
ss lists sockets. Recv-Q/Send-Q are bytes waiting in the kernel; on LISTEN rows they show how many connections have finished the handshake but haven't been accepted by your program yet, and the maximum allowed (the accept backlog). The flags you'll use most: -t TCP, -u UDP, -l listening only, -n show numbers instead of names, -p show the owning process (needs root for other users' processes).
$ ss -tlnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=812,fd=6))
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=640,fd=3))
LISTEN 0 511 127.0.0.1:5173 0.0.0.0:* users:(("node",pid=4120,fd=20))
LISTEN 0 4096 *:3000 *:* users:(("server",pid=5301,fd=3))This answers "is my server listening, and on which address?" in one line, which is the §06 bug at a glance. Postgres and the Vite dev server (port 5173) are local-only; sshd listens on every IPv4 interface; the Go server on 3000 shows *, every address, IPv4 and IPv6. Drop the -l (ss -tnp) to see established connections with their full 4-tuples (§07).
tcpdump: the packets themselves
tcpdump prints every packet that crosses an interface. Here's a capture of curl http://example.com/ (plain HTTP so we can read it; with HTTPS the payload would be encrypted). -nn means "don't turn addresses and ports into names". The output is trimmed: long options […] fields are shortened, and your numbers will differ.
$ sudo tcpdump -i wlan0 -nn 'host 203.0.113.10 and port 80'
10:32:48.815086 IP 192.168.1.37.51234 > 203.0.113.10.80: Flags [S], seq 4135661311, win 64240, options [mss 1460,…], length 0
10:32:48.906211 IP 203.0.113.10.80 > 192.168.1.37.51234: Flags [S.], seq 3095193394, ack 4135661312, win 65160, options [mss 1460,…], length 0
10:32:48.906290 IP 192.168.1.37.51234 > 203.0.113.10.80: Flags [.], ack 1, win 502, length 0
10:32:48.906402 IP 192.168.1.37.51234 > 203.0.113.10.80: Flags [P.], seq 1:75, ack 1, win 502, length 74: HTTP: GET / HTTP/1.1
10:32:48.997815 IP 203.0.113.10.80 > 192.168.1.37.51234: Flags [.], ack 75, win 509, length 0
10:32:48.998127 IP 203.0.113.10.80 > 192.168.1.37.51234: Flags [P.], seq 1:1257, ack 75, win 509, length 1256: HTTP: HTTP/1.1 200 OK
10:32:48.998190 IP 192.168.1.37.51234 > 203.0.113.10.80: Flags [.], ack 1257, win 501, length 0
10:32:48.998542 IP 192.168.1.37.51234 > 203.0.113.10.80: Flags [F.], seq 75, ack 1257, win 501, length 0
10:32:49.089617 IP 203.0.113.10.80 > 192.168.1.37.51234: Flags [F.], seq 1257, ack 76, win 509, length 0
10:32:49.089680 IP 192.168.1.37.51234 > 203.0.113.10.80: Flags [.], ack 1258, win 501, length 0How to read one line: time, IP, source IP.port > destination IP.port, then the TCP details. The Flags are TCP's: S = SYN (open), . = ACK (got it), P = push (here's data), F = FIN (I'm done). What the ten packets show:
- Lines 1–3: TCP's opening handshake (SYN, SYN-ACK, ACK). Both sides announce
mss 1460: a 1500-byte MTU minus 40 bytes of headers (§08). Notice the ~91 ms gap between line 1 and line 2: that's one round trip to the server. - Line 4: your HTTP request, 74 bytes. Port 51234 is curl's ephemeral port (§07).
- Lines 5–7: the response, 1256 bytes, and the acknowledgements in both directions.
- Lines 8–10: closing, a FIN from each side.
After the handshake, tcpdump shows sequence numbers relative to the start of the connection, which is why seq 4135661311 becomes seq 1:75. What the handshake, seq, ack and win numbers mean is the TCP & UDP topic's job. What matters here: you can see the 4-tuple, the ports, the round-trip time and the data, all in plain text.
Every layer in one packet
Add -e (show the link-layer header) and -v (verbose IP details) and look at just the request packet. Every layer from §03 is visible:
$ sudo tcpdump -i wlan0 -nn -e -v 'host 203.0.113.10 and port 80'
10:32:48.906402 3c:22:fb:1a:7e:09 > a4:91:b1:0c:22:5e, ethertype IPv4 (0x0800), length 140: (tos 0x0, ttl 64, id 23702, offset 0, flags [DF], proto TCP (6), length 126)
192.168.1.37.51234 > 203.0.113.10.80: Flags [P.], cksum 0x1c3a (incorrect -> 0xc4fa), seq 1:75, ack 1, win 502, options […], length 74: HTTP, length: 74
GET / HTTP/1.1
Host: example.com
User-Agent: curl/8.5.0
Accept: */*| You see | Layer | Meaning |
|---|---|---|
3c:22:… > a4:91:… | Link | From the laptop's MAC to the router's MAC (not the server's: §08) |
length 140 (first one) | Link | Whole frame: 14 B Ethernet header + 126 B IP packet (captures don't include the 4-byte FCS trailer) |
ttl 64 | Internet | Fresh from the sender; each router will subtract 1 (§05) |
flags [DF] | Internet | Don't Fragment is set, so path MTU discovery is on (§08) |
proto TCP (6), length 126 | Internet | What's inside, and the IP packet's size: 20 B IP + 32 B TCP + 74 B data |
192.168.1.37.51234 > 203.0.113.10.80 | Internet + Transport | The 4-tuple |
length 74 + the text | Application | Your HTTP request, byte for byte |
The TCP header here is 32 bytes, not 20: 12 bytes of options (timestamps) ride along on every packet.
macOS and Windows equivalents
| Linux | macOS | Windows |
|---|---|---|
ip addr | ifconfig | ipconfig /all |
ip route | netstat -rn | route print |
ss -tlnp | lsof -iTCP -sTCP:LISTEN -n -P | netstat -ano |
ip neigh | arp -a | arp -a |
tcpdump | tcpdump (built in) | Wireshark / pktmon |
Where the layers leak (staff-level)
1. MTU: tunnels, overlays and Kubernetes
Every tunnel wraps packets in more headers, which shrinks the room left for your data. VPNs do it, and so do cloud and Kubernetes overlay networks. VXLAN, a common overlay, adds 50 bytes, so pods get an MTU of 1450 on a 1500-byte network. If any part of the system still assumes 1500, and ICMP "too big" is filtered somewhere, you get the §08 black hole. It tends to show up under rare conditions: only in one cluster, only for large responses, only through one VPN.
What staff engineers do about it:
- Know the MTU of every hop you own:
ip linkon nodes, the CNI (Kubernetes network plugin) config, the cloud's documented values (some clouds support 9000-byte jumbo frames inside a VPC but 1500 or less across VPNs and peering). - Clamp the MSS at tunnel edges, so TCP never builds segments that won't fit, even if ICMP is lost.
- Allow ICMP "fragmentation needed" / "packet too big" in security groups and firewalls. Blocking it buys no real security and breaks PMTUD.
- Linux can also probe for the right size without relying on ICMP (the
net.ipv4.tcp_mtu_probingsetting), a useful safety net on hosts behind networks you don't control.
2. TCP over lossy Wi-Fi and mobile links
TCP was designed when lost packets almost always meant one thing: a router somewhere was overloaded. So when TCP sees loss, it slows down. On Wi-Fi and mobile links, packets are also lost to radio interference, which slowing down doesn't fix. The link layer partly hides this by retrying frames itself, but every retry adds delay and jitter (varying delay). The result is a familiar mix: low throughput even though the "bandwidth" is high, and latency spikes that look like server slowness. Link-layer behaviour leaks straight into transport-layer performance. How TCP's congestion control decides how fast to send is covered in the TCP & UDP topic.
3. Middleboxes: why protocols "ossify"
In theory only the two endpoints read the TCP header. In practice, middleboxes (firewalls, NAT, corporate proxies, carrier equipment) read it, rewrite it, and sometimes drop packets with options they don't recognise. So adding new features to TCP became nearly impossible, because some box somewhere would break. Engineers call this ossification.
This is a big part of why QUIC (the transport under HTTP/3) runs over UDP and encrypts almost all of its own headers: if middleboxes can't read the headers, they can't depend on them, and the protocol can keep evolving. See the HTTP topic. NAT idle timeouts (§06) are another middlebox leak: long-lived idle connections die silently unless something sends keepalive probes or heartbeat messages.
4. Anycast: one address, many places
Normally one IP address means one machine. With anycast, many sites announce the same prefix through BGP, and each router sends packets to the nearest one. That's how 1.1.1.1 and 8.8.8.8 answer from your own region, how a CDN sends you to a nearby edge, and how DNS root servers survive attacks.
The leak: "nearest" means nearest by routing, which isn't always nearest geographically. Also, a routing change in the middle of a connection can move your packets to a different site that has never heard of your TCP connection. In practice this is rare, and anycast works best for short connections (DNS) or when the sites share connection state.
5. IPv6, dual stack and Happy Eyeballs
On a dual-stack network, example.com may resolve to both an IPv6 and an IPv4 address. If the client tries IPv6 first and IPv6 is broken somewhere on the path, a naive client waits for a full connect timeout, often many seconds, before falling back. Users experience this as "the internet is slow".
The standard fix is Happy Eyeballs: start IPv6, and if it hasn't connected within a short delay, start IPv4 in parallel and keep whichever connects first.
- Go does this by default in
net.Dialer. ItsFallbackDelaydefaults to 300 ms. - Node does it by default since v20 (
autoSelectFamily: true), trying each address for 250 ms (net.getDefaultAutoSelectFamilyAttemptTimeout()).
If you write your own dialing logic (custom DNS, connection pools that pin to one address), make sure you didn't throw this behaviour away.
6. Containers and Kubernetes, in one picture
§06 said a container has its own network stack. Here's what Docker builds to make that work:
lo and eth0. A veth pair (a virtual cable) connects it to the docker0 bridge (a virtual switch). Outgoing traffic is NAT-ed to the host's address; -p 8080:3000 forwards host port 8080 to the container's eth0 — never to its lo.The pieces: each container is a network namespace with its own lo and eth0. A veth pair acts as a virtual cable from the container to a bridge on the host. Outgoing traffic is NAT-ed to the host's address, and published ports are forwarded in. Every concept from this topic appears again, just implemented in software inside one kernel.
Kubernetes changes one rule: every pod gets its own routable IP, and any pod can reach any other pod without NAT. The network plugin makes that true across machines, either with plain routes or with an overlay (hence the MTU issue in point 1). Kubernetes Service IPs are stranger: no interface owns them. They're virtual addresses that the kernel rewrites into a real pod's IP as connections are made (kube-proxy programs those rules). That's why, in kube-proxy's default iptables mode, pinging a Service IP usually fails while curl to it works: ICMP isn't covered by the rewrite rules.
7. Where latency hides, layer by layer
When a request is slow, the time went somewhere in the stack. A rough map (all numbers approximate):
| Layer | Where time goes | Typical size |
|---|---|---|
| Link | Wi-Fi retries, radio scheduling on mobile, oversized buffers in home routers | 1–50 ms, very jittery |
| Internet | Distance: light in fibre needs ~1 ms of round trip per 100 km. Plus queues in congested routers. | 1 ms in a data center, 70–150 ms across an ocean |
| Transport | The TCP handshake costs 1 round trip; a lost packet must be resent | 1 RTT for the handshake; a loss costs ~1 RTT if TCP notices quickly, ≥ 200 ms if it has to wait for a timeout |
| Application | DNS lookup, TLS handshake (1 more round trip with TLS 1.3), then the server's own work | 0–100 ms each |
The lesson: with a 90 ms round trip, a new HTTPS connection spends 2 round trips to the server (TCP handshake + TLS 1.3 handshake), about 180 ms, plus a DNS lookup if the name isn't cached, before the first byte of your request is even sent. That's why connection reuse (keep-alive), CDNs close to users and fewer round trips matter more than raw bandwidth. Measure it instead of guessing:
curl -o /dev/null -s -w 'dns %{time_namelookup} tcp %{time_connect} tls %{time_appconnect} first byte %{time_starttransfer} total %{time_total}\n' https://example.com/start := time.Now()
at := func(what string) { fmt.Printf("%-14s %v\n", what, time.Since(start).Round(time.Millisecond)) }
trace := &httptrace.ClientTrace{ // hooks the HTTP client calls at each stage
DNSDone: func(httptrace.DNSDoneInfo) { at("dns done") },
ConnectDone: func(network, addr string, err error) { at("tcp connected") },
TLSHandshakeDone: func(tls.ConnectionState, error) { at("tls done") },
GotFirstResponseByte: func() { at("first byte") },
}
req, err := http.NewRequest("GET", "https://example.com/", nil)
if err != nil {
log.Fatal(err)
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
io.Copy(io.Discard, res.Body) // read to the end so the connection can be reused
at("done")Pitfalls gallery
Listening on 127.0.0.1 inside a container
The server works when you run it directly and fails behind docker run -p. Inside a container, 127.0.0.1 is the container's own private loopback, and published ports arrive on its eth0. Fix: listen on 0.0.0.0 (or omit the host) inside containers. On your laptop, the opposite habit is safer: keep dev databases on 127.0.0.1. (§06)
Assuming that sent means delivered
IP is best effort, and UDP adds no guarantees on top. Even with TCP, a successful conn.Write / socket.write only means the bytes reached your own kernel's buffer. They may still be in your machine when the server crashes. Fix: if you need to know something was processed, the other side has to say so in your protocol (an HTTP response, an ack message), and you need timeouts and retries for when it doesn't. See worker pools for retry budgets.
Treating TCP reads as messages
One write of 100 bytes can arrive as two reads of 60 and 40, and two writes can arrive as one read. Code that assumes "one data event = one message" works on localhost and breaks over a real network, where packets get split differently. Fix: frame your messages with newlines, a length prefix, or a real protocol (HTTP, gRPC). (§07)
Running out of ephemeral ports
Each outgoing connection to the same server IP and port needs its own source port, and Linux has about 28,000 of them (32768–60999). After a connection closes, its 4-tuple stays reserved in TIME_WAIT for 60 seconds. So a client that opens a new connection per request to one backend can sustain only about 28,000 ÷ 60 ≈ 470 new connections per second. Beyond that, connects fail with cannot assign requested address / EADDRNOTAVAIL.
Fix: reuse connections. In Go, share one http.Client and always read the response body to the end and close it, otherwise the connection can't go back to the pool. In Node 19+, http/https reuse connections by default (keep-alive agent). For databases, use a connection pool.
Blocking all ICMP
It feels secure. It breaks path MTU discovery, so large transfers hang over VPNs and tunnels while small requests work: the black hole from §08. It also makes ping and traceroute useless for debugging. Fix: allow at least ICMP "fragmentation needed" (IPv4 type 3, code 4) and ICMPv6 "packet too big"; rate-limit the rest if you must.
"localhost" isn't always 127.0.0.1
localhost can resolve to ::1 (IPv6) or 127.0.0.1. If your server listens only on 127.0.0.1 and the client picks ::1, you get connect ECONNREFUSED ::1:3000. This was a common surprise when Node 17 began returning addresses in the order the OS gives them, often IPv6 first. Node 20+ usually hides it by trying both families (§10). Fix: be explicit on both sides (127.0.0.1 everywhere), or have the server listen on both.
Gluing host and port with string concatenation
ip + ":" + port produces ::1:8080 for an IPv6 address, which is ambiguous: which colon starts the port? IPv6 addresses must be wrapped in brackets: [::1]:8080.
addr := net.JoinHostPort("::1", "8080") // "[::1]:8080" ← adds brackets when needed
conn, err := net.Dial("tcp", addr)In JavaScript, pass host and port as separate arguments (net.connect(port, host)) instead of building an address string. When you do need a URL, write IPv6 hosts in brackets: http://[::1]:8080/.
Trusting the source IP
Behind a reverse proxy, a load balancer or NAT, the source address your server sees is the proxy's, not the user's. Proxies pass the original address along in the X-Forwarded-For header, but any client can send that header too. Fix: only trust X-Forwarded-For entries added by proxies you run, and never use a raw IP alone for authentication.
Check yourself
First a warm-up: which layer does each thing belong to? (§02, §03)
Now predict the outcome before you click. Q1 is §06, Q2 is §04, Q3 is §03 and §05, Q4 is §07, Q5 is §08, and Q6 is §05.
// server.mjs, inside a container started with: docker run -p 3000:3000 my-app
import http from "node:http";
http.createServer((req, res) => res.end("ok"))
.listen(3000, "127.0.0.1");
// on the host: curl http://localhost:3000You get the subnet 10.0.5.0/26 for a group of servers.
How many of them can get an address?A packet goes laptop → router → router → server (no NAT involved).
Which of these is different when it leaves the first router
compared to when it arrived?Your browser opens two connections to 203.0.113.10:443
from your laptop at 192.168.1.37. What differs between them?After the company added a VPN, logging in to an internal site works,
but downloading a large file stalls forever at 0 bytes.
The network team "blocks all ICMP for security".$ traceroute 203.0.113.10
1 192.168.1.1 3.1 ms
2 198.51.100.1 9.0 ms
3 * * *
4 198.51.100.130 88.2 ms
5 203.0.113.10 91.0 msCheat sheet
| Need | Answer |
|---|---|
| Header sizes | Ethernet 14 + 4 · IPv4 20 (IPv6 40) · TCP 20–60 (often 32) · UDP 8 |
| Private IPv4 ranges | 10.0.0.0/8 · 172.16.0.0/12 · 192.168.0.0/16 · (CGNAT 100.64.0.0/10) |
| Special addresses | 127.0.0.1 / ::1 loopback · 0.0.0.0 / :: "all interfaces" when listening · 169.254.x.x link-local |
Size of a /n | 2^(32 − n) addresses; minus 2 usable for /30 and bigger blocks. /24 = 256 (254 hosts) |
| Ephemeral ports (Linux) | 32768–60999 (/proc/sys/net/ipv4/ip_local_port_range) |
| Default TTL | 64 (Linux, macOS) · 128 (Windows) |
| My addresses / routes | ip addr · ip route · ip route get <ip> (macOS: ifconfig, netstat -rn) |
| What's listening, on which address? | ss -tlnp (macOS: lsof -iTCP -sTCP:LISTEN -n -P) |
| The path to a host | traceroute / mtr (Windows: tracert) |
| See the packets | sudo tcpdump -i <iface> -nn 'host <ip> and port <port>' → Wireshark for more |
| MTU test | ping -M do -s 1472 <host> (1472 + 28 = 1500) |
| Go | net.Listen("tcp", ":3000") · net.Dial("tcp", net.JoinHostPort(h, p)) · net/netip for CIDR |
| Node | net.createServer(cb).listen(3000) · net.connect(port, host) · net.BlockList for CIDR |