← Writing

What Actually Happens When You Open a Web Page

Published
Reading
19 min
Contents · 25 sections
  1. The whole journey on one page
  2. Step 1: Reading the URL, and HSTS
  3. Step 2: DNS, the internet’s phone book
  4. Caching and TTL
  5. Record types
  6. DNS over HTTPS
  7. Step 3: Opening a connection
  8. TCP: the three-way handshake
  9. TLS 1.3: keys and a certificate
  10. HTTP/2 and HTTP/3
  11. Step 4: The edge, where most of the work is done
  12. Anycast: one address, many places
  13. Caching at the edge
  14. This site’s setup
  15. Step 5: The HTTP request and response
  16. Step 6: The browser turns bytes into pixels
  17. Core Web Vitals
  18. Where each step can break
  19. Try it yourself
  20. 1. DNS: dig
  21. 2. Headers: curl -sI
  22. 3. The handshake: curl -sv
  23. 4. Timing: curl -w
  24. 5. The browser: DevTools Network tab
  25. Key ideas

Type phamphutai.com into the address bar and press Enter. A page appears. It feels like one action.

It isn’t. Before the first word shows up, your browser has asked a chain of servers where the site lives, opened a connection to a machine in another country, agreed on encryption keys with it, checked a certificate, sent a request, received a compressed reply from a cache, and turned that reply into pixels. A dozen separate systems, owned by different organizations, in a fraction of a second.

This post walks through every step, in order, for developers and curious builders. Every term is explained once, and the example is a real site: this one. At the end you’ll run the same commands I ran.


The whole journey on one page

STEP WHAT HAPPENS 1 URL + HSTS browser decides: https, this host 2 DNS lookup name to IP address (often cached) 3 TCP handshake SYN, SYN-ACK, ACK: one round trip 4 TLS 1.3 keys + certificate: one round trip 5 HTTP request GET / over HTTP/2 or HTTP/3 6 Edge nearest data centre: cache, Worker 7 Render HTML, CSS, JS, fonts become pixels
Seven steps between pressing Enter and seeing a page; steps 2 to 4 often run before a single byte of the page is sent.

Step 1: Reading the URL, and HSTS

First the browser works out what you typed. Is it a search query or an address? phamphutai.com has a dot and no spaces, so it’s treated as an address. A full URL (Uniform Resource Locator) has parts: a scheme (https), a host (phamphutai.com), an optional port, a path (/), and sometimes a query string after ?.

You didn’t type a scheme, so which one? Modern browsers try https first. But there’s an older safety net called HSTS (HTTP Strict Transport Security, RFC 6797). A site sends a header like this:

strict-transport-security: max-age=31536000; includeSubDomains

That tells the browser: for the next year (31,536,000 seconds), never talk to this host or its subdomains over plain HTTP. After the first visit, the browser upgrades every http:// link to https:// by itself, before any network traffic happens. The very first visit is still exposed, which is why Chrome maintains an HSTS preload list that’s built into the browser. Firefox, Safari and Edge use lists based on it. To join, a site needs max-age of at least a year, includeSubDomains and a preload directive. My site sends the first two but not preload, so it relies on the normal header plus a redirect from http to https.


Step 2: DNS, the internet’s phone book

Computers on the internet find each other by IP address, a number like 104.21.6.41 (IPv4) or 2606:4700:3033::ac43:9ad0 (IPv6). Humans use names. The Domain Name System (DNS) turns one into the other.

Four kinds of server are involved:

  • Stub resolver. A small piece of your operating system. It doesn’t know answers; it asks someone who does. It also keeps a short-lived cache.
  • Recursive resolver. Usually run by your internet provider, or a public one like Cloudflare’s 1.1.1.1 or Google’s 8.8.8.8. It does the legwork of finding the answer and caches it for everyone who asks next.
  • Root servers. The top of the tree. They don’t know where phamphutai.com is, but they know who runs .com. There are 13 root server identities (a to m) run by 12 independent operators, served from more than 2,000 physical instances around the world.
  • TLD and authoritative servers. The TLD (top-level domain) servers for .com know which servers are responsible for phamphutai.com. Those authoritative servers hold the actual answer. For this site, that’s Cloudflare’s nameservers.
Browser + OS stub resolver Recursive ISP or 1.1.1.1 ask answer Root (.) "ask the .com servers" .com TLD "ask ns.cloudflare.com" Authoritative "A 104.21.6.41, TTL 300" 1 2 3
The recursive resolver walks down the tree, root to TLD to authoritative, then caches the answer so the next person skips the walk.

Caching and TTL

Every answer comes with a TTL (time to live), in seconds, that says how long it may be cached. In the real lookup further down, the .com servers say Cloudflare is responsible for phamphutai.com with a TTL of 172,800 seconds (two days), while the site’s own address has a TTL of 300 seconds (five minutes). Delegations change rarely; addresses can change more often.

Most lookups never leave your machine or your resolver. The full walk only happens when nothing along the way has a fresh copy.

Record types

DNS stores more than addresses. The three you meet first:

Record Holds Example
A An IPv4 address phamphutai.com. 300 IN A 104.21.6.41
AAAA An IPv6 address 2606:4700:3033::ac43:9ad0
CNAME “This name is an alias for that name” blog.example.com points to example.netlify.app

A CNAME makes the resolver start again with the new name, which adds a lookup. Note that phamphutai.com answers with two A records and two AAAA records. The browser can try any of them, and modern clients often race IPv6 and IPv4 and use whichever connects first.

DNS over HTTPS

Classic DNS travels unencrypted, so anyone on the network path can read or tamper with your lookups. DNS over HTTPS (DoH, RFC 8484) sends the same queries inside ordinary encrypted HTTPS requests to a resolver. Browsers like Firefox and Chrome can use it. It hides your lookups from the café Wi-Fi, though the resolver you send them to still sees them.


Step 3: Opening a connection

The browser now has an IP address. Next it needs a reliable channel to that machine.

TCP: the three-way handshake

TCP (Transmission Control Protocol, RFC 9293) turns the internet’s unreliable packets into an ordered, reliable stream of bytes. Before any data flows, the two sides agree to talk:

  1. SYN. Client: “I’d like to connect, here’s my starting sequence number.”
  2. SYN-ACK. Server: “OK, here’s mine, and I got yours.”
  3. ACK. Client: “Got yours.” The client can send data right after this.

That costs one round trip (RTT): the time for a message to go there and back. From Đà Nẵng to a data centre in Singapore, that’s around 50 milliseconds in my measurements below. You pay it before anything useful happens.

TLS 1.3: keys and a certificate

Next comes TLS (Transport Layer Security), the “S” in HTTPS. It does two jobs: encrypt the traffic so nobody in between can read or change it, and prove the server really is phamphutai.com.

TLS 1.3 (RFC 8446) does this in one round trip:

  • The client sends a ClientHello: supported ciphers, a key share for a key exchange, and two important extensions. SNI (Server Name Indication) names the site it wants, because one IP address at a CDN serves thousands of sites. ALPN (Application-Layer Protocol Negotiation) lists the HTTP versions it speaks, like h2 and http/1.1.
  • The server answers with a ServerHello and its own key share. Both sides can now compute the same secret. RFC 8446 puts it plainly: “All handshake messages after the ServerHello are now encrypted.” So the server’s certificate, a signature proving it holds the matching private key, and a Finished message all travel encrypted.
  • The client checks the certificate: is it for this name, is it in date, and was it signed by a certificate authority (CA) the device already trusts? Then it sends its own Finished, plus the request.

TLS 1.2 needed two round trips for the same thing. TLS 1.3 also offers 0-RTT resumption, where a returning client sends data in its very first message, but the RFC warns that 0-RTT data can be replayed by an attacker, so it’s only safe for requests that don’t change anything.

The certificate on this site, from the curl output below: issued to CN=phamphutai.com by Google Trust Services, valid from 7 November 2026 to 5 February 2027. Short-lived certificates like this are renewed automatically by Cloudflare.

HTTP/2 and HTTP/3

Inside the encrypted channel, the browser speaks HTTP. Which version depends on what ALPN agreed:

Version Runs over Key idea
HTTP/1.1 TCP One request at a time per connection, so browsers open several connections
HTTP/2 (RFC 9113) TCP + TLS Many requests multiplexed as streams on one connection; compressed headers
HTTP/3 (RFC 9114) QUIC over UDP Same streams, but a lost packet only stalls its own stream

HTTP/2 fixed most of HTTP/1.1’s waste, but it inherited one problem from TCP. TCP delivers bytes strictly in order, so one lost packet holds up every stream on the connection until it’s resent. This is called head-of-line blocking.

QUIC (RFC 9000) is a new transport built on UDP. It moves reliability and ordering into each stream, so a lost packet only delays the stream it belonged to. And it merges the transport and TLS 1.3 handshakes into one, so a new connection is ready after a single round trip instead of two. HTTP/3 is HTTP running on top of QUIC.

How does a browser know a site speaks HTTP/3? The site tells it. This site’s response includes alt-svc: h3=":443"; ma=86400: “HTTP/3 is available on port 443; remember this for a day.” The first visit typically uses HTTP/2, and later connections can switch to HTTP/3.


Step 4: The edge, where most of the work is done

That IP address, 104.21.6.41, isn’t a server in my house or a single machine anywhere. It belongs to Cloudflare, a CDN (content delivery network): a company with data centres in more than 300 cities that sits between visitors and websites.

Anycast: one address, many places

Cloudflare uses anycast. The same IP address is announced from every data centre at once, using BGP (Border Gateway Protocol, the system networks use to tell each other which addresses they can reach). Internet routers send your packets along the shortest route they know, so you end up at a nearby data centre without anyone choosing it for you. The cf-ray header in a Cloudflare response ends with an airport code for the data centre that served it. My requests from Đà Nẵng came back stamped SIN: Singapore.

Caching at the edge

A CDN keeps copies of files close to visitors. If the Singapore data centre already has the file, it answers immediately: a cache hit, shown as cf-cache-status: HIT. If not, it’s a miss, and it fetches the file from the origin first.

This site’s setup

phamphutai.com is a static site built with Astro: at build time, every post becomes plain HTML, CSS and a little JavaScript in a dist folder. No application server, no database.

That folder is deployed as a Cloudflare Worker with static assets. A Worker is a small JavaScript function that runs in every Cloudflare data centre, right next to the cache. The configuration (wrangler.jsonc) does three things that matter here:

  • "assets": { "directory": "./dist" } uploads the built site to Cloudflare’s asset storage.
  • "run_worker_first": true makes the Worker run before every request. By default, Cloudflare serves a matching file without running the Worker at all.
  • Two custom domains, phamphutai.com and www.phamphutai.com, route traffic to it.

The Worker itself is about 30 lines. In order, it:

  1. Redirects http:// and www. requests to https://phamphutai.com with a 301 (permanent) redirect, so there’s one canonical address for search engines.
  2. Adds a trailing slash to paths like /writing, again with a 301.
  3. For the Markdown copies of posts (.md files), adds a Link: rel="canonical" header pointing at the HTML page.
  4. Otherwise hands the request to the static assets with env.ASSETS.fetch(request).

So for most requests, the “server” is a cache and a tiny function, a few hundred kilometres from the reader. That’s why a static site on a CDN is so fast and so cheap to run.


Step 5: The HTTP request and response

The browser sends something like this (HTTP/2 encodes it in binary, but the meaning is the same):

GET / HTTP/2
Host: phamphutai.com
Accept: text/html
Accept-Encoding: gzip, br

The first word is the method. GET means “give me this,” HEAD asks for headers only, POST sends data.

The response starts with a status code:

Code Meaning You’ll see it when
200 OK The page is returned
301 Moved permanently www. or http:// gets redirected
304 Not modified Your cached copy is still good
404 Not found A typo in the URL
5xx Server error Something behind the edge broke

Then come headers, the metadata. A few that matter for speed:

  • content-type says what the body is: text/html, text/css, image/png. The browser trusts this, not the file extension.
  • cache-control says who may cache the response and for how long. This site uses two very different rules. HTML pages get public, max-age=0, must-revalidate: the browser may keep a copy, but must check it’s still current before using it, so a new post shows up immediately. CSS and JavaScript files under /_astro/ get public, max-age=31536000, immutable: keep it for a year and never ask again. That’s safe because Astro puts a hash of the content in the file name, like Base.CL4LZlOo.css. Change the file, and the name changes.
  • etag is a fingerprint of the file. Next time, the browser sends it back in an If-None-Match header. If nothing changed, the server replies 304 with no body, and the browser uses its copy.
  • content-encoding says how the body was compressed. Text compresses very well. This site’s home page is 26,774 bytes raw and 5,173 bytes with Brotli (br), a compression format designed for the web. That’s about 80% smaller.

Step 6: The browser turns bytes into pixels

Now the browser has HTML. The sequence of steps from there to a visible page is called the critical rendering path:

  1. Parse HTML into the DOM. The DOM (Document Object Model) is a tree of every element on the page. The parser works as bytes arrive; it doesn’t wait for the whole file.
  2. Parse CSS into the CSSOM. The CSS Object Model holds every style rule. CSS is render-blocking: the browser won’t paint until it has the stylesheets in the <head>, otherwise you’d see a flash of unstyled page.
  3. Build the render tree. Combine the DOM and CSSOM, keeping only what’s visible. An element with display: none is dropped.
  4. Layout. Work out the exact size and position of every box for this screen width.
  5. Paint. Fill in the pixels: text, colours, borders, images.
  6. Composite. Combine painted layers in the right order and show them on screen.

JavaScript can interrupt all this. A plain <script> tag stops the HTML parser until the script downloads and runs, because the script might change the page. Adding defer or async, or using type="module", lets parsing continue. Fonts can hide text until they load, unless the CSS uses font-display: swap. Images without a declared width and height make the page jump when they arrive.

Core Web Vitals

Google measures how this feels to real users with three Core Web Vitals, judged at the 75th percentile of page loads:

Metric Measures Good Poor
LCP (Largest Contentful Paint) When the biggest image or text block appears ≤ 2.5 s > 4.0 s
INP (Interaction to Next Paint) How quickly the page responds to clicks and taps ≤ 200 ms > 500 ms
CLS (Cumulative Layout Shift) How much content jumps around ≤ 0.1 > 0.25

INP replaced First Input Delay as a Core Web Vital in March 2024. Everything between “good” and “poor” counts as “needs improvement.” Each step in this post feeds LCP: a slow DNS lookup, an extra round trip or an uncompressed file all push the largest element later.


Where each step can break

Every layer has failed in public. One real example each:

Layer Incident What happened
DNS + BGP Facebook, 4 Oct 2021 A maintenance command took down Meta’s backbone. Its DNS servers then withdrew their BGP routes, so the internet couldn’t even find facebook.com. Cloudflare saw it unreachable for roughly six hours, from about 15:40 to 21:28 UTC.
CDN / edge Cloudflare, 18 Nov 2025 A database permissions change doubled the size of a Bot Management configuration file. Cloudflare’s proxy had a hard limit below the new size and failed, returning 5xx errors from about 11:20 UTC, fully resolved at 17:06 UTC. Cloudflare says it was not an attack.
TLS Let’s Encrypt root expiry, 30 Sep 2021 The old DST Root CA X3 certificate expired. Older devices that didn’t trust Let’s Encrypt’s newer ISRG Root X1 started showing certificate errors on sites that were otherwise fine.

My takeaway: the steps you never think about are the ones that take everything down at once. Your code can be perfect and the page still won’t load.


Try it yourself

Everything above can be seen from a terminal. These commands are read-only and safe to run against any public site. The output below is real, trimmed, from one machine in Đà Nẵng on 14 November 2026. Your times and IP addresses will differ.

1. DNS: dig

$ dig phamphutai.com

;; ANSWER SECTION:
phamphutai.com.		300	IN	A	172.67.154.208
phamphutai.com.		300	IN	A	104.21.6.41

;; Query time: 545 msec

Two A records, each with a TTL of 300 seconds. That first query took 545 ms because the resolver had to do work; ask again and it comes back from cache. To watch the whole walk from the root down:

$ dig +trace phamphutai.com

.        169     IN  NS  a.root-servers.net.
...
com.     172800  IN  NS  a.gtld-servers.net.
...
phamphutai.com.  172800  IN  NS  daisy.ns.cloudflare.com.
phamphutai.com.  172800  IN  NS  cameron.ns.cloudflare.com.

phamphutai.com.  300     IN  A   172.67.154.208
phamphutai.com.  300     IN  A   104.21.6.41
;; Received 75 bytes from ...(cameron.ns.cloudflare.com) in 53 ms

Root, then .com, then Cloudflare’s authoritative servers, exactly as in the diagram.

2. Headers: curl -sI

$ curl -sI https://phamphutai.com/

HTTP/2 200
content-type: text/html
cf-cache-status: HIT
cache-control: public, max-age=0, must-revalidate
strict-transport-security: max-age=31536000; includeSubDomains
server: cloudflare
cf-ray: a5a7539c1bdece6e-SIN
alt-svc: h3=":443"; ma=86400

HTTP/2, a cache hit at the Singapore edge, the HSTS policy, and an offer of HTTP/3. Try curl -sI http://phamphutai.com/ to see the 301 redirect to HTTPS.

3. The handshake: curl -sv

$ curl -sv -o /dev/null https://phamphutai.com/ 2>&1 | head -40

* Connected to phamphutai.com (2606:4700:3033::ac43:9ad0) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256
* ALPN: server accepted h2
*  subject: CN=phamphutai.com
*  issuer: C=US; O=Google Trust Services; CN=WE1
*  SSL certificate verify ok.
> GET / HTTP/2
< HTTP/2 200

Every TLS 1.3 message from the section above is here, in order. Note that curl connected over IPv6 and ALPN settled on HTTP/2.

4. Timing: curl -w

$ curl -s -o /dev/null -w "dns:%{time_namelookup} connect:%{time_connect} \
  tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" \
  https://phamphutai.com/

dns:0.003148 connect:0.052460 tls:0.115769 ttfb:0.201334 total:0.207054

Each number is seconds since the start, so the steps are the gaps between them:

Step Time Why
DNS 3 ms Answer already cached on the machine
TCP ~49 ms One round trip to Singapore
TLS ~63 ms One more round trip, plus crypto
Request to first byte ~86 ms Request travels, edge serves from cache
Download ~6 ms 5 KB of Brotli-compressed HTML

Two more runs gave 0.28 seconds total, mostly a slower first byte. Notice that the network round trips cost more than the server work. That’s why CDNs move content closer instead of making servers faster.

5. The browser: DevTools Network tab

Open any page, press F12 (or Cmd+Option+I on a Mac), open the Network tab and reload. Each row is one request: the HTML, then the CSS, scripts, fonts and images it pulls in. Click a row and open Timing to see the same phases as the curl -w breakdown: DNS lookup, initial connection, SSL, waiting for the server, download. The Protocol column (right-click the header row to add it) shows h2 or h3. Tick Disable cache to see a first visit. The Performance tab shows the rendering steps and LCP.


Key ideas

  • Opening a page is a chain: URL, DNS, TCP, TLS, HTTP, edge, render. Each link can be slow, and each can break.
  • DNS is a cached tree. Root, TLD and authoritative servers answer once; TTLs decide how long everyone else reuses the answer.
  • Round trips are the real cost. TCP needs one, TLS 1.3 needs one, and QUIC folds both into one. Distance sets the price of each.
  • CDNs win by being close. Anycast routes you to a nearby data centre; caching means the origin often isn’t touched at all.
  • Headers are performance settings. cache-control, etag and content-encoding decide whether bytes are sent at all, and how many.
  • The browser has its own critical path. CSS blocks painting, plain scripts block parsing, and LCP, INP and CLS measure what users feel.
  • You can see all of it. dig, curl -sv, curl -w and the DevTools Network tab turn the invisible steps into numbers.

Sources: RFC 6797: HSTS · HSTS preload list · Root server system · RFC 8484: DNS over HTTPS · RFC 9293: TCP · RFC 8446: TLS 1.3 · RFC 9113: HTTP/2 · RFC 9000: QUIC · RFC 9114: HTTP/3 · Cloudflare: network · Cloudflare: what is anycast · Cloudflare: HTTP headers (cf-ray) · Cloudflare Workers: static assets routing · MDN: HTTP caching · web.dev: critical rendering path · web.dev: Web Vitals · web.dev: LCP · web.dev: INP · web.dev: CLS · Meta: details about the October 4 outage · Cloudflare: understanding how Facebook disappeared · Cloudflare: 18 November 2025 outage · Let’s Encrypt: DST Root CA X3 expiration

Related: Background Jobs Done Right · Run AI Models on Your Own Laptop · Shipping Is Half the Job