Capstone: Three Seconds from Warsaw
A user in Warsaw opens your application hosted in another region and the page takes three seconds: enumerate every layer where the time could be, assign each to the domain that explains it, put a typical cost and a measurement next to each, and bisect.
The problem
The shape of the answer
Three seconds is enormous. A single round trip Warsaw ↔ Frankfurt is ~20–30 ms; Warsaw ↔ US-East is ~100–120 ms; Warsaw ↔ Singapore ~180 ms. A well-built page from a distant region needs roughly: DNS (0–1 RTT to a resolver, more if uncached), TCP handshake (1 RTT), TLS 1.3 (1 RTT; TLS 1.2 is 2), the request and first byte of response (1 RTT plus server time), then the body at a rate that ramps with cwnd — perhaps 5–8 RTTs before the base document is in. At 100 ms that is 500–800 ms with a fast server. Three seconds means either many more round trips than necessary, a server that spends seconds, a loss-degraded path, or a page that needs dozens of dependent fetches.
The wrong answer is a list of technologies. The right answer is a budget: every layer named, in order, with a typical cost and a measurement, and then a bisection — measure the boundaries between layers until the missing seconds are found. The matrix below is the budget; the following section is the bisection. Each row names the Engineer Atlas domain whose lessons explain it, because the point of this capstone is that no single domain can answer the question.
- Browser: DNS lookupcache → OS resolver → recursive → authoritative; 0 to hundreds of ms↓
- TCP handshake1 RTT; or 0 with a reused connection↓
- TLS handshake1 RTT (1.3), 2 RTT (1.2); 0-RTT resumption possible↓
- Request in flight; CDN / LBedge hit ends here; miss adds edge→origin RTT + connection↓
- Server: accept, schedule, runbacklog wait, thread/loop availability, CPU time↓
- Server → databaseconnection (pooled?), query, disk I/O, result size↓
- Response body transfersize / (bandwidth) plus cwnd ramp: log2(size/initcwnd) RTTs↓
- Browser: parse, render, dependent fetcheseach dependent resource repeats the top of the ladder
The latency budget
Typical costs are for a 100 ms RTT path in a healthy state; they are orders of magnitude for reasoning, not measurements of your system. The measurement column is the tool that isolates that layer and nothing else — the property that makes a bisection possible. The lesson column is where to go when a row is the answer.
Read the domain column as a routing table for the investigation. Rows owned by Networking cost round trips and are fixed by removing round trips (caching, reuse, proximity); rows owned by Operating Systems cost waiting and are fixed by removing queues (capacity, pools, non-blocking I/O); rows owned by Database cost work and are fixed by doing less of it (indexes, fewer queries, smaller results). A three-second page usually has one row an order of magnitude over budget, and the domain tells you which kind of fix to expect before you have found it.
| Area | Domain | Typical cost | How to measure | Lesson |
|---|---|---|---|---|
| DNS resolution | Networking | 0 ms cached; 20–300 ms uncached; seconds on a failing resolver | dig +trace; curl -w %{time_namelookup}; browser devtools DNS phase | Following One Lookup Through Every Cache, DNS Failure Modes: What Each One Looks Like |
| TCP connection | Networking | 1 RTT ≈ 100 ms; 0 if pooled/keep-alive; +1–3 s on SYN loss | curl -w %{time_connect} − %{time_namelookup}; devtools "Initial connection" | The Three-Way Handshake, Keep-Alive and Connection Reuse |
| TLS handshake | Networking / Security | 1 RTT (1.3) ≈ 100 ms; 2 RTT (1.2); +OCSP fetch if stapling is off | curl -w %{time_appconnect} − %{time_connect}; openssl s_client -msg | The TLS Handshake, Certificates and the Chain of Trust |
| Network RTT | Networking | 20–30 ms EU; 100–120 ms EU↔US-East; 180 ms EU↔SG | ping / mtr from the user’s location; ss -ti rtt on the server | Latency: Same Machine to Cross-Continent, Bandwidth vs Latency |
| Packet loss | Networking | 1% loss: 2–10× slower transfers; RTO ≥ 200 ms per event, doubling | mtr loss column; ss -ti retrans; nstat TcpRetransSegs | Packet Loss: Duplicate ACKs, Fast Retransmit and the RTO, Congestion Control: Protecting the Network |
| CDN | System Design / Networking | hit: user↔edge RTT ≈ 10–30 ms; miss: + edge↔origin RTT + origin time | response headers (x-cache, age); compare edge vs origin curl | CDNs: The Networking View |
| Load balancer / proxy | System Design / Networking | 1–5 ms; +1 RTT if it opens a new upstream connection per request; 502/504 on unhealthy upstream | LB access-log upstream_time vs total; proxy connection reuse metrics | Load Balancers: L4 vs L7, Forward and Reverse Proxies |
| Server scheduling | Operating Systems | <1 ms idle; 10s–100s of ms under CPU saturation or a full accept queue | run-queue length (vmstat r); ss -ltn Recv-Q; scheduler latency histograms | The Scheduling Problem, Context Switching |
| Application CPU | Operating Systems / DSA | 1–50 ms typical; seconds for O(n²) code, big JSON, template rendering | profiler (perf, py-spy, async-profiler); per-handler timing | Follow a Program: from `./server` to the First Instruction, Dynamic Programming |
| Thread pool / event loop | Operating Systems | 0 when free; queue-wait or loop-lag otherwise; unbounded under overload | pool queue depth and wait; event-loop lag metric; thread dumps | The Thread Pool Server, The Event-Driven Server |
| Database network call | Networking / Database | same-AZ 0.2–1 ms per round trip; cross-region 100 ms; ×N for N queries (N+1) | query count per request; pool wait time; DB client timings | Follow send() Through the OS to recv(), Connection Pooling |
| Database query | Database | <1 ms indexed; 10 ms–seconds for scans, sorts, lock waits | EXPLAIN ANALYZE; pg_stat_statements; slow-query log | Reading EXPLAIN ANALYZE, Why Is This Query Slow? Indexes |
| Disk I/O | Operating Systems / Database | ~100 µs SSD read; ms for HDD; seconds under a swap storm or fsync queue | iostat await; buffer-cache hit ratio; vmstat si/so | Follow a File Read, Memory Pressure, Swap and the OOM Killer |
| Response size | Networking | log2(size / 14 kB) RTTs to ramp cwnd; 1 MB ≈ 6–7 RTTs ≈ 700 ms at 100 ms | content-length; devtools "Content download"; compare gzip/brotli | Throughput: Requests, Packets and Bytes per Second, Where the Time Goes: The Request Timeline |
| Congestion / bufferbloat | Networking | RTT under load ≫ idle RTT; 100s of ms of queueing on a saturated link | mtr under load vs idle; ss -ti cwnd and rtt variance | Congestion Control: Protecting the Network, The Buffer Chain |
| Browser processing | Browser | parse/JS/render 50 ms–seconds; each dependent resource repeats DNS/TCP/TLS unless reused | devtools Performance and Network waterfall; Lighthouse | What Happens When You Press Enter, HTTP/1.1 vs HTTP/2 vs HTTP/3 |
Bisecting three seconds
Start from the outside and split. First split: is it the network path or the server? curl -w from the user’s region against the same URL prints every phase — name lookup, connect, TLS, time-to-first-byte, total. If time_starttransfer minus time_appconnect (the server’s think time plus one RTT) is 2.5 s, the server is the problem and the network rows can be closed. If that gap is 150 ms but the total is 3 s, the time is in the transfer or before the request: check the size, the phases before it, and the number of requests.
Second split, server side: is it the request path or the dependencies? The server’s own timing — handler duration in its access log, or a trace — separates "we spent 2.4 s in the database" from "we spent 2.4 s in the queue before a thread picked it up" (pool wait, loop lag, accept-queue depth). The first leads to EXPLAIN ANALYZE and query counts; the second to top, run-queue length, and the The Thread Pool Server and The Event-Driven Server metrics. A server that is idle and still slow is either waiting on something remote (database, cache, a third-party API — measure each) or blocked on disk (iostat).
Second split, client side: is it round trips or bytes? Count them. The devtools waterfall shows how many connections were opened (each one is DNS + TCP + TLS unless reused — Keep-Alive and Connection Reuse, HTTP/2: Streams on One Connection multiplexing, Connection Pooling) and how deep the dependency chain is (a page that fetches a script that fetches a config that fetches data pays 4 sequential round trips before rendering). Bytes: log2(size/14 kB) round trips to ramp the congestion window means a 2 MB uncompressed bundle from 100 ms away is ~700 ms of pure cwnd growth before bandwidth matters at all. A CDN moves the RTT for static assets from 100 ms to 15 ms; nothing else moves it.
Each split closes half the matrix. Three or four splits reach a row, and the row’s lesson explains the fix. The discipline is to measure the boundary before naming the layer — the failure of most three-second investigations is that each team measures inside its own layer, finds it healthy, and stops.
$ curl -o /dev/null -s -w 'dns %{time_namelookup} connect %{time_connect} tls %{time_appconnect} ttfb %{time_starttransfer} total %{time_total} size %{size_download}\n' https://app.example.com/
dns 0.212 connect 0.318 tls 0.431 ttfb 2.905 total 3.012 size 48213
# ttfb − tls = 2.47 s: the server (or its dependencies) owns the time.
# dns 212 ms: uncached from Warsaw — a second, smaller problem (TTL? resolver?).
# 48 kB body in 107 ms: transfer is not the issue this time.What a strong answer sounds like
A strong answer to the interview question (A user in Warsaw waits 3 seconds. Where did the time go?) names the layers in path order, gives an order of magnitude for each, states that 3 s cannot be explained by RTT alone on a healthy path from a fast server, and then proposes the bisection: curl -w from Warsaw first, then server-side timing, then either the database or the client waterfall. It mentions the things that silently multiply RTT — uncached DNS, no keep-alive, TLS 1.2, N+1 queries, sequential dependent fetches, a CDN miss, a cross-region database — because those are how 500 ms becomes 3 s without anything being "broken".
It also knows what each domain contributes and does not pretend one explains everything. The OS explains why an idle-looking server can still queue (scheduling, pool, loop lag, disk). Networking explains the round trips and the ramp. Database explains the query and the buffer cache. System Design explains the CDN, the balancer and the region choice. DSA explains why the handler took a second. And the answer ends with a number: "I would expect ~600 ms for this page from Warsaw; the missing 2.4 s is on the server, and I would find it with a trace".
The Capstone: A Server With 50,000 Concurrent Connections and Capstone: What Happens When You Visit https://example.com each ask the single-domain version of this question; Where the Time Goes: The Request Timeline and Latency: Same Machine to Cross-Continent give the networking numbers; Follow send() Through the OS to recv() gives the OS half of every network row. This lesson is the map that joins them.
- Budget first, then bisect; measure boundaries, not layers.
- Round trips multiply: count connections and dependency depth before optimising anything.
- A server’s "think time" includes queueing before the handler runs; an idle CPU does not clear the OS.
- End with an expected number and where the difference is.
Key points
- A page from 100 ms away needs ~5–8 RTTs minimum (DNS, TCP, TLS, request, cwnd ramp): 500–800 ms with a fast server. Three seconds is extra round trips, server time, loss, or dependent fetches.
- Build the budget: every layer in path order, its domain, an order-of-magnitude cost, and the one measurement that isolates it.
- Bisect at boundaries:
curl -wsplits network from server; server timing splits queueing from dependencies; the client waterfall splits round trips from bytes. - Silent multipliers: uncached DNS, no keep-alive, TLS 1.2, CDN miss, per-request upstream connections, N+1 queries, cross-region database, sequential dependent resources, uncompressed bundles.
- The OS contributes queueing that an idle CPU does not reveal: accept backlog, pool queue, event-loop lag, disk waits, scheduler latency under saturation.
- Each row belongs to a domain and no domain explains the whole; the capstone is the map.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why start with curl -w rather than the server logs?
Because it measures from the user’s side and separates the phases the server cannot see — DNS, connect, TLS, transfer — from the one it can. One command closes half the matrix; the server logs cannot tell you the DNS lookup took 200 ms.
▸Why does distance matter so much more than bandwidth for a page load?
Because a page is dozens of round trips, not one transfer. Each handshake, each dependent fetch, and each doubling of the congestion window costs an RTT; bandwidth only matters once the window is large. A 100 Mbit/s link 100 ms away loads a typical page slower than a 10 Mbit/s link 10 ms away.
▸Why can a server with 5% CPU add two seconds?
Because time is spent waiting, not computing: in the accept queue, in the pool queue, behind a blocked event loop, on a lock, on a database round trip, on disk. All of those are invisible to CPU utilisation and visible to queue and wait metrics.
Capstone: the Warsaw latency budget
| Area | ms | Domain that explains it | Feedback |
|---|---|---|---|
| DNS | — | ||
| Connection establishment | — | ||
| TLS | — | ||
| Network RTT | — | ||
| Packet loss | — | ||
| CDN | — | ||
| Load balancer | — | ||
| Server scheduling | — | ||
| Application CPU | — | ||
| Thread / event loop | — | ||
| Database network call | — | ||
| Database query | — | ||
| Disk I/O | — | ||
| Response size | — | ||
| Congestion | — | ||
| Browser processing | — |
Contributions are seeded, editable, and illustrative — 2.0 MB of JSON over a 150 ms RTT really does cost this much, but your numbers will differ.
How it fails
What the failure looks like from inside real software.
- Each team measures inside its own layer, finds it healthy, and the incident is closed as "the network" — the classic outcome the bisection exists to prevent.
- Optimising bandwidth (a bigger instance, a faster link) when the page is round-trip-bound; the RTT count is unchanged and so is the load time.
- A CDN in front of a page whose HTML is dynamic and uncacheable: static assets get fast, the base document still crosses the ocean, and the waterfall barely moves.
- Connection pooling to the database disabled by a config change: each request pays a TCP + TLS handshake to the database — 2 RTTs — invisible in the query time.
- Reasoning from a load test in the same region as the server: 40 ms total, "it is fast"; the user in Warsaw still waits 3 s because the test never paid the RTTs.
- A 200 ms uncached DNS lookup on every page view because the record’s TTL is 30 s; nobody measured
time_namelookup.