Skip to content

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.

beginner46 min readReviewed 23 October 2026

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.

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.

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.

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.

A page load is the longest chain of dependent round tripsThe HTML is fetched first. Its first packets name the CSS, the JavaScript and the images, which each cost at least one more round trip. The CSS names a web font on another host, which costs a DNS lookup and three more round trips. The JavaScript, once it runs, calls an API on another host, which also costs a lookup and three round trips. The images are not needed for the first render. The page is ready when the longest chain, HTML then JavaScript then API, finishes.HTMLCSSJSImagesFontAPI callnamed in the firstHTML packets+1+3 RTT+3 RTT,after JS runsimages: offthis chainnew host = DNS + TCP + TLS + requestlongest chain: HTML → JS → API
The critical path of a typical page. Each arrow is at least one round trip; an arrow to a new host adds a DNS lookup, a TCP and a TLS round trip before the request. The page is ready when the longest chain finishes, here HTML, then JavaScript, then the API call.

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.

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:

Round trips before the first byteFive rows, each a row of blocks, one block per round trip at 150 ms, plus 50 ms of server work. TCP with TLS 1.2: four round trips, 650 ms. TCP with TLS 1.3: three, 500 ms. HTTP/3 over QUIC: two, 350 ms. QUIC with 0-RTT resumption: one, 200 ms, the same as a request on a connection that is already open.TCP + TLS 1.2TCP + TLS 1.3HTTP/3 (QUIC)QUIC, 0-RTT resumeConnection reusedTCPTLSTLSGETTCPTLSGETQUICGETGETGET650 ms500 ms350 ms200 ms200 ms
Round trips before the first byte at 150 ms per round trip, plus 50 ms of server work. Each newer protocol removes one round trip; 0-RTT resumption and a reused connection leave only the request itself.

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 /home is harmless; a replayed POST /transfer moves the money twice. Servers should accept 0-RTT only for requests that are safe to run twice. nginx leaves it off unless you set ssl_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-Svc header on an HTTP/2 response, so the first connection is TCP and only the next one is QUIC. An HTTPS record in DNS (RFC 9460) can advertise h3 before 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:

The congestion window doubling each round tripA timeline with the server on the left and the browser on the right, time running downwards. The server sends 10 packets; they cross to the browser, and the acknowledgements cross back. Only when those arrive, one round trip later, does the server send the next window, now 20 packets, and one round trip after that, 40 packets.ServerBrowser1 RTT1 RTT10 packets20 packets40 packetsdata packetsACKs
Slow start on a timeline. The server sends a window of 10 packets (the thickness of each blue band is its packet count); the browser's ACKs come back in green, and each one grows the window by a packet. The next window can only leave when the ACKs arrive, so every round costs a full round trip however fast the line is.

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.

SimulationOne response, one fresh connection, round by round

What to watch for. With the defaults, 100 KB needs three round trips of data (10, 20, then 39 packets) and arrives about 455 ms after the request, while the link alone could carry it in 8 ms. Ten times the bandwidth saves 4 ms; half the round trip saves 225 ms. Then set the size to 10 KB and 20 KB, and add 2% loss to watch one lost packet end the doubling.

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:

One server, three separate connectionsThree requests from one browser go to the same server, cdn.example. A script loaded by site A with cookies, a web font loaded by site A in anonymous mode, and a script loaded by site B with cookies each land in a different connection pool, so the browser opens three connections and pays three handshakes and three cold windows.one browser, three poolssite A, script, cookiessite A, font, anonymoussite B, script, cookiescdn.example3 handshakes,3 cold windows
One server, three connections. A request with cookies and an anonymous request (fonts are always fetched this way) use different pools, and so do requests made on behalf of different top-level sites. Each pool pays its own handshakes and its own slow start.

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:

SimulationTwelve files, one lossy link, three protocols

What to watch for. Watch the HTTP/2 lane turn amber after a loss: bytes for later files have arrived but wait behind one missing packet. The HTTP/3 lane never shows amber, yet finishes at about the same time, because both run every file through one congestion window that each loss cuts. HTTP/1.1, with six connections and so six windows, finishes first. Set loss to 0: HTTP/1.1 still starts faster, which is what six handshakes buy.

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).

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.

SimulationOne page, four hosts, and the chain that sets its speed

What to watch for. With the defaults the server works for 50 ms, but the page needs about 1.8 s: the font waits for the CSS, the API call waits for the JavaScript, and each new host pays a lookup and two round trips of set-up. Set the CDN to 1: every connection now ends 20 ms away and the load drops to about 0.76 s, although the HTML and the API answer still come from the origin. Then try HTTP/1.1 (eleven connections), preconnect, and a repeat visit.

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:

  1. HTML: lookup, two set-up round trips, the request: first byte at 540 ms.
  2. CSS and JS are named in the first HTML packets and ride the same HTTP/2 connection, whose window has already grown.
  3. 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.
  4. 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.

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:

The same uncacheable HTML, direct and through an edgeDirect: the browser looks up the name, then pays a TCP and a TLS round trip of 150 ms each, a request round trip plus 50 ms of server work, and a second round trip of slow start: the HTML is complete after 690 ms. Through an edge 20 ms away: the lookup, two 20 ms set-up round trips, then the edge forwards the request over its open connection to the origin, 150 ms plus the server work, and the second slow-start round is 20 ms: complete after 320 ms.BrowserEdgeOrigin20 ms, new150 ms, openwarm windowDirectThrough the edgeTCP + TLSwait690 mswait320 msteal DNS · amber set-up · blue data
The same 30 KB of uncacheable HTML. Direct, every set-up and slow-start round trip crosses the full 150 ms. Through the edge, those happen over 20 ms, and the one long hop rides a connection that is already open with a warm window. Numbers from the page sim's model.

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.

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

Three tools, from arithmetic to measurement. All three use only the standard library; the files are in examples/how-the-web-works/.

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.

latency_budget.py
# runnable version: examples/how-the-web-works/python/latency_budget.py
MSS = 1460 # bytes of payload in one TCP packet
INITCWND = 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
python latency_budget.py (output trimmed)
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 ms

The 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.

by_hand.py
# 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)

Run from a network in India on 18 October 2026:

python by_hand.py (output)
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.154
2. 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 GMT
4. First byte 73.7 ms after sending the request: HTTP/1.1 200 OK
5. 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 ms
Second 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.

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.

probe.py
# runnable version: examples/how-the-web-works/python/probe.py
TIMEOUTS = {"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
python probe.py (output, three of six targets)
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_INVALID

The 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):

curl/timing.sh
# runnable version: examples/how-the-web-works/curl/timing.sh
curl --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"
bash curl/timing.sh (output)
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.243

Each 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.

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.

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.

  • 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.

“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.

  1. “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.
  2. “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.
  3. “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.
  4. “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.
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 304 still costs one, and a CDN helps even uncacheable pages by ending connections near the user.
  • DNS

    The lookup in front of every new host: resolvers, caches, TTLs, and why changes take time to arrive.

    Networking & APIs
  • TCP vs UDP

    Slow start, congestion control and head-of-line blocking from the transport side, and when UDP is the better base.

    Networking & APIs
  • TLS and Encryption in Transit

    What the TLS round trip actually does: certificates, key exchange, resumption and 0-RTT.

    Networking & APIs
  • REST API Design

    Once requests reach your server: what a client may do after a timeout, how two editors avoid erasing each other, and how to change a response without breaking old apps.

    Networking & APIs
  • HTTP/1.1 → HTTP/2 → HTTP/3coming soon

    Why HTTP/2 and HTTP/3 were built, and what changes for your servers.

    Networking & APIs
  • Reverse Proxy vs Load Balancer vs API Gateway

    The boxes behind the address, and why long-lived HTTP/2 connections break per-connection balancing.

    Traffic & edge
  • CDNs and edge cachingcoming soon

    The biggest single fix for distant users: edge caching, split connections and invalidation.

    Traffic & edge
  • Latency vs Throughput

    The two numbers behind every speed problem on this page, and how to reason about each.

    Foundations

Specs & official docs

Engineering blogs, papers & data

Explainers

Edit page

Last updated:

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.