Foundations· 1 of 5
What Happens When You Type a URL
A 1 Gbit/s line does not download 100 KB in a millisecond. Here is where the other 450 go.
The question
Section titled “The question”A user on a 1 Gbit/s fibre line is 150 ms from your server and asks for a 100 KB HTML page. The line could carry 100 KB in under a millisecond. The last byte arrives about 450 ms after the request, and upgrading the user to 10 Gbit/s would not change that by a single round trip. Then you put a CDN in front of the server, one that cannot cache this page at all because it is built per user, and the page gets twice as fast anyway. Why?
You already know the order: DNS, then TCP, then TLS, then HTTP. This page is about the accounting nobody shows you: which round trips a page load cannot avoid, which ones can overlap, which caches skip which ones, and what quietly adds a second without an error anywhere.
Why the time is not where you think
Section titled “Why the time is not where you think”A round trip is a message to the server and its reply back; the round-trip time (RTT) is how long that takes. Light in optical fibre covers about 200 km per millisecond, so distance sets a floor no hardware removes. Mumbai to northern Virginia is about 13,000 km in a straight line, 65 ms each way at best; the measured round trip between the AWS regions there is about 187 ms, as of October 2026 (cloudping.co), because cables do not run straight and every router adds a little.
The time to load anything over a network has three terms:
- Round trips that must happen one after another, times the RTT.
- Work on the server, the only part your logs and APM traces measure.
- Bytes divided by bandwidth, the only part a faster connection improves.
For web pages the first term dominates, and it is the one people forget to count. Mike Belshe at Google measured this in 2010: holding the RTT at 60 ms and raising bandwidth, page load time improved by only 5% going from 5 to 10 Mbit/s, and at 10 Mbit/s pages used about 16% of the link they had. Lowering the RTT, by contrast, improved load time steadily at every step (Belshe, 2010). Pages are bigger now, and a 2025 study argues that on mobile networks bandwidth matters more than that result suggests for large images (Mostafa et al., 2025). The shape still holds for the part of the page that blocks the first render: HTML, CSS, fonts and the first API calls are small, and small transfers are bound by round trips.
The obvious fixes, and why they miss
Section titled “The obvious fixes, and why they miss”When a page is slow for distant users, teams usually try one of two things.
A bigger server. If your logs say the HTML takes 50 ms to build and the user waits 2 s, then shaving 20 ms off the server leaves 1.98 s. The server is the only part of the load you can see from the server, so it gets all the attention.
More bandwidth. It helps when the transfer is bandwidth-bound, which for most of a page load it is not. Here are the numbers from the runnable latency budget in “Build it yourself”, for 100 KB of HTML on a new connection with 50 ms of server work:
| RTT | 10 Mbit/s | 100 Mbit/s | 1 Gbit/s | Naive estimate at 1 Gbit/s |
|---|---|---|---|---|
| 20 ms | 270 ms | 198 ms | 191 ms | 0.8 ms |
| 75 ms | 545 ms | 473 ms | 466 ms | 0.8 ms |
| 150 ms | 920 ms | 848 ms | 841 ms | 0.8 ms |
Read across a row: a hundred times the bandwidth saves about 80 ms. Read down a column: every 75 ms of distance costs about 375 ms, because the load needs five sequential round trips (connect, secure, request, and two more rounds of data) and each one pays the distance again. The fix is in the count of round trips and in their length, not in the pipe.
Two terms get mixed up here. Latency is how long one message takes to arrive; throughput is how many bytes per second you actually get, which on a short transfer is far below the link’s bandwidth because the sender has not been allowed to use it yet (the slow start section shows why). And time to first byte (TTFB) in DevTools is not server time: it is one full round trip plus server time, so a 50 ms handler shows as a 200 ms TTFB to a user 150 ms away.
The core idea
Section titled “The core idea”A page load is not one download. It is a chain of discoveries: the browser cannot ask for the CSS until the HTML names it, cannot ask for the font until the CSS names it, and cannot call the API until the JavaScript runs. Each link in the chain costs at least one round trip, and a link to a host the browser has not talked to yet costs a DNS lookup plus a connection set-up on top. The load time is the length of the critical path: the longest chain of round trips that each wait for the previous one.
Everything in web performance attacks one of three numbers in that picture: the length of each round trip (move the server closer, or end connections at an edge near the user), the round trips per link (fewer handshakes, warm connections, bigger first windows), and the number of links in the chain (find resources earlier, cache them, cut dependencies). The sections below take the round trips one kind at a time.
How it works, piece by piece
Section titled “How it works, piece by piece”Before the first byte: set-up round trips
Section titled “Before the first byte: set-up round trips”On a new connection, the request cannot leave until two things are agreed, and each costs round trips.
The TCP handshake opens the connection. The client sends a SYN packet (“I want to talk; my
bytes are numbered from X”), the server answers SYN-ACK (“agreed; mine from Y”), and the client’s
final ACK can travel with its first data. Both sides now know the other is there and how to
number bytes: one round trip.
The TLS handshake then agrees on encryption keys and proves, with a certificate, that the
server owns the name. The keys come from a key exchange: each side sends a public value, its key
share, and both compute the same secret from the two. In TLS 1.2 the client sent its key share
only after the server’s first reply had named the method, and both sides then confirmed with a
Finished message: two round trips. TLS 1.3 lets the client guess the method and send its key
share in its very first message. Nearly every server accepts the common choice, so it answers with
its own key share, its certificate and Finished in one reply: one round trip (a wrong guess costs
a second).
QUIC, the transport under HTTP/3, runs over UDP, which has no handshake at all: a UDP packet is sent without any connection being set up first. QUIC builds connections, ordering and resending itself, and carries the TLS 1.3 messages inside its first packets, so both handshakes cost one round trip together.
A return visit can do better. At the end of a handshake the server can hand the client a session ticket: a small encrypted blob holding a secret from this connection, which only the server can open. Next time, the client presents the ticket and both sides derive keys from the stored secret, with no certificate to send or check; that is resumption. 0-RTT goes one step further: the client also encrypts its request with that secret and sends it in the first flight, beside the ticket, so the request needs no set-up round trip at all.
Here is what each option costs at 150 ms, with 50 ms of server work after the request arrives:
The jump from 650 ms to 200 ms is pure protocol: the server and the code did not change. The TLS page covers the handshake messages in full; three behaviours around them matter for timing:
- 0-RTT is not free. The first flight depends on nothing the server sends this time, so an
attacker who records it can send the same bytes again, and the server cannot tell the copy from
the original. A replayed
GET /homeis harmless; a replayedPOST /transfermoves the money twice. Servers should accept 0-RTT only for requests that are safe to run twice. nginx leaves it off unless you setssl_early_data on(nginx docs). Plain resumption without 0-RTT skips the certificate work but still costs the full round trip. - The first visit usually cannot use HTTP/3. A browser learns that a server speaks HTTP/3 from
an
Alt-Svcheader on an HTTP/2 response, so the first connection is TCP and only the next one is QUIC. AnHTTPSrecord in DNS (RFC 9460) can advertiseh3before the first connection, where the browser asks for it. And between 3% and 5% of networks block UDP entirely (RFC 9308), so browsers race QUIC against TCP and fall back without telling anyone. - Broken IPv6 costs 250 ms, silently. When a name has both IPv6 and IPv4 addresses, browsers try IPv6 first and start IPv4 in parallel if it has not connected after about 250 ms (Happy Eyeballs, RFC 8305). A broken IPv6 route is therefore never an error, only a quarter of a second added to every new connection.
DNS sits in front of all of this, once per new host. The resolver is the server your operating system sends lookups to (the ISP’s, or a public one such as 1.1.1.1); it finds the answer on your behalf and caches it. A hit in the browser’s or the operating system’s cache is free; a hit in the resolver’s cache costs one round trip to the resolver, usually a few milliseconds to tens; a miss sends the resolver to the root, the top-level domain and the domain’s own servers, which can take hundreds. The DNS page covers how those caches decide. For timing, the rule is that each extra hostname on the critical path is another lookup that may miss.
Slow start: why bandwidth does not help small responses
Section titled “Slow start: why bandwidth does not help small responses”The connection is open; now the response has to arrive. Three building blocks first.
TCP cuts the response into packets (segments) of at most about 1,460 bytes of data each: most links carry frames of 1,500 bytes, and the IP and TCP headers take 40 of them. The receiver answers with acknowledgements (ACKs), each saying “I have every byte up to N”. The sender keeps a copy of each packet until it is acknowledged, so it can resend anything that goes missing. A packet sent but not yet acknowledged is in flight.
The congestion window is the sender’s limit on packets in flight. It exists because every router on the path has a queue of limited size: a sender that pushed packets out as fast as its own network card allowed would overflow the queue at the slowest link, and that router would drop them. The window is the sender’s running guess at how much the path can hold. A packet’s ACK takes one round trip to come back, so with at most one window in flight the sender can put out at most one window per round trip: 10 packets (14.6 KB) every 150 ms is about 97 KB/s, or 0.8 Mbit/s, on a 1 Gbit/s line.
A new connection has measured nothing yet, so it starts small: an initial congestion window of 10 packets (RFC 6928). Every packet acknowledged grows the window by one packet, so when all 10 of the first round are acknowledged the window is 20, then 40, then 80: it doubles each round trip until a packet is lost. This is slow start, slow only in where it begins. Here it is on a timeline, with time running down the page:
Each round waits for the one before it, so the number of round trips a response needs depends on its size, not on the link:
| Response | Packets of 1,460 bytes | Round trips of data |
|---|---|---|
| 14 KB | 10 | 1 |
| 15 KB | 11 | 2 |
| 100 KB | 69 | 3 (10 + 20 + 39) |
| 1 MB | 685 | 7 |
How large must the window grow before the line itself becomes the limit? Think of the path as a pipe: new packets go in while earlier ones are still inside, and the first ACK comes back only after a full round trip. To keep the line busy for that whole time, the sender must have a round trip’s worth of the line’s capacity in flight. That amount, bandwidth × RTT, is the bandwidth-delay product (BDP):
| Line | RTT | Bandwidth × RTT | Packets of 1,460 bytes |
|---|---|---|---|
| 10 Mbit/s | 150 ms | 188 KB | 128 |
| 100 Mbit/s | 20 ms | 250 KB | 171 |
| 100 Mbit/s | 150 ms | 1.9 MB | 1,284 |
| 1 Gbit/s | 150 ms | 19 MB | 12,842 |
Until the window reaches the BDP, part of the line sits idle and more bandwidth changes nothing. Starting from 10 packets, the 100 Mbit/s line at 150 ms needs seven doublings to get there and the 1 Gbit/s line eleven, so the first second or more of every new connection runs far below the speed you paid for. This is the reason behind the advice to fit what the first screen needs into the first 14 KB of HTML: the first round trip is the only one that arrives with the first byte.
Change the inputs and watch the rounds. The bars are the packets sent in each round trip; the three bars below compare the same download on this link, with ten times the bandwidth, and with half the round trip.
The comparison bars carry the lesson: 455 ms on the default link, 450 ms with ten times the bandwidth, 230 ms with half the distance. Bandwidth only starts to matter once the window is wider than the pipe, which for a fresh connection means a response well past a megabyte. Raising the initial window to 30 packets saves a round trip on 100 KB, which is why several CDNs run with more than 10: a 2017 survey measured 32 at Akamai and 46 at CacheFly, against Linux’s default of 10 at Cloudflare and Fastly (CDN Planet, 2017; these values are old and may have changed).
Loss ends the doubling. A sender learns that a packet is lost in one of two ways: the ACKs keep naming the same missing byte while later packets arrive, or no ACK comes at all before the retransmission timeout (RTO) runs out, a timer of at least 200 ms plus the round trip on Linux. TCP reads a loss as a sign that some router’s queue overflowed, so it resends the packet, shrinks the window and from then on grows it with care. The rule for how far to shrink and how fast to grow is the congestion control algorithm. Linux’s default, CUBIC, cuts the window to 70%; the older Reno halves it. A window of 20 that loses a packet drops to 14 under CUBIC and 10 under Reno, then climbs back slowly instead of doubling.
In the sim, set the loss to 2%: one lost packet in the second round cuts the window to 70%, and from then on the sim grows it by one packet per round trip. The same 100 KB now takes six rounds, 900 ms. On a link that loses 1% of packets, 1 MB takes over six seconds at 150 ms. Loss is the reason a page that is fine on office Wi-Fi crawls on a train.
TLS records can cost a round trip of their own. TLS encrypts data in records of up to
16 KB, each sealed with an authentication tag at its end that proves no byte inside was changed.
Until the tag arrives the browser cannot check the record, so it cannot use any byte of it. A 16 KB
record is 12 packets; the first window carries 10, so the record completes only in the second round
trip, along with every byte inside it, including the <head> the browser wanted first. nginx
writes 16 KB records by default (ssl_buffer_size); Cloudflare patched it to start with records that fit one packet and grow them
later (Cloudflare, 2016).
Setting ssl_buffer_size 4k; is a common middle ground.
Reused connections, and what quietly resets them
Section titled “Reused connections, and what quietly resets them”A request on a connection that is already open skips DNS, TCP and TLS, and it finds a window that
has already grown. Measured against Cloudflare’s edge in Mumbai from a network in India, the first
request to example.com took 151 ms with set-up included and the second, on the same connection,
17 ms (the full output is in “Build it yourself”). Connection reuse is the
biggest saving on any page that is not the first, and several things take it away without telling
you.
Idle connections forget their window. Linux servers, by default, shrink the congestion window
of a connection that has been idle for longer than its retransmission timeout: the window halves
for each timeout of idleness, down to the initial 10 packets. The setting is net.ipv4.tcp_slow_start_after_idle, on by default
(kernel docs). A keep-alive
connection that sat quiet for a second while the user read the page is back to slow start for the
next response. Servers that hold long-lived connections to clients (HTTP/2 front ends, APIs called
in bursts) often turn it off.
HTTP/1.1 opens many connections, each one cold. HTTP/1.1 carries one response at a time per connection, so browsers open several per host: six in Chromium, as of October 2026 (Chromium source). Each pays its own handshakes and starts its own slow start.
HTTP/2 and HTTP/3 send every request to a host over one connection, which stays warm. They can,
because they cut each request and response into small frames, each labelled with a stream
number, one stream per request. Frames of different responses take turns on the wire and the
receiver sorts them back by number, so [1: HTML] [3: CSS] [1: HTML] [5: JS] [3: CSS] is three
responses arriving at once on one connection.
HTTP/2 can even coalesce hosts: send requests for a second hostname down a connection opened
for the first. Two checks make that safe. The connection’s TLS handshake proved the server’s
identity only for the names on its certificate, and one certificate can list many names, so the new
name must be on it. And browsers also want the new name to resolve to the same address, as a sign
that it is the same server. A connection to www.example.com whose certificate lists
www.example.com and img.example.com, both at 203.0.113.10, carries the image requests too
(RFC 9113, section 9.1.1); a font on
another company’s CDN still needs its own connection. The old HTTP/1.1 trick of domain
sharding, spreading files over static1, static2 and so on to get more parallel connections,
now costs extra lookups and handshakes for nothing.
The browser keeps separate pools. “The connection is already open” is true less often than it looks, because the browser will not mix connections across certain boundaries:
Two of those boundaries surprise people. The first is credentials. A credentialed request
carries the user’s cookies, or a client certificate; an anonymous one carries none. Browsers
keep the two on separate connections, so that state attached to a connection, such as a client
certificate sent in its TLS handshake, can never tie an anonymous request to the signed-in user.
Fonts are always fetched in anonymous CORS mode, so they cannot reuse a connection opened for
credentialed requests; a <link rel="preconnect"> to a font host does nothing useful unless it
carries crossorigin (web.dev).
The second is partitioning. The browser’s stores (HTTP cache, DNS cache, open connections, TLS
session tickets) used to be keyed by what was fetched. Now the key also holds the top-level
site, the site in the address bar. A shared store leaks: a page on evil.example asks for a file
only bank.example uses, and if it arrives in 2 ms instead of 200 ms (illustrative numbers), you
have been to the bank. With the site in the key, one file used by two sites is two entries:
| Address bar | Request | Shared key | Partitioned key |
|---|---|---|---|
news.example |
cdn.shared.net/lib.js |
lib.js: miss, stored |
(news.example, lib.js): miss, stored |
shop.example |
cdn.shared.net/lib.js |
lib.js: hit |
(shop.example, lib.js): miss, stored |
Chromium partitions connections, DNS cache entries and TLS session tickets this way, so a connection your site opened to a shared CDN cannot be reused by another site (Chromium Intent to Ship, 2022), and browsers partition the HTTP cache too (cache section below).
HTTP/1.1, HTTP/2 and HTTP/3: where head-of-line blocking went
Section titled “HTTP/1.1, HTTP/2 and HTTP/3: where head-of-line blocking went”Head-of-line blocking is when one slow or lost item holds up items behind it that are ready. Its transport form comes from what TCP promises the application: one stream of bytes, in exactly the order they were sent. Each packet carries the position of its bytes in that stream. When one goes missing, the receiver’s operating system holds every later packet in a buffer and hands nothing on until the resend fills the gap, at least one round trip later.
Take six packets on one connection carrying three interleaved responses, and lose packet 2:
| Packet | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| Stream | HTML | CSS | JS | HTML | JS | CSS |
| HTTP/2 over TCP | used | lost | held | held | held | held |
| HTTP/3 over QUIC | used | lost | used | used | used | held |
Over TCP, the HTML and the JavaScript wait for a CSS packet they do not need. QUIC numbers bytes per stream, so only the CSS waits. Each HTTP version moved the blocking to a different layer:
- HTTP/1.1: one response at a time per connection, so a slow response blocks the next request on that connection. Browsers work around it with six connections.
- HTTP/2: streams interleave, so no response waits for another at the HTTP layer, but TCP’s in-order delivery brings the blocking back on every loss, as in the table.
- HTTP/3: a lost packet stalls only its own stream.
That suggests HTTP/3 should win clearly on lossy links. Watch what happens with twelve files on a link that loses 2% of packets, with every connection already open so only the transfer counts. Amber marks bytes that have arrived but cannot be used, because an earlier packet is missing:
Two things show up that the protocol diagrams do not tell you. First, HTTP/3 removes the amber, but with these defaults it finishes at the same 2.1 s as HTTP/2: both run every file through one congestion window, and every loss cuts that one window. Head-of-line blocking delays individual files; the shared window slows all of them. Second, HTTP/1.1 finishes first, at 600 ms, because six connections means six windows: 60 packets in the first round instead of 10, and a loss cuts only one sixth of the sending. That matches an old observation from curl’s author that at around 2% loss, HTTP/1.1 users tend to do better than HTTP/2 users (HTTP/3 explained). The sim’s model is simplified (no selective acknowledgements, which tell the sender exactly which packets are missing, and no pacing, which spreads a window’s packets out in time), so read its gaps as directions, not predictions.
HTTP/3’s real gains come from elsewhere: one round trip less to connect, 0-RTT on return visits, and better loss recovery. Google reported that QUIC cut search latency by 8% on desktop and 3.6% on mobile, and YouTube rebuffering by 18% and 15% (Langley et al., SIGCOMM 2017). HTTP/3 carried about 21% of requests to Cloudflare in 2025, against 50% for HTTP/2 (Cloudflare Radar 2025).
The critical path: what waits for what
Section titled “The critical path: what waits for what”Now put the pieces together on a whole page. This one has HTML on www, a stylesheet and a 120 KB
script on the same host, eight images on an image host, a web font on a third-party font CDN, and an
API call the script makes once it has run. The sim works out, at every millisecond, what the
browser knows it needs, which connection can carry it, and when each window of data arrives.
The defaults are the story from the opening: 50 ms of server work, about 1.8 s of loading. Read the rows from the top and the chain is visible:
- HTML: lookup, two set-up round trips, the request: first byte at 540 ms.
- CSS and JS are named in the first HTML packets and ride the same HTTP/2 connection, whose window has already grown.
- The font is not discovered until the CSS has arrived and been parsed, at 990 ms. New host: a lookup and two round trips more before its request even leaves.
- The API call waits for the JavaScript to download and run, then pays for a new host as well. It is the last thing to finish, at 1.76 s.
Now try the levers one at a time and compare with the defaults:
| Change | First render | Everything loaded | Why |
|---|---|---|---|
| None (HTTP/2, 150 ms, first visit) | 990 ms | 1.78 s | |
| HTTP/1.1 | 1.14 s | 2.06 s | CSS and JS need new connections while the HTML one is busy; 11 connections, all cold |
| HTTP/3 | 840 ms | 1.48 s | One set-up round trip less on each of four hosts |
| Preconnect to font and API hosts | 990 ms | 1.78 s (API at 1.42 s) | Set-up overlaps the HTML download instead of following it |
| CDN edge 20 ms away | 360 ms | 760 ms | Every handshake and slow-start round costs 20 ms, not 150 |
| Repeat visit | 650 ms | 1.08 s | DNS and static files come from caches; HTML and the API call still pay |
The table makes the priorities plain. Preconnect does not move first render at all here, because the font and API are not on the chain to it, but it takes 340 ms off the moment the data appears. The CDN beats every protocol change, because it shortens every round trip at once. And the repeat visit is still over a second, because the two per-user responses (the HTML and the API call) are exactly the ones no cache can serve.
The tools for shortening the chain itself: the browser’s preload scanner already reads ahead in
the HTML for files it will need; <link rel="preload"> names files it cannot find that way (a font
inside CSS, an API response); rel="preconnect" starts DNS and handshakes for hosts it will need;
and the 103 Early Hints status lets the server send those hints while it is still building the
HTML (RFC 8297). The most effective fix is structural:
render the data the first view needs into the HTML, so there is no JavaScript-then-API link at all.
Caches at every layer, and what each one skips
Section titled “Caches at every layer, and what each one skips”Every layer of the journey has a cache, and each one removes a different set of round trips. Two
HTTP cache terms first. A stored response is fresh for as long as its Cache-Control: max-age
allows, and the browser uses it without asking anyone. After that it is
stale: before using it, the browser sends a conditional request carrying the response’s
validator (If-None-Match with its ETag), and if nothing changed the server answers
304 Not Modified with no body. A file stored at 10:00 with max-age=600 costs no request at all
at 10:05, and one round trip for a 304 at 10:15.
The caches fail differently too:
| Cache | Skips | Lifetime set by | What surprises people |
|---|---|---|---|
| Browser and OS DNS cache | the lookup | the record’s TTL | Moving a server takes up to the old TTL to reach everyone |
| HTTP cache, fresh entry | everything: no request at all | Cache-Control: max-age |
Partitioned by top-level site since Chrome 86 (2020) and Firefox 85 (2021): a library on a shared public CDN is downloaded again for every site |
| HTTP cache, stale entry | the body only | ETag or Last-Modified |
A 304 Not Modified still costs a full round trip, plus a connection if none is open |
| TLS session ticket | certificate checks; with 0-RTT, the TLS round trip | the server’s ticket lifetime | Without 0-RTT, resumption saves CPU, not a round trip |
| Open connection | DNS, TCP and TLS, and the window has grown | browser and server idle timeouts | An idle connection may be back in slow start |
| CDN edge | the trip to the origin | Cache-Control, s-maxage |
Even a miss is faster than going direct (next section) |
| Back/forward cache | the whole page load | the browser | some pages are not eligible, for example ones holding an open WebSocket |
So the still-repeated advice to “load jQuery from a public CDN so it is already cached” no longer works (Chrome developers blog): self-host it on a host you already have a connection to.
The second-visit numbers depend heavily on Cache-Control. A file served with
max-age=31536000, immutable and a content hash in its name (app.3f9a2c.js) costs nothing on the
next visit. The same file with no-cache costs a round trip every time to learn that it has not
changed.
Why a CDN speeds up pages it cannot cache
Section titled “Why a CDN speeds up pages it cannot cache”Back to the second question from the top. A CDN edge is a server near the user that ends the
user’s TCP and TLS connection. Users reach the nearest edge in one of two ways. With anycast,
the CDN announces the same IP address from every city it is in, and routers send each packet to the
nearest announcement (nearest by the routing rules, which is usually near on the map too): the
example.com addresses in “Build it yourself” reach Cloudflare’s Mumbai edge
from India, and the same addresses reach a different machine from Frankfurt. With DNS steering, the CDN’s DNS servers answer each
resolver with the address of an edge close to it. For a per-user page
it has to ask the origin, but it does so over a connection it keeps open all the time, whose window
is already large. The handshakes and the slow-start rounds, the round trips that multiply, now
happen over 20 ms instead of 150:
From 690 ms to 320 ms for a response the CDN never stores. This is called split TCP, or TCP termination at the edge, and it is the part of a CDN’s value that does not depend on cache hit ratios. The cost: one more hop that can fail, and a provider in the middle of your TLS.
Choosing what to fix first
Section titled “Choosing what to fix first”Measure before you choose: the DevTools Network tab breaks every request into queueing, DNS, initial connection, SSL, waiting (TTFB) and content download. Then match the pattern:
| What the waterfall shows | Likely cause | Fix, cheapest first |
|---|---|---|
| Long “initial connection” and “SSL” on many rows | many hosts, distant server | fewer hosts, preconnect, a CDN, HTTP/3 |
| TTFB far above your server time | distance (TTFB = RTT + server) | a CDN edge, or a region nearer the users |
| TTFB high even nearby | the server | your code, queries, downstream calls |
| Gaps where nothing downloads | a dependency chain | preload, inline critical CSS, render data into HTML |
| A large file that takes many round trips | slow start, or loss | smaller critical files, compression, bigger initial window at the edge |
| Fine on Wi-Fi, slow on mobile | loss and RTT variance | fewer round trips everywhere; HTTP/3 helps most here |
Build it yourself
Section titled “Build it yourself”Three tools, from arithmetic to measurement. All three use only the standard library; the files are
in examples/how-the-web-works/.
Step 1: a latency budget in round trips
Section titled “Step 1: a latency budget in round trips”Before measuring anything, count. This is the model behind the table near the top of the page: the naive estimate divides bytes by bandwidth; the real one counts set-up, the request, server work and slow-start rounds.
# runnable version: examples/how-the-web-works/python/latency_budget.pyMSS = 1460 # bytes of payload in one TCP packetINITCWND = 10 # packets in the first round trip of data (RFC 6928)
def data_rounds(size_bytes: int, initcwnd: int = INITCWND) -> int: """Round trips of data a fresh connection needs: the window doubles every round trip.""" packets = math.ceil(size_bytes / MSS) rounds, window = 0, initcwnd while packets > 0: packets -= window window *= 2 rounds += 1 return rounds
def naive_ms(size_kb: int, mbps: float) -> float: """What most people estimate: bytes divided by bandwidth.""" return size_kb * 1000 * 8 / (mbps * 1000)
def fetch_ms(size_kb: int, rtt: float, mbps: float, server: float = 50, dns: float = 40, setup_rtts: int = 2, origin_rtt: float = 0) -> float: """First byte to last byte of one response on a new connection.
setup_rtts: 2 for TCP + TLS 1.3, 1 for QUIC, 0 for a connection that is already open. origin_rtt: for an edge that forwards to the origin on its own open, warm connection. """ rounds = data_rounds(size_kb * 1000) wire = naive_ms(size_kb, mbps) # the bandwidth term: only matters once the window is large return dns + setup_rtts * rtt + rtt + origin_rtt + server + (rounds - 1) * rtt + wire// runnable version: examples/how-the-web-works/js/latency-budget.mjsconst MSS = 1460; // bytes of payload in one TCP packetconst INITCWND = 10; // packets in the first round trip of data (RFC 6928)
/** Round trips of data a fresh connection needs: the window doubles every round trip. */function dataRounds(sizeBytes, initcwnd = INITCWND) { let packets = Math.ceil(sizeBytes / MSS); let rounds = 0; for (let window = initcwnd; packets > 0; window *= 2) { packets -= window; rounds++; } return rounds;}
/** What most people estimate: bytes divided by bandwidth. */const naiveMs = (sizeKB, mbps) => (sizeKB * 1000 * 8) / (mbps * 1000);
/** * First byte to last byte of one response on a new connection. * setupRtts: 2 for TCP + TLS 1.3, 1 for QUIC, 0 for a connection that is already open. * originRtt: for an edge that forwards to the origin on its own open, warm connection. */function fetchMs(sizeKB, rtt, mbps, { server = 50, dns = 40, setupRtts = 2, originRtt = 0 } = {}) { const rounds = dataRounds(sizeKB * 1000); const wire = naiveMs(sizeKB, mbps); // the bandwidth term: only matters once the window is large return dns + setupRtts * rtt + rtt + originRtt + server + (rounds - 1) * rtt + wire;} RTT bandwidth naive real 20 ms 10 Mbit/s 80.0 ms 270 ms 20 ms 1000 Mbit/s 0.8 ms 191 ms 150 ms 10 Mbit/s 80.0 ms 920 ms 150 ms 1000 Mbit/s 0.8 ms 841 ms
Round trips of data for a fresh window of 10 packets: 14 KB -> 1 15 KB -> 2 100 KB -> 3 1000 KB -> 7
30 KB page that no cache can hold, user 150 ms from the origin: direct: 692 ms through an edge 20 ms away: 322 msThe model ignores loss, and so it is a floor, not a forecast. That is its use: if the floor for your users is already over your budget, no amount of server tuning will get you under it.
Step 2: every step by hand, with a stopwatch
Section titled “Step 2: every step by hand, with a stopwatch”Now measure a real site. Instead of one fetch, this client resolves the name, opens TCP, wraps it
in TLS and writes the HTTP request itself, timing each step, then sends a second request on the same
connection. Reading the response (including chunked bodies) is in the full file.
# runnable version: examples/how-the-web-works/python/by_hand.py # 1. DNS: turn the name into addresses. getaddrinfo asks the operating system, which asks # its configured resolver (and may answer from its own cache). t = time.perf_counter() infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) dns = time.perf_counter() - t
# 2. TCP: one round trip (SYN, SYN-ACK; our ACK rides along with the next packet). family, _, _, _, sockaddr = infos[0] t = time.perf_counter() raw = socket.socket(family, socket.SOCK_STREAM) raw.settimeout(5) # never wait for ever on any one step raw.connect(sockaddr) tcp = time.perf_counter() - t
# 3. TLS: agree on keys, check the certificate, and pick HTTP/1.1 via ALPN. context = ssl.create_default_context() context.set_alpn_protocols(["http/1.1"]) t = time.perf_counter() tls = context.wrap_socket(raw, server_hostname=host) # server_hostname = SNI + cert check handshake = time.perf_counter() - t
# 4. HTTP: the request is plain text inside the encrypted tunnel. request = f"GET {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: by-hand/1.0\r\nConnection: keep-alive\r\n\r\n" reader = Reader(tls) t = time.perf_counter() tls.sendall(request.encode()) status, headers, ttfb, size = read_response(reader, t)// runnable version: examples/how-the-web-works/js/by-hand.mjs // 1. DNS: turn the name into addresses. lookup() asks the operating system, which asks // its configured resolver (and may answer from its own cache). let t = now(); const addresses = await lookup(host, { all: true, order: 'ipv6first' }); const dns = now() - t;
// 2. TCP: one round trip (SYN, SYN-ACK; our ACK rides along with the next packet). const { address } = addresses[0]; t = now(); const raw = net.connect({ host: address, port: 443, timeout: 5000 }); // never wait for ever raw.on('timeout', () => raw.destroy(new Error('connect timed out'))); await once(raw, 'connect'); const tcp = now() - t;
// 3. TLS: agree on keys, check the certificate, and pick HTTP/1.1 via ALPN. t = now(); const sock = tls.connect({ socket: raw, servername: host, ALPNProtocols: ['http/1.1'] }); // servername = SNI await once(sock, 'secureConnect'); const handshake = now() - t;
// 4. HTTP: the request is plain text inside the encrypted tunnel. const request = `GET ${path} HTTP/1.1\r\nHost: ${host}\r\nUser-Agent: by-hand/1.0\r\nConnection: keep-alive\r\n\r\n`; const reader = new Reader(sock); t = now(); sock.write(request); const first = await readResponse(reader, t);Run from a network in India on 18 October 2026:
Loading https://example.com/
1. DNS lookup 39.7 ms example.com -> 2606:4700:10::6814:179a, 2606:4700:10::ac42:93f3, 104.20.23.1542. TCP connect 14.1 ms to 2606:4700:10::6814:179a port 443 (about one round trip)3. TLS handshake 23.8 ms TLSv1.3, TLS_AES_256_GCM_SHA384, ALPN=http/1.1 certificate issued by SSL Corporation, valid until Dec 25 22:56:35 2026 GMT4. First byte 73.7 ms after sending the request: HTTP/1.1 200 OK5. Whole body 73.9 ms 577 bytes, Content-Type: text/html; charset=utf-8 transfer-encoding: chunked age: 2031 server: cloudflare cf-ray: a4c15fa4bcf7ca5b-BOM alt-svc: h3=":443"; ma=86400
First request, everything included: 151.5 msSecond request, same connection: 16.7 ms (HTTP/1.1 200 OK)Read it against the rest of the page. The TCP connect took 14 ms: that is the round trip to
Cloudflare’s edge in Mumbai (cf-ray ends in BOM, Mumbai’s airport code), not to wherever
example.com’s origin lives; the same step to badssl.com, hosted in the US, took about 230 ms.
age: 2031 says the edge served a copy cached 34 minutes earlier. alt-svc: h3 is the server
offering HTTP/3 for next time. The first byte took 74 ms although the edge had the page cached, a
sign that its TTFB includes more than one round trip of edge work. And the second request, with no
set-up and a warm connection, took 17 ms: 89% of the first request was set-up.
Step 3: a timeout on every step
Section titled “Step 3: a timeout on every step”The production-shaped version is a probe, the kind uptime monitors run: a timeout on each step and, when one fails, the name of the step. Here is the connect step; DNS, TLS and the response follow the same pattern in the full file.
# runnable version: examples/how-the-web-works/python/probe.pyTIMEOUTS = {"dns": 3.0, "connect": 3.0, "tls": 5.0, "response": 10.0} # seconds, per step
def connect(infos: list[tuple]) -> socket.socket: # Try each address in turn (browsers race IPv6 and IPv4 instead: "Happy Eyeballs"). last = StepFailed("connect", "no addresses", "ERR_NAME_NOT_RESOLVED") for family, _, _, _, sockaddr in infos: sock = socket.socket(family, socket.SOCK_STREAM) sock.settimeout(TIMEOUTS["connect"]) try: sock.connect(sockaddr) return sock except TimeoutError: last = StepFailed("connect", f"no answer from {sockaddr[0]}", "ERR_CONNECTION_TIMED_OUT") except ConnectionRefusedError: last = StepFailed("connect", f"{sockaddr[0]} refused", "ERR_CONNECTION_REFUSED") sock.close() raise last// runnable version: examples/how-the-web-works/js/probe.mjsconst TIMEOUTS = { dns: 3000, connect: 3000, tls: 5000, response: 10000 }; // ms, per step
/** Try each address in turn (browsers race IPv6 and IPv4 instead: "Happy Eyeballs"). */async function connect(addresses, port) { let last = new StepFailed('connect', 'no addresses', 'ERR_NAME_NOT_RESOLVED'); for (const { address } of addresses) { const sock = net.connect({ host: address, port }); try { await within(TIMEOUTS.connect, new Promise((ok, fail) => sock.once('connect', ok).once('error', fail)), () => new StepFailed('connect', `no answer from ${address}`, 'ERR_CONNECTION_TIMED_OUT')); return sock; } catch (err) { sock.destroy(); last = err instanceof StepFailed ? err : new StepFailed('connect', `${address} refused`, 'ERR_CONNECTION_REFUSED'); } } throw last;}https://example.com/ dns 25 connect 17 tls 115 first byte 74 (total 230 ms) OK: HTTP/1.1 200 OK
https://10.255.255.1/ dns 0 connect 3009 (total 3009 ms) FAILED at connect: no answer from 10.255.255.1 -> a browser shows ERR_CONNECTION_TIMED_OUT
https://expired.badssl.com/ dns 14 connect 233 tls 349 (total 596 ms) FAILED at tls: certificate has expired -> a browser shows NET::ERR_CERT_DATE_INVALIDThe middle case is why the timeout exists: 10.255.255.1 is a private address that nothing answers
on, and without our 3 s limit the connect call waits for the operating system’s own limit, which
the gotchas below explain.
For a quick look without code, curl reports when each step finished
(curl/timing.sh):
# runnable version: examples/how-the-web-works/curl/timing.shcurl --silent --output /dev/null --write-out ' DNS done: %{time_namelookup} s TCP connected: %{time_connect} s TLS finished: %{time_appconnect} s request sent: %{time_pretransfer} s first byte: %{time_starttransfer} s total: %{time_total} s HTTP version: %{http_version}, status %{http_code}, from %{remote_ip}' "$url" DNS done: 0.021631 s TCP connected: 0.037901 s TLS finished: 0.153071 s request sent: 0.153666 s first byte: 0.173056 s total: 0.175590 s HTTP version: 2, status 200, from 172.66.147.243Each number is cumulative, so a step’s cost is the gap from the line above: here 16 ms of TCP and 115 ms of TLS, which is more than one 16 ms round trip. A TLS step far longer than the TCP step usually means the server is slow to sign (CPU-bound handshakes) or the certificate chain needs more than one window to arrive.
How real systems do it
Section titled “How real systems do it”CDNs attack the length of every round trip, with anycast edges near users and warm connections
to the origin. That is why the example.com measurement above connected in 14 ms from India.
Browsers attack the count of round trips. Chrome races QUIC against TCP, races IPv6 against
IPv4, keeps connections open for reuse, reads ahead with the preload scanner, and honours
preconnect, preload and 103 Early Hints. When it does not yet know whether a server speaks
HTTP/2, it may open several connections at once rather than wait to find out.
Search and video engineered the protocol itself. Google built QUIC because TCP could not be changed fast enough. TCP is code inside the operating system’s kernel, so a change reaches users only with OS upgrades, and middleboxes (firewalls, home routers and load balancers along the path) read TCP headers and often drop packets that use options they do not recognise. QUIC encrypts nearly all of its headers, so middleboxes cannot depend on them, and it ships as a library inside the browser and the server, which is why HTTP/3 can evolve without kernel upgrades. Its 0-RTT return visits and its per-stream ordering are the two features this page has shown the value and limits of.
Gotchas and war stories
Section titled “Gotchas and war stories”A redirect is a whole extra load. http:// to https://, or example.com to
www.example.com, costs at least one round trip and often a new lookup and handshake for the new
host, before the real page starts. HSTS and the browser’s preload list
(hstspreload.org) remove the http:// one; the www one you remove by
linking to the right name. And browsers cache 301 responses, often with no expiry, so a mistaken
permanent redirect keeps costing after you fix it; use 302 while testing.
A 304 is not free. Revalidation saves the body, not the round trip. A page whose twenty
static files are served with no-cache makes twenty conditional requests on every visit, and on a
150 ms link that is never fast, however small the answers are.
The fast path fails silently, by design. Broken IPv6 shows up as 250 ms of Happy Eyeballs delay.
A firewall that drops UDP turns HTTP/3 into HTTP/2 after a race. An idle connection quietly returns
to slow start. A font preconnect without crossorigin is wasted. None of these produce an error; all
of them show up only as a slower waterfall, which is why the waterfall is the first thing to read.
Trade-offs and limits of the model
Section titled “Trade-offs and limits of the model”- Fewer round trips often costs safety or freshness. 0-RTT can be replayed. Long cache lifetimes make changes slow to reach users. Long DNS TTLs make moves slow.
- Fewer connections are not always faster. One HTTP/2 connection is kinder to the network and to the server than six, but on a lossy link six windows can beat one, as the sim showed.
- An edge is another dependency. A CDN removes round trips and adds a hop that can fail, a bill, and a party that terminates your TLS.
- This page’s arithmetic assumes distance dominates. Inside one data centre the RTT is well under a millisecond and server work dominates instead; for a chat or streaming connection that stays open, set-up is paid once and per-message latency is about one way. Large downloads (video, software updates) are bandwidth-bound, and there the bandwidth figure does matter.
Interview corner
Section titled “Interview corner”“What happens when you type a URL and press Enter?” is asked at every level. Listing DNS, TCP, TLS and HTTP is what everyone does; what separates answers is where the time and the failures are.
- “Walk me through it.” Give the order in one breath, then go where the interviewer wants. Strong signals: round trips per step and how many overlap, what a repeat visit skips and why, and the critical path of dependent requests after the HTML.
- “A page is fast in the US and slow in India. How do you investigate?” Measure from India. Separate round trips from server time in the waterfall. Count set-up round trips per host and dependency links. Fixes in order of leverage: an edge near users, fewer hosts and redirects, preconnect and preload, HTTP/3, then server work.
- “Why is HTTP/2 sometimes slower than HTTP/1.1?” One TCP connection means one congestion window and in-order delivery: a loss stalls every stream and cuts the only window. Six HTTP/1.1 connections have six windows. HTTP/3 fixes the in-order part, not the shared window.
- “How can a CDN help a page it cannot cache?” It ends TCP and TLS near the user, so handshakes and slow start run at the short RTT, and it reaches the origin over a warm connection.
Check yourself
Section titled “Check yourself”1. A user is 100 ms from your server. DNS is cached and the server needs 30 ms. How long until the last byte of a 40 KB HTML response on a new TCP + TLS 1.3 connection? With HTTP/3?
40 KB is 28 packets: 10 in the first round, 18 in the second, so two rounds of data. TCP 100 + TLS 100 + request 100 + server 30 + one more data round 100 = 430 ms. HTTP/3 removes one set-up round trip: 330 ms. Bandwidth barely appears in either number.
2. You add <link rel="preconnect" href="https://fonts.example-cdn.com"> and the waterfall still shows a new connection when the font loads. Why?
Fonts are fetched in anonymous CORS mode, which uses a different connection pool from credentialed
requests. The preconnect opened a credentialed connection the font cannot use. Add crossorigin to
the link.
3. Your API server keeps connections alive for 60 s, yet a client that calls it every two seconds sees each response take as many round trips as on a fresh connection, minus the handshakes. What is going on?
Slow start after idle. Two seconds of silence is longer than the retransmission timeout, so Linux
shrinks the congestion window back towards 10 packets before the next response. Turning off
net.ipv4.tcp_slow_start_after_idle on the server keeps the window between bursts.
4. A repeat visit with every static file cached still takes over a second at 150 ms. Which round trips are left, and what would remove them?
A new connection to the HTML host (two set-up round trips, or none with QUIC 0-RTT), the HTML request itself, and the same again for the API host after the cached JavaScript runs. Rendering the first view’s data into the HTML removes the API link; an edge shortens every remaining round trip; HTTP/3 with 0-RTT removes the set-up.
- Page load time is round trips on the critical path × RTT, plus server time. Bandwidth matters only once a transfer is past slow start.
- Set-up before the first byte: 4 round trips with TLS 1.2, 3 with TLS 1.3, 2 with HTTP/3, 1 with 0-RTT or a reused connection. Each new host pays it again, plus DNS.
- Slow start sends 10 packets (about 14 KB), then doubles per round trip: 100 KB needs three rounds, and one loss ends the doubling.
- Warm connections are the biggest saving, and idle timeouts, separate pools (anonymous vs credentialed, per top-level site) and HTTP/1.1’s six cold connections take it away.
- HTTP/3 removes TCP’s head-of-line blocking but keeps one shared congestion window.
- Every layer has a cache that skips different round trips; a
304still costs one, and a CDN helps even uncacheable pages by ending connections near the user.
Where to go next
Section titled “Where to go next”Further reading
Section titled “Further reading”Specs & official docs
- RFC 6928, Increasing TCP’s initial window — the 10-packet first flight behind the 14 KB rule.
- RFC 9113, HTTP/2 (section 9.1.1 on connection reuse) and RFC 9114, HTTP/3 — the two multiplexed versions.
- RFC 9000, QUIC and RFC 9308, applicability of QUIC — the transport, and the 3–5% of networks that block UDP.
- RFC 9460, SVCB and HTTPS records — learning about HTTP/3 before the first connection.
- RFC 8305, Happy Eyeballs version 2 — how clients race IPv6 and IPv4, and the 250 ms delay.
- RFC 8297, 103 Early Hints — sending preload hints while the page is still being built.
- Linux ip-sysctl documentation and tcp(7) —
tcp_slow_start_after_idle,tcp_syn_retriesand the 127 s connect timeout. - nginx SSL module —
ssl_buffer_sizeandssl_early_data. - Chromium client socket pool manager — the six-connections-per-host limit, in the source.
- hstspreload.org — removing the
http://redirect for first visits.
Engineering blogs, papers & data
- Mike Belshe: More bandwidth doesn’t matter (much) (2010) — the original bandwidth versus RTT measurement.
- Mostafa, Wittie and Goel: Does more bandwidth really not matter (much)? (2025) — where bandwidth does matter, on mobile and for images.
- Langley et al.: The QUIC transport protocol (SIGCOMM 2017) — why QUIC exists and what it measured at Google.
- Cloudflare: Optimizing TLS over TCP to reduce latency (2016) — TLS records that straddle round trips.
- CDN Planet: initcwnd settings of major CDN providers (2017) — measured initial windows; dated.
- Chrome developers: Gaining security and privacy by partitioning the cache — why shared CDN copies no longer hit.
- Chromium: Intent to Ship, network state partitioning — connections, DNS and TLS state keyed by top-level site.
- Cloudflare Radar 2025 Year in Review — HTTP version shares.
- cloudping.co — live round-trip times between cloud regions.
Explainers
- web.dev: Establish network connections early — preconnect, dns-prefetch and the
crossorigintrap. - Daniel Stenberg: HTTP/3 explained — TCP’s head-of-line blocking and why QUIC moved off it.
- Ilya Grigorik: High Performance Browser Networking — latency, slow start and the browser’s networking, with the arithmetic.
Senior Dev Notes
System design, explained like a mentor would.
Written by Uday. Every page is researched, reviewed by a human, and every code example was run before it was published.