Frontend Labs
⋯

Chapter 59 — Network performance engineering: QoS, packet loss, shaping, saturation

In one sentence

Performance work means separating latency, loss, bandwidth, queueing, and application delay, then fixing whichever one is actually the limit, instead of guessing.

The problem

"The network is slow" can mean a dozen different things. A 1 Gb/s link that gives one transfer 40 Mb/s might be lossy, high-latency, window-limited, shaped, congested, or perfectly healthy with a slow application at one end. Each needs a different fix, and buying more bandwidth fixes only one of them.

The four questions

  1. Is there packet loss?
  2. Is latency high even without load?
  3. Does latency increase only under load? (Queueing / bufferbloat.)
  4. Is throughput limited by bandwidth, window size, CPU, disk, or the app?

How it actually works: three laws to carry around

1. Bandwidth-delay product (Chapter 24). One TCP connection can have at most one window of unacknowledged data in flight, so:

 max throughput of one connection  ≈  window / RTT
 window needed to fill a link      =  bandwidth × RTT
 1 Gb/s × 100 ms = 12.5 MB in flight        (a 64 KB window gives ~5 Mb/s)

2. Little's law. For any stable queue: items in the system = arrival rate × time each spends in it. A server handling 2,000 requests/s at 50 ms each has ~100 requests in flight, so it needs ~100 concurrent workers or connections. If latency doubles, so does concurrency, which is why a slow dependency exhausts connection pools.

3. Mathis' formula for loss-limited TCP (Reno/CUBIC-style). throughput ≲ (MSS / RTT) × (1.22 / √loss). With MSS 1460, RTT 50 ms, and 1% loss, that's about 2.9 Mb/s, however fast the link. Loss hurts most on long paths. BBR (Chapter 25) is much less sensitive to random loss, which is why it helps on lossy long-haul and mobile links.

Useful tests

mtr -ezbw -c 100 example.com                 # per-hop loss and latency (look at the LAST hop)
iperf3 -c server.example.com -t 20           # single-stream throughput
iperf3 -c server.example.com -P 8            # 8 streams: if much faster, you're window-limited
iperf3 -c server.example.com -R              # reverse direction (server -> client)
ss -tin dst server.example.com               # rtt, cwnd, retrans, and "rwnd_limited"/"sndbuf_limited"
curl -w '@curl-format.txt' -o /dev/null -s https://example.com/

curl-format.txt:

dns=%{time_namelookup}
connect=%{time_connect}
tls=%{time_appconnect}
ttfb=%{time_starttransfer}
total=%{time_total}

Interpreting iperf3: if one stream is slow but eight together fill the link, the limit is per-connection: a window or buffer that's too small, or loss. If eight streams are also slow, the limit is the path or an endpoint's CPU or NIC.

Build it in Go (30 min) — measure the bandwidth-delay product

This lab sends data as fast as possible to a sink and reports throughput. It can pin the socket buffer (SetWriteBuffer, which disables Linux's autotuning) and open parallel streams, which lets you reproduce the first law on demand:

// bdp: feel the bandwidth-delay product (Chapters 24 and 59).
//
// One TCP connection can have at most one window of data "in flight". If the
// window (bounded by socket buffers) is smaller than bandwidth x RTT, the pipe
// is never full and throughput = window / RTT, no matter how fast the link is.
//
//	go run ./bdp -serve :7500
//	go run ./bdp -to HOST:7500 -buf 65536            # small buffer
//	go run ./bdp -to HOST:7500                       # kernel autotuning
//	go run ./bdp -to HOST:7500 -streams 4 -buf 65536 # parallel streams
//
// Add delay first to make the effect obvious (Linux, on the client):
//
//	sudo tc qdisc add dev eth0 root netem delay 50ms
package main

import (
	"flag"
	"fmt"
	"io"
	"net"
	"os"
	"sync"
	"sync/atomic"
	"time"
)

func main() {
	serve := flag.String("serve", "", "run the sink server on this address")
	to := flag.String("to", "", "server to send to")
	buf := flag.Int("buf", 0, "socket send buffer in bytes (0 = kernel autotuning)")
	streams := flag.Int("streams", 1, "parallel connections")
	secs := flag.Int("secs", 5, "test duration in seconds")
	flag.Parse()

	if *serve != "" {
		ln, err := net.Listen("tcp", *serve)
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}
		fmt.Println("sink listening on", ln.Addr())
		for {
			c, err := ln.Accept()
			if err != nil {
				continue
			}
			go func() { io.Copy(io.Discard, c); c.Close() }()
		}
	}
	if *to == "" {
		flag.Usage()
		os.Exit(2)
	}

	rtt := measureRTT(*to)
	var total atomic.Int64
	deadline := time.Now().Add(time.Duration(*secs) * time.Second)
	var wg sync.WaitGroup
	for i := 0; i < *streams; i++ {
		c, err := net.Dial("tcp", *to)
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}
		if *buf > 0 {
			c.(*net.TCPConn).SetWriteBuffer(*buf) // pins the size: disables autotuning
		}
		wg.Add(1)
		go func() {
			defer wg.Done()
			defer c.Close()
			chunk := make([]byte, 128*1024)
			for time.Now().Before(deadline) {
				n, err := c.Write(chunk)
				total.Add(int64(n))
				if err != nil {
					return
				}
			}
		}()
	}
	wg.Wait()

	mbps := float64(total.Load()) * 8 / float64(*secs) / 1e6
	fmt.Printf("streams=%d buf=%d rtt~%v  ->  %.1f Mbit/s\n", *streams, *buf, rtt.Round(time.Millisecond), mbps)
	if *buf > 0 && rtt > 0 {
		// Linux doubles the SO_SNDBUF you ask for (half is bookkeeping overhead),
		// so roughly 2*buf bytes can be in flight per connection.
		ceiling := float64(2**buf) * 8 / rtt.Seconds() / 1e6 * float64(*streams)
		fmt.Printf("theory: streams x 2*buf / RTT ~ %.1f Mbit/s\n", ceiling)
	}
}

// measureRTT takes the fastest of three TCP handshakes: one handshake ~ 1 RTT.
func measureRTT(addr string) time.Duration {
	best := time.Hour
	for i := 0; i < 3; i++ {
		start := time.Now()
		c, err := net.Dial("tcp", addr)
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}
		best = min(best, time.Since(start))
		c.Close()
	}
	return best
}

Real results between two containers, with 50 ms of delay added by netem:

$ go run ./bdp -to sink:7500 -buf 65536
streams=1 buf=65536 rtt~55ms  ->  18.4 Mbit/s
theory: streams x 2*buf / RTT ~ 19.0 Mbit/s

$ go run ./bdp -to sink:7500                    # kernel autotuning
streams=1 buf=0 rtt~52ms  ->  230.7 Mbit/s

$ go run ./bdp -to sink:7500 -buf 65536 -streams 4
streams=4 buf=65536 rtt~51ms  ->  72.9 Mbit/s
theory: streams x 2*buf / RTT ~ 82.0 Mbit/s

What to notice:

  • The theory line predicts the measurement to within a few percent. The network was the same in all three runs. Only the window changed.
  • Linux doubles the SO_SNDBUF you ask for (half is bookkeeping), so 2 × buf is in flight. This surprises everyone who reads man 7 socket for the first time.
  • Setting a buffer size disables autotuning. Autotuning reached 230 Mbit/s without help. Hard-coding "big" buffers in an application often makes things worse than leaving the kernel alone. Tune the kernel's ceilings (net.ipv4.tcp_wmem/tcp_rmem max) for long fat networks, not each socket.
  • Parallel streams multiply the window. That's why download managers, aws s3 cp, and rclone open many connections on high-RTT paths.

Exercises:

  1. Add -delay values of 10, 50, and 150 ms (with tc netem) and plot throughput for the pinned 64 KB buffer. It should fall as 1/RTT.
  2. Add 1% loss and compare CUBIC with BBR by switching sudo sysctl -w net.ipv4.tcp_congestion_control=bbr on the sender between runs (load it first with sudo modprobe tcp_bbr if needed). Which degrades less? Compare both with Mathis' prediction above.
  3. Run it across the internet between two cloud regions. How close does a single stream get to the instance's advertised bandwidth?

Queues, bufferbloat, and shaping

Latency that rises only under load is a queue filling somewhere: a home router, a cable modem, a VPN gateway, a cloud NAT. Large, dumb FIFO buffers cause bufferbloat: hundreds of milliseconds of queueing delay that make video calls stutter while a download runs.

  • Active queue management (fq_codel, cake) keeps queues short by dropping or marking early and isolating flows from each other. Most Linux distributions now default to fq_codel; check with tc qdisc show.
  • Shaping means sending slightly below the bottleneck rate, so the queue forms on a device you control (with good AQM) instead of in the ISP's modem. tc qdisc add dev eth0 root cake bandwidth 90mbit.

QoS does not create bandwidth. It decides which packets suffer first when a link is full. It's useful for voice, video, and control traffic, but it can't fix an undersized link or an application that sends too much.

Bufferbloat rule: if ping latency explodes during an upload or download, you need queue management or shaping near the bottleneck, not a faster DNS server.

Host tuning that actually matters

sysctl net.ipv4.tcp_congestion_control net.core.default_qdisc   # e.g. cubic, fq_codel
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem                       # min default max (bytes)
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog           # accept/SYN queues (Chapter 52)
sysctl net.ipv4.ip_local_port_range                              # ephemeral ports (Chapter 19)

Change these only with a measurement showing they're the limit: before, after, and in a canary.

Common confusions

  • "More bandwidth will fix it." Not if the limit is RTT × window, loss, queueing, or the application. Measure first.
  • "Average latency is fine." Bufferbloat and loss show up in p99, not the mean (Chapter 45).
  • "iperf3 shows 900 Mb/s, so the app should get that." iperf3 measures the network with an ideal application. Your app adds serialisation, TLS, syscalls per small write, disk, and locks.

Check yourself

  1. Compute the window needed for 500 Mb/s at 80 ms RTT.
  2. Using Little's law, how many DB connections does a service doing 300 queries/s at 40 ms each need at minimum?
  3. What do you conclude if 1 iperf3 stream gives 50 Mb/s and 8 streams give 400 Mb/s?
  4. Why can setting SO_SNDBUF in an app reduce throughput?

Further reading

  • Book: High Performance Browser Networking (Ilya Grigorik, free online), chapters 1–2 on latency, bandwidth, and TCP.
  • Article: "Bufferbloat: Dark Buffers in the Internet" (Gettys & Nichols, ACM Queue) and bufferbloat.net.
  • Across the series: the Linux guide's observability chapter covers host-side profiling for when the bottleneck is the application, not the network, and the Go guide's performance chapter covers profiling Go programs.

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