What Actually Happens When You Click a Link on a Website
You click a button. A new page appears. It takes maybe 200ms if everything is healthy. You’ve done this ten thousand times without thinking about it.
But every single one of those clicks triggers a cascade of events that spans your device, multiple servers on different continents, and about a dozen different protocols. Zoom in on that 200ms and it’s surprisingly dense. Here’s what’s actually happening.
The Problem, Properly Framed
Most engineers understand “it goes through DNS and HTTP.” That’s correct but useless in the same way that “a CPU executes instructions” is a correct description of a video game.
The real value of understanding this flow is operational. When a page load is slow, you need to know which layer is slow — is it DNS lookup? TCP connection setup? TLS handshake? Server processing time? Render blocking? Each one has a different fix, and each one shows up differently in your browser’s Network tab. Knowing the sequence turns a mysterious performance regression into a scoped debugging problem.
Step 1: The Click and What the Browser Does First
When you click a <a href="/dashboard"> or fire a router.push('/dashboard') in React, the browser doesn’t immediately go to the network. There are checks it runs first.
Cache check. The browser looks up the URL in its HTTP cache. If it has a fresh copy — one where the Cache-Control: max-age hasn’t expired — it renders from cache and skips the network entirely. This is why hard refreshing (Ctrl+Shift+R) exists: it bypasses this cache and forces a new request.
Service Worker interception. If you’re running a Progressive Web App, a Service Worker may intercept the fetch before it even reaches the browser’s networking stack. The SW can respond from its own cache, go to the network, or do some combination. This is where offline-capable apps live.
If neither of these short-circuits the request, the browser starts building a real network request.
Step 2: DNS — Turning a Name Into an Address
The browser knows you want api.example.com, but the network only understands IP addresses. DNS is the phonebook lookup that maps one to the other.
The resolution happens in layers:
- OS cache — the system keeps a local DNS cache. If you visited the site recently, the OS returns the cached IP immediately.
- Recursive resolver — your ISP or a configured resolver like
8.8.8.8takes over. It queries the DNS hierarchy: root nameservers → TLD nameservers (.com) → authoritative nameservers forexample.com. - Authoritative response — the domain’s DNS server returns the IP, with a
TTL(time-to-live) that tells everyone how long to cache it.
In practice, a cold DNS lookup costs 20–120ms depending on geography and resolver quality. A cached one costs ~0ms. This is why services like Cloudflare and AWS Route 53 obsess over DNS response time — it’s latency you’re paying before a single byte of your application has been sent.
A subtle detail: browsers also do DNS prefetching. When you load a page, the browser parses all the <a> tags and kicks off background DNS lookups for the domains it finds. By the time you actually click, the IP is already resolved.
Step 3: TCP — Opening a Connection
With an IP address in hand, the browser opens a TCP connection. TCP is the reliable transport layer — it guarantees that bytes arrive in order and without corruption.
Opening a TCP connection requires a three-way handshake:
Client → Server: SYN (I want to connect)
Server → Client: SYN-ACK (Acknowledged, I'm ready)
Client → Server: ACK (Let's go)
This round-trip costs one RTT (round-trip time). If your server is 50ms away, you’re spending 50ms just to establish the connection before sending your first HTTP byte.
This cost is why TCP connection reuse (HTTP keep-alive) exists, and why HTTP/2 multiplexes multiple requests over a single TCP connection. Opening a new TCP connection per resource would be catastrophically slow on a page with 40 assets.
Step 4: TLS — Negotiating Encryption
If you’re on HTTPS (you are, in 2026), the TCP connection is followed by a TLS handshake. This is where the browser and server agree on:
- Which cipher suite to use (AES-256-GCM, ChaCha20-Poly1305, etc.)
- The server’s certificate (is this actually
example.com?) - The session keys for symmetric encryption
TLS 1.3, which is now standard, reduced the handshake from 2 RTTs (TLS 1.2) to 1 RTT. With 0-RTT resumption, subsequent connections can reuse session state and skip the handshake entirely — at the cost of some security tradeoffs around replay attacks, which is why 0-RTT is only enabled for safe, idempotent requests.
Certificate validation is non-trivial. The browser checks:
- Is the certificate signed by a trusted CA?
- Is the domain in the cert’s SAN (Subject Alternative Name) field?
- Is the certificate revoked? (OCSP or CRL)
- Has it expired?
A certificate validation failure is a hard stop — the browser throws an error and refuses to proceed. This is why cert expiry is an incident, not a warning.
Step 5: HTTP — The Actual Request
With a TCP connection open and TLS negotiated, the browser sends an HTTP request:
GET /dashboard HTTP/2
Host: app.example.com
Accept: text/html,application/xhtml+xml
Accept-Encoding: gzip, br
Cookie: session_id=abc123; csrf_token=xyz
User-Agent: Mozilla/5.0 ...
Key things happening here:
- HTTP/2 binary framing — unlike HTTP/1.1 which is plaintext, HTTP/2 frames are binary, enabling multiplexing, header compression (HPACK), and server push.
- Cookies are sent automatically — every
Cookieheader on the matching domain gets attached. This is both how auth sessions work and how CSRF attacks are possible. - Content negotiation —
Accept-Encoding: brtells the server the browser understands Brotli compression, which is ~20% better than gzip for text.
Step 6: Server Processing
The request hits your backend. What happens here depends entirely on your stack, but a typical path:
Load Balancer → App Server → Auth Middleware → Route Handler → DB Query → Response
From a network perspective, the key metric is Time To First Byte (TTFB) — the time between the request being sent and the first byte of the response arriving at the browser. TTFB captures server processing time plus network latency.
A TTFB above 600ms is a red flag. In most cases it means either a slow database query, no caching at the app layer, or cold-starting a serverless function.
The server responds with something like:
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Content-Encoding: br
Cache-Control: no-store
Set-Cookie: session_id=abc123; Secure; HttpOnly; SameSite=Strict
Step 7: Browser Rendering
The browser receives the HTML response and immediately starts parsing it — it doesn’t wait for the full document to download. This is called incremental parsing, and it’s why you see content appear progressively on slow connections.
The rendering pipeline:
- Parse HTML → DOM — the parser builds a Document Object Model. When it hits a
<script>tag withoutasyncordefer, it pauses and waits for the script to download and execute. This is why render-blocking scripts are a classic performance problem. - Parse CSS → CSSOM — stylesheets build the CSS Object Model. Render is blocked until CSSOM is complete.
- DOM + CSSOM → Render Tree — nodes with
display: noneare excluded. - Layout — the browser calculates positions and sizes of each element.
- Paint — pixels are drawn to layers.
- Composite — layers are combined and sent to the GPU for display.
The first meaningful render — when the user sees actual content rather than a blank screen — maps to the First Contentful Paint (FCP) metric. The point at which the main content is loaded and interactive is Largest Contentful Paint (LCP) and Time to Interactive (TTI).
The SPA Twist
If you’re navigating inside a React, Vue, or SvelteKit app, steps 1–6 are replaced after the initial load.
Clicking a <Link> in Next.js doesn’t trigger a full browser navigation. Instead:
- The router intercepts the click and calls
history.pushState()to update the URL without a page reload. - It fetches only the data for the new route — usually a JSON API call, not a full HTML document.
- React re-renders the component tree with the new data.
- The browser never tears down and rebuilds the DOM.
This is why SPAs feel fast for in-app navigation — you’re skipping the full TCP/TLS handshake and HTML parse on every page change. But you’re paying for it upfront with a larger initial bundle, which is why code splitting and lazy loading of routes exist.
// Next.js prefetches routes automatically when a Link is visible
<Link href="/dashboard" prefetch={true}>
Dashboard
</Link>
// Under the hood, Next.js calls router.prefetch('/dashboard')
// which fetches the JS chunk for that route in the background
The prefetch behaviour is what makes navigation in Next.js feel instantaneous — by the time you click, the JS for the route is already in the browser’s cache.
What Goes Wrong
The failure modes at each layer are distinct:
- DNS failure — NXDOMAIN (no such domain) or timeout. Usually a misconfigured record or a TTL issue during migration. Symptoms: the browser shows the error immediately, or after a long pause.
- TCP failure — connection refused or timeout. Server not listening on that port, firewall blocking traffic, or the server is down.
- TLS failure — certificate mismatch, expiry, or revoked cert. Hard browser error with no fallback.
- HTTP 4xx — client error. 401 (unauthenticated), 403 (unauthorized), 404 (not found). The server understood the request and rejected it.
- HTTP 5xx — server error. 500 (unhandled exception), 502 (bad gateway, upstream died), 503 (overloaded). The browser got a response, but not a useful one.
- Render blocking — the HTML loads but the page appears blank. Usually a synchronous
<script>in the<head>that’s slow to download, or a CSS file that’s blocking paint.
Takeaways
- DNS is not free. A 100ms DNS lookup compounds across every cold navigation. Prefetching and aggressive TTLs are the fix.
- TTFB tells you where to look. High TTFB = server-side problem. Low TTFB but slow FCP = render-blocking resources.
- TLS 1.3 is worth the upgrade. The handshake reduction from 2 RTTs to 1 RTT is free latency.
- SPAs shift the cost, they don’t eliminate it. Fast in-app navigation trades off against a heavier initial load and JS execution time.
- HTTP/2 multiplexing matters. On high-latency connections (mobile, cross-region), the difference between 1 TCP connection and 40 is enormous.
- Cache-Control is your best performance tool. A 200ms page load with no cache is a 0ms page load with the right cache headers.
Next time you’re debugging a slow navigation, open the browser’s Network tab, filter by the request type, and read the waterfall. DNS, TCP, TLS, TTFB, download, and render are all in there. The problem will be obvious once you know what you’re looking for.
If you found this useful or want to discuss it further, connect with me on GitHub or LinkedIn.