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
- Is there packet loss?
- Is latency high even without load?
- Does latency increase only under load? (Queueing / bufferbloat.)
- 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_SNDBUFyou ask for (half is bookkeeping), so2 × bufis in flight. This surprises everyone who readsman 7 socketfor 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_rmemmax) for long fat networks, not each socket. - Parallel streams multiply the window. That's why download managers,
aws s3 cp, andrcloneopen many connections on high-RTT paths.
Exercises:
- Add
-delayvalues of 10, 50, and 150 ms (withtc netem) and plot throughput for the pinned 64 KB buffer. It should fall as 1/RTT. - Add 1% loss and compare CUBIC with BBR by switching
sudo sysctl -w net.ipv4.tcp_congestion_control=bbron the sender between runs (load it first withsudo modprobe tcp_bbrif needed). Which degrades less? Compare both with Mathis' prediction above. - 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 tofq_codel; check withtc 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
- Compute the window needed for 500 Mb/s at 80 ms RTT.
- Using Little's law, how many DB connections does a service doing 300 queries/s at 40 ms each need at minimum?
- What do you conclude if 1 iperf3 stream gives 50 Mb/s and 8 streams give 400 Mb/s?
- Why can setting
SO_SNDBUFin 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.