Frontend Labs
⋯

Part 5 — Ports and the transport layer

IP got the packet to the right machine. Now: which program on that machine, and did everything arrive?

Chapter 19 — Ports and sockets: which program gets the data?

In one sentence

A port is a number that says which program on a machine a packet is for, and a connection is identified by the combination of both machines' addresses and ports.

The problem

Your laptop has one IP address and is running fifty programs — a browser with twelve tabs, an email client, a chat app, a music streamer. A packet arrives. Which program gets it?

The idea, in plain language

Back to the office building analogy from Chapter 2:

   IP ADDRESS  = the building's street address     "12 High Street"
   PORT NUMBER = the room/department number         "Room 443"

The postman gets the letter to the building using the street address. The receptionist gets it to the right room using the room number.

The numbers

A port is a 16-bit number: 0 to 65535.

Range Name Used for
0 – 1023 Well-known Standard services. Binding to these usually needs admin rights.
1024 – 49151 Registered Applications that registered a number (MySQL 3306, Postgres 5432)
49152 – 65535 Ephemeral Temporary, random ports your machine picks when it starts a connection

The well-known ports worth knowing by heart (the rest are in Appendix D):

    22  SSH            53  DNS           80  HTTP        443  HTTPS
    25  SMTP (mail)   123  NTP (time)   3306 MySQL      5432  PostgreSQL
    67/68 DHCP        3389 RDP          6379 Redis      27017 MongoDB

The four-part identity of a connection

This is the key concept. A connection is identified by four things together:

   (your IP, your port, their IP, their port)

   Example -- two browser tabs to the same website:

     tab 1:  192.168.1.50 : 51000  <-->  93.184.216.34 : 443
     tab 2:  192.168.1.50 : 51001  <-->  93.184.216.34 : 443
                            ^^^^^
                    only this differs -- and that's enough

This is called the 4-tuple (or 5-tuple, including the protocol). It's why:

  • One server on port 443 can handle millions of simultaneous connections — they all differ in the client's address and port.
  • Your machine can have many connections to the same server at once.
  • The receiving machine can always tell them apart.

The server listens on a fixed, well-known port (443). The client uses a random ephemeral port so its replies can be sorted out.

Listening vs connected

ss -tuln      # Linux: listening TCP+UDP sockets
netstat -an | grep LISTEN    # macOS
State    Local Address:Port     Peer Address:Port
LISTEN   0.0.0.0:22            0.0.0.0:*        <- SSH, waiting for anyone
LISTEN   127.0.0.1:5432        0.0.0.0:*        <- Postgres, LOCAL ONLY
ESTAB    192.168.1.50:51000    93.184.216.34:443  <- an actual connection

Note the difference between 0.0.0.0:22 and 127.0.0.1:5432:

  • 0.0.0.0 means "listen on all my addresses" — reachable from the network.
  • 127.0.0.1 means "listen on loopback only" — reachable only from this machine.

This distinction causes an enormous number of "why can't I connect to my server?" problems. Your database is running, the port is open, but it's bound to 127.0.0.1 so nothing outside the machine can reach it. Check this first, always.

Practice (25 min)

A. See what's listening on your machine:

# Linux
ss -tulnp

# macOS
sudo lsof -iTCP -sTCP:LISTEN -n -P
netstat -an | grep LISTEN

For each one, ask: do I know what that is, and should it be reachable from the network? This is a genuinely useful security habit.

B. See your active connections:

ss -tn state established        # Linux
netstat -an | grep ESTABLISHED  # macOS

Open a few browser tabs and run it again. Watch the count grow.

C. Be a server and a client yourself — this makes ports concrete:

# Terminal 1: listen on port 9999
nc -l 9999

# Terminal 2: connect to it and type something
nc 127.0.0.1 9999
# whatever you type appears in terminal 1. You just built a chat app.

# Terminal 3: watch the connection exist
ss -tn '( sport = :9999 or dport = :9999 )'

D. Prove the binding-address rule:

# Bind to loopback only
python3 -m http.server 8000 --bind 127.0.0.1
# From this machine:      curl http://127.0.0.1:8000   -> works
# From another device:    curl http://<your-ip>:8000    -> FAILS

# Now bind to everything
python3 -m http.server 8000 --bind 0.0.0.0
# From another device: works.

E. Check whether a remote port is open:

nc -zv example.com 443        # "succeeded" or "refused"/timeout
nc -zv example.com 444        # almost certainly nothing there

Build it in Go (25 min) — the binding rules, tried for real

Three rules from this chapter cause real outages: one listener per address:port, a loopback bind only accepts loopback traffic, and the less well-known SO_REUSEPORT, which lets several sockets share one port on purpose.

//go:build linux || darwin

// bindlab: Chapter 19's binding rules, tried for real.
//
//	go run ./bindlab conflict     # two listeners on one port -> EADDRINUSE
//	go run ./bindlab reuseport    # SO_REUSEPORT: four listeners share one port
//	go run ./bindlab loopback     # 127.0.0.1 vs 0.0.0.0, and what each accepts
package main

import (
	"context"
	"errors"
	"fmt"
	"net"
	"os"
	"sync"
	"syscall"
	"time"
)

func main() {
	if len(os.Args) != 2 {
		fmt.Println("usage: bindlab conflict|reuseport|loopback")
		os.Exit(2)
	}
	switch os.Args[1] {
	case "conflict":
		conflict()
	case "reuseport":
		reusePort()
	case "loopback":
		loopback()
	}
}

func conflict() {
	a, err := net.Listen("tcp", "127.0.0.1:7300")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer a.Close()
	_, err = net.Listen("tcp", "127.0.0.1:7300")
	fmt.Println("second listen:", err)
	fmt.Println("EADDRINUSE?   ", errors.Is(err, syscall.EADDRINUSE))
}

// reusePort opens four sockets on the SAME port with SO_REUSEPORT set before
// bind(). The kernel then spreads incoming connections across them -- the
// trick nginx, Envoy, and HAProxy use to scale accept() across CPU cores.
func reusePort() {
	lc := net.ListenConfig{
		Control: func(network, address string, c syscall.RawConn) error {
			var opErr error
			err := c.Control(func(fd uintptr) {
				opErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1)
			})
			if err != nil {
				return err
			}
			return opErr
		},
	}
	counts := make([]int, 4)
	var mu sync.Mutex
	for i := range counts {
		ln, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:7301")
		if err != nil {
			fmt.Println("listener", i, err)
			return
		}
		go func() {
			for {
				c, err := ln.Accept()
				if err != nil {
					return
				}
				mu.Lock()
				counts[i]++
				mu.Unlock()
				c.Close()
			}
		}()
	}
	for i := 0; i < 400; i++ {
		if c, err := net.Dial("tcp", "127.0.0.1:7301"); err == nil {
			c.Close()
		}
	}
	time.Sleep(200 * time.Millisecond)
	mu.Lock()
	defer mu.Unlock()
	fmt.Println("connections accepted per listener:", counts)
	fmt.Println("(Linux hashes each connection to one socket -> roughly even;")
	fmt.Println(" macOS/BSD semantics differ -> often all on one listener)")
}

func loopback() {
	lo, _ := net.Listen("tcp", "127.0.0.1:7302")
	all, _ := net.Listen("tcp", "0.0.0.0:7303")
	defer lo.Close()
	defer all.Close()

	// Find a non-loopback address of this machine to test "from the network".
	c, err := net.Dial("udp", "1.1.1.1:53")
	if err != nil {
		fmt.Println("no network address to test with:", err)
		return
	}
	lan := c.LocalAddr().(*net.UDPAddr).IP.String()
	c.Close()

	for _, t := range []string{"127.0.0.1:7302", lan + ":7302", "127.0.0.1:7303", lan + ":7303"} {
		conn, err := net.DialTimeout("tcp", t, time.Second)
		if err != nil {
			fmt.Printf("%-22s FAIL  %v\n", t, err)
			continue
		}
		conn.Close()
		fmt.Printf("%-22s ok\n", t)
	}
	fmt.Println("\nA socket bound to 127.0.0.1 only accepts traffic addressed to 127.0.0.1.")
	fmt.Println("No firewall rule can change that: fix the bind address, not the firewall.")
}
$ go run ./bindlab conflict
second listen: listen tcp 127.0.0.1:7300: bind: address already in use
EADDRINUSE?    true

$ go run ./bindlab loopback
127.0.0.1:7302         ok
192.168.1.2:7302       FAIL  dial tcp 192.168.1.2:7302: connect: connection refused
127.0.0.1:7303         ok
192.168.1.2:7303       ok

$ go run ./bindlab reuseport          # on Linux
connections accepted per listener: [103 105 104 88]
$ go run ./bindlab reuseport          # on macOS
connections accepted per listener: [0 0 0 400]

What to notice:

  • "Refused" from your own LAN address. The 127.0.0.1 listener exists, but the connection was addressed to 192.168.1.2:7302, where nothing listens. This is the most common "works on my machine" bug in container and VM setups: the app binds 127.0.0.1 and everything outside the host gets refused. Fix the bind, not the firewall.
  • ListenConfig.Control runs your code on the raw file descriptor after socket() but before bind(). That's the only moment socket options like SO_REUSEPORT can be set. This hook is how Go programs reach any socket option the net package doesn't wrap.
  • The same code behaves differently on Linux and macOS. Linux load-balances new connections across SO_REUSEPORT sockets by hashing the 4-tuple. BSD-derived systems historically deliver to one socket. Always test kernel behaviour on the kernel you deploy to.

A real incident this explains: during testing for this guide, a backend bound to 127.0.0.1:8081 was killed. Instead of getting "connection refused", the clients started receiving 404s. Another program (Docker Desktop) was listening on the wildcard *:8081. The kernel gives each connection to the most specific matching listener. While the backend was alive, it was more specific. Once it died, the wildcard listener got the traffic. If you see "wrong service answered", check ss -ltnp / lsof -nP -iTCP -sTCP:LISTEN for overlapping binds.

Exercises:

  1. Start python3 -m http.server 8000 --bind 0.0.0.0, then try to net.Listen("tcp", "127.0.0.1:8000") in Go. Does it conflict? (On Linux it does: a wildcard bind covers every address.)
  2. Run reuseport under strace -f -e trace=setsockopt,bind,listen and find the four setsockopt(... SO_REUSEPORT ...) calls.
  3. Read how Linux's Chapter 37 on network views shows these listeners with ss -ltnp, then find which process owns each.

Common confusions

  • "Is a port a physical thing?" No. It's purely a number in a packet header. Nothing plugs into it.
  • "Port 80 vs port 8080?" Both are just numbers. 80 is the convention for HTTP; 8080 is a common alternative that doesn't need admin rights.
  • "Open port vs listening port?" "Listening" = a program is waiting on it. "Open" usually means a firewall permits traffic to it. You need both.
  • "Connection refused vs timeout?" Refused = the machine is there and actively said no (nothing listening). Timeout = nothing came back at all (firewall dropping silently, or host unreachable). Very different diagnoses.

Check yourself

  1. Why does a connection need four numbers to identify it, not two?
  2. What's the difference between binding to 0.0.0.0 and 127.0.0.1?
  3. If a server listens on 443 and a million clients connect, how does it tell them apart?
  4. What's the difference between "connection refused" and "connection timed out"?

Further reading

  • Video: "Ports and Sockets Explained" — Practical Networking.
  • Article: "What is a computer port?" — Cloudflare Learning Center.
  • Reference: the IANA port registry (search "IANA service name port number registry") — the official list.
  • Blog: Julia Evans' zine "How to be a Wizard Programmer" / her networking tools posts cover ss, netstat, and lsof well.

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36