You’ve Typed a URL a Million Times — But Do You Know What Happens Next?
Most developers I’ve talked to can describe HTTP abstractly — “the browser sends a request, the server sends back HTML” — but freeze up when a real problem hits. A CORS error shows up and they paste it into Stack Overflow without understanding why two different origins are even a concept. A page loads slow and they blame “the server” without knowing where in the chain the delay actually lives. That gap between vague mental model and working knowledge is exactly where debugging becomes a guessing game instead of a systematic process.
This guide is specifically for people who build things — frontend devs, full-stack juniors, bootcamp grads who are shipping real features — but who feel a quiet dread when someone more senior asks “okay but where does the DNS resolution actually happen?” or “wait, is that a TCP or TLS issue?” You don’t need a networking degree. You need a clear, honest walkthrough of what actually fires off the moment you hit Enter on a URL, told in the right order, without skipping the parts that seem boring but turn out to be where all the bugs live.
Here’s what we’re covering, in the exact sequence it happens in the real world:
Deploying Flask on AWS Lambda Without Losing Your Mind: A Setup-to-Production Guide
- Browser cache check and DNS lookup (including what your OS, router, and ISP each cache separately)
- TCP handshake and why that round-trip adds latency before a single byte of your HTML ships
- TLS negotiation — the thing that makes HTTPS work and also adds another round-trip
- The HTTP request itself, headers and all
- Server-side processing, CDN edge nodes, and where your origin server actually fits
- The response: status codes, response headers (including the ones that cause CORS explosions), and body parsing
- Browser rendering — HTML parsing, the DOM, CSSOM, render tree, paint — and why JavaScript can block all of it
No hand-waving. I’m going to show you actual dig and curl commands you can run right now to watch these steps happen live. The thing that caught me off guard the first time I actually traced a full request was how many separate roundtrips happen before your fetch() call even fires. Once you see it laid out, “why is my site slow on first load but fast after?” stops being a mystery.
One quick sidebar: if you’re also trying to move faster while you’re learning this stuff, the Best AI Coding Tools in 2026 roundup has some genuinely useful options for speeding up your dev workflow. But I’d nail this first — AI tools write better code for you when you can catch the networking assumptions they get wrong.
Step 1: You Hit Enter — Your Browser Does Not Know Where to Go Yet
Your browser has a URL like https://example.com/products and absolutely zero idea where that server physically lives. The internet routes traffic by IP address — something like 93.184.216.34 — not by human-readable domain names. So before a single byte of your request goes anywhere, your browser has to translate that domain into an IP. That translation process is DNS, and it doesn’t start with a remote server. It starts right on your own machine.
The first place your browser checks is its own internal DNS cache. Chrome maintains one completely separately from your OS, which surprises a lot of beginners. You can see it live by pasting this into your address bar:
chrome://net-internals/#dns
You’ll see every domain Chrome has recently resolved, the TTL it cached, and whether the entry is still valid. Firefox has a similar internal cache. The thing that caught me off guard early on was realizing that clearing your OS DNS cache does nothing if Chrome still has a stale entry sitting in its own bucket. You have to flush both — hit the “Clear host cache” button on that Chrome page, and flush the OS.
Speaking of the OS cache — if Chrome doesn’t have the answer, it asks the operating system. To dump what your OS currently has cached:
- Windows:
ipconfig /displaydns— dumps every cached record to the terminal. To flush:ipconfig /flushdns - macOS:
sudo dscacheutil -cachedump -entries Hostto inspect, thensudo dscacheutil -flushcache; sudo killall -HUP mDNSResponderto flush. Thekillallpart is not optional — without it, the cache refills from mDNSResponder’s own memory instantly. - Linux: Depends entirely on what’s running. If you have
systemd-resolved, useresolvectl statisticsto inspect andsudo systemd-resolve --flush-cachesto clear.
Here’s where this bites real projects: cached bad DNS entries are the number one source of “works on my machine” debugging nightmares I’ve seen with junior devs. The scenario plays out like this — you update an A record to point a domain at a new server IP, deploy your fix, test it yourself and it works great. Your colleague tests it three hours later and hits the old server. Neither of you are wrong. You had a fresh lookup. They have a cached entry that won’t expire until the TTL runs out, which could be anywhere from five minutes to 24 hours depending on what the previous record was set to. The DNS change is real and correct; the cache is just lying to one of you. I’ve lost more than one afternoon to this before I learned to check TTLs and cache states before assuming a DNS change had fully propagated.
One more thing beginners miss: the OS also checks a completely separate file before it even touches the DNS cache. On every major OS, there’s a hosts file — C:\Windows\System32\drivers\etc\hosts on Windows, /etc/hosts on macOS and Linux. Entries in that file override DNS entirely. If someone added 127.0.0.1 example.com to their hosts file during local development and forgot to remove it, no amount of DNS flushing will fix their inability to reach the real site. Check the hosts file first when someone insists DNS isn’t working on their machine specifically.
Getting Selenium to Actually Work in CI/CD for JavaScript Apps (Without Losing Your Mind)
Step 2: DNS — The Phone Book Nobody Talks About Clearly
Your Browser Has No Idea Where GitHub Lives — Until It Asks
Type github.com into your browser and hit enter. Your machine doesn’t magically know that’s 140.82.114.4. It has to ask. The thing doing the asking is called a recursive resolver — and most people are using whichever one their ISP assigned without ever thinking about it. That’s a mistake worth fixing. I switched to Cloudflare’s 1.1.1.1 a few years ago because my ISP’s resolver was logging queries and selling that data. Cloudflare’s privacy policy is more explicit about not doing that. Google’s 8.8.8.8 is the other popular option — fast, reliable, but you’re trading query data to Google. On macOS you change it in System Preferences → Network → Advanced → DNS. On Linux it’s /etc/resolv.conf or your NetworkManager config. This one change affects every DNS lookup your machine makes.
The Chain: From Root to Authoritative
Here’s exactly what happens when you look up github.com for the first time with a cold cache:
- Your machine asks its recursive resolver: “What’s the IP for github.com?”
- The resolver doesn’t know, so it asks one of the 13 root nameserver clusters (operated by ICANN, Verisign, NASA, and others — yes, NASA). The root doesn’t know the final answer either, but it knows who handles
.com. - The resolver asks the
.comTLD nameserver (run by Verisign). It doesn’t know the IP forgithub.comspecifically, but it knows which nameservers are authoritative for it — in GitHub’s case, something likens1.p16.dynect.net. - The resolver asks that authoritative nameserver. This one actually has the record. It returns the IP:
140.82.114.4. - The resolver hands that back to your browser and caches it.
Every subsequent request skips most of this chain — the answer is cached at the resolver level. The thing that caught me off guard early on: your browser also has its own DNS cache, separate from the OS cache, separate from the resolver cache. Chrome has a hidden page at chrome://net-internals/#dns where you can flush it manually. Knowing that three-layer cache exists saves debugging time.
Run This Right Now
You don’t have to take my word for any of this. Install dig (it ships with macOS and most Linux distros; on Windows, use WSL or grab it via BIND tools) and run:
dig github.com +trace
You’ll see the actual handoffs in real time — root servers, the .com TLD servers, then the authoritative nameservers for GitHub. Each line shows which server responded and how long it took in milliseconds. Run it a second time and the first few hops disappear because they’re cached. That’s not magic — that’s TTL working exactly as designed.
TTL: Just a Cache Timer, Nothing More
TTL (Time To Live) is measured in seconds. A TTL of 3600 means resolvers can cache that answer for one hour before they have to ask again. This is why “DNS propagation takes 24–48 hours” is both true and slightly misleading. What’s actually happening: every resolver on the internet that cached your old record has to wait for its TTL to expire before it fetches the new one. If your TTL was set to 86400 (24 hours) before you made a change, some users could see the old record for a full day.
The practical trick: if you know you’re about to change a DNS record — migrating a server, switching hosting providers — lower the TTL to 300 (5 minutes) a day or two beforehand. Wait for the old TTL to expire so everyone’s cache is refreshed with the short TTL. Make your change. Now propagation is fast. Bump the TTL back up to something sane (3600 or higher) afterward, because low TTLs mean more queries and more load on your nameservers.
The Records You’ll Actually Touch
Ignore the RFC definitions. Here’s what these records do in the real world:
- A record: Maps a hostname to an IPv4 address.
github.com → 140.82.114.4. This is the one you set when you point a domain at a server. AAAA is the same thing for IPv6. - CNAME: An alias. Instead of pointing to an IP, it points to another hostname.
www.github.com → github.com. The resolver follows the chain until it hits an A record. The gotcha: you can’t put a CNAME on a root domain (github.comitself) — only subdomains. This breaks a lot of first-timers trying to use Vercel or Netlify on a bare domain. Some DNS providers fake this with something called CNAME flattening or ALIAS records. - MX record: Tells the internet where to deliver email for your domain. If you set up Google Workspace for
yourdomain.com, Google gives you MX records pointing to their mail servers (aspmx.l.google.comand friends). Priority numbers on MX records matter — lower number means higher priority. If the primary mail server is down, the next lowest number is tried. Get these wrong and your email silently fails to deliver.
One more that trips people up: TXT records. They’re just arbitrary text attached to a domain, but they’re used for SPF (proving you’re allowed to send email as that domain), DKIM (email signing), and domain verification (Stripe, Google Search Console, and dozens of others ask you to add a TXT record to prove you own the domain). You’ll add a lot of TXT records over the lifetime of any real project.
Step 3: TCP Handshake — Before Any Web Data Moves, a Connection Has to Open
Three Messages, One Goal: Prove You Both Exist
The TCP handshake is blunt and kind of beautiful in its paranoia. Before your browser sends a single byte of actual web content, it needs to verify that both sides can send and receive. Not just that the server is alive — that the full two-way channel works. So it runs this tiny ceremony: your machine sends a SYN (“I want to talk”), the server fires back a SYN-ACK (“I heard you, and I want to talk too”), and your machine closes the loop with an ACK (“Great, let’s go”). Only after that third message lands does either side trust the connection enough to move real data. Three messages, two round trips worth of waiting, zero actual content transferred. Every. Single. New. Connection.
That cost shows up directly in your page load times. A user in São Paulo hitting a server in Frankfurt might see 180–200ms of round-trip latency on a good day. The TCP handshake alone burns one full round trip before the browser can even send the HTTP request. Then you wait another round trip for the response to start arriving. You’re already 400ms in before a single byte of HTML has rendered. The thing that caught me off guard early on was realizing how much of a “slow site” complaint had nothing to do with server speed — the server was fast, the TCP overhead was just brutal for distant users.
You can watch this happen in real time. Open Chrome, hit F12, go to the Network tab, reload the page, and click on the very first HTML request. In the right panel, choose the Timing tab. You’ll see a breakdown that includes Initial connection — that’s your TCP handshake cost staring back at you. On a CDN-served site it might be 5ms. On a single origin server in a different continent it can be 200ms or more. Once you see that number in the wild for the first time, you start thinking about CDN placement differently.
Why Video Calls Use UDP and Your Downloads Use TCP
TCP’s reliability guarantee — the mechanism that ensures every packet arrives in order, and retransmits anything that gets dropped — is exactly what you want for a file download or a web page. You’d rather wait a few extra milliseconds than get a corrupted ZIP or garbled HTML. But for a live video call, a retransmitted packet from 300ms ago is useless. By the time it arrives, the conversation has moved on. UDP skips the handshake entirely, skips the retransmit logic, and just fires packets at the destination and moves on. If some drop, the video glitches for a frame. That’s an acceptable trade-off for real-time audio and video. It is absolutely not acceptable for transferring your tax return PDF.
HTTP/2 and HTTP/3: Paying the Handshake Tax Less Often
The engineers who designed HTTP/2 understood that the handshake wasn’t going away — so they attacked a different problem: multiplexing multiple requests over a single TCP connection instead of opening a new one for each resource. One handshake, many requests. That alone was a meaningful improvement for sites loading dozens of assets. HTTP/3 goes further: it’s built on QUIC, which runs over UDP and bakes its own reliability and encryption into the protocol, cutting the connection setup time dramatically — sometimes eliminating a round trip entirely compared to TLS over TCP. We’ll dig into both properly in later steps, but keep the handshake cost in your head when we get there, because that’s exactly the problem they were designed to solve.
Step 4: TLS — The Padlock in Your Address Bar, Demystified
What TLS Actually Does (And Why You Should Care)
The padlock icon feels like decoration. It’s not. TLS — Transport Layer Security — is the protocol that encrypts everything flowing between your browser and the server. Without it, every router, ISP, coffee shop hotspot, and anyone running tcpdump on the same network can read your login credentials, session cookies, and credit card numbers in plain text. That’s not a hypothetical. It’s trivially easy to do with tools anyone can download in five minutes. TLS turns that readable stream into scrambled ciphertext that’s useless to anyone who intercepts it.
The part that tripped me up when I first learned this: TLS doesn’t just encrypt — it also authenticates. You need proof that the server you’re talking to is actually who it claims to be, not some machine in the middle pretending to be your bank. That’s what certificates are for. Encryption without authentication is like whispering a secret into a stranger’s ear because you can’t see who’s listening.
The TLS Handshake, Without the Jargon
Here’s what actually happens before a single byte of your page loads. Your browser says: “Here are the encryption algorithms I support, in preference order.” The server picks one from that list and replies with its certificate — a digital document that says “I am example.com, and here’s my public key.” Both sides then use asymmetric cryptography to agree on a temporary symmetric key called the session key, without ever sending it directly over the wire. From that point on, all traffic is encrypted using that session key because symmetric encryption is much faster than asymmetric. The whole handshake takes milliseconds. You can actually watch it happen:
openssl s_client -connect example.com:443 -tls1_3
Run that and you’ll see the certificate chain, the cipher suite negotiated, and the TLS version. I use this constantly when debugging HTTPS issues on new deployments. It’s faster than opening DevTools and far more informative.
Certificate Authorities: The Trust Chain Explained
Your browser ships with a pre-installed list of trusted Certificate Authorities — organizations like DigiCert, Comodo, and Sectigo that are trusted to vouch for certificates. When a site presents its cert, your browser checks: did a trusted CA sign this? Is it expired? Does the domain match? If any of those checks fail, you get the red “Your connection is not private” screen. That screen isn’t just a warning — it means the authentication part of TLS has broken down, which means you genuinely have no idea who you’re talking to.
The thing that caught me off guard the first time I deployed a server with a self-signed cert: I expected browsers to just warn and move on. Instead, Chrome buries the “proceed anyway” option, and for most users it’s a dead end. A self-signed cert encrypts traffic fine, but it fails the authentication check because no trusted CA vouches for it. This is why you can’t just generate your own cert for a public-facing site and call it a day.
You can inspect any site’s cert yourself — no tools needed. Click the padlock icon in Chrome or Firefox, then select “Certificate” or “Connection is secure → Certificate is valid.” You’ll see the issuing CA, the validity window, and the Subject Alternative Names (the domains this cert covers). Do this on a site running Let’s Encrypt and you’ll notice the cert expires in 90 days — that’s intentional, forcing automation so nobody runs expired certs for years on forgotten servers.
Let’s Encrypt Changed Everything
Before Let’s Encrypt launched in 2016, a basic Domain Validation SSL certificate cost somewhere between $10 and $100 per year depending on the vendor. That doesn’t sound like much, but for a hobbyist, a nonprofit, or a developer in a country where that represents real money, it was a genuine barrier. Small sites ran on HTTP. People accepted that. Let’s Encrypt made certs free, automated, and — crucially — respected by all major browsers from day one. The ACME protocol they built lets you auto-renew without touching anything:
certbot renew --quiet
Stick that in a cron job and your cert renews automatically every 60 days. The old world of “log into a dashboard, download a zip file, manually install the cert, set a calendar reminder for a year from now” is completely gone. If you’re setting up a new server today and not using Let’s Encrypt or a host that handles certs automatically, you’re doing extra work for no reason. The only case where I’d still pay for a cert is if you need Extended Validation (EV) — where the CA verifies your organization legally, not just domain ownership — or if you need a wildcard cert on a provider that hasn’t implemented ACME properly. Even then, Let’s Encrypt supports wildcard certs via DNS challenges, so that gap is mostly closed.
Step 5: The HTTP Request — What Your Browser Actually Sends
Run This Command First, Then Read the Rest
Open your terminal and run this before anything else:
curl -v https://example.com 2>&1 | head -50
You’re going to see a wall of output and it’s going to look intimidating. It isn’t. Every single line is telling you something specific. The thing that caught me off guard when I first read raw HTTP output was realizing how boring it actually is — it’s just text. Key-value pairs. A few agreed-upon conventions. No magic. Let’s walk through what you actually see.
The output splits into two phases: the connection handshake (lines starting with *) and the actual HTTP conversation (lines starting with > for what your browser sends, < for what the server sends back). Focus on the > lines first. Here’s a trimmed version of what a real request looks like:
> GET / HTTP/2
> Host: example.com
> User-Agent: curl/8.1.2
> Accept: */*
That’s the entire HTTP request. Four lines. The first line is the request line: method, path, HTTP version. Everything after that is a header. A header is nothing more than a name, a colon, a space, and a value. Host tells the server which domain you want (critical when one server hosts hundreds of domains). User-Agent tells the server what client is making the request. Accept tells the server what response formats you can handle. When your browser adds Cookie headers, it’s just tacking on another line in this same format. There is no special encoding, no binary format, no mystery. Both sides just agreed to read these keys and act on them.
GET vs POST: Where the Data Actually Goes
Here’s the real difference that nobody explains properly: GET puts data in the URL, POST puts data in the request body. That’s it. When you search Google for “curl tutorial”, your browser makes a GET request to /search?q=curl+tutorial. The search term is right there in the URL, visible in your address bar, saved in your browser history, logged in every proxy and server between you and Google. POST requests have a body — a separate chunk of text that comes after the headers, separated by a blank line. When you submit a login form, your username and password go in that body. They’re not in the URL. They don’t show up in server access logs by default. That’s why forms with passwords use POST, not GET — it’s not about semantics, it’s about where the bytes physically travel.
The practical consequence: bookmark a GET URL and it works. Bookmark a POST result and you get a blank page or an error because there’s no body to re-send. That’s why sites ask “do you want to re-submit the form?” when you refresh after a POST — your browser is warning you it’s about to replay that body.
Status Codes You’ll Actually Debug
Ignore the full list of 60+ codes. Here’s the ones you’ll actually hit and what they mean in practice:
- 200 OK — Request worked. Server found what you asked for and sent it back. Most boring, most common.
- 301 Moved Permanently — The URL moved forever. Browsers and search engines cache this. If you set up a 301 redirect incorrectly, you’ll fight cached redirects for weeks. I’ve been there.
- 302 Found — Temporary redirect. Browser follows it but doesn’t cache it. Used after a successful form POST to redirect you to a confirmation page — this is called the PRG pattern (Post/Redirect/Get) and it’s why pressing back on an order confirmation page doesn’t re-charge you.
- 304 Not Modified — Your browser asked “has this file changed since I last fetched it?” and the server said no. Browser uses its cached copy. Zero bytes transferred for the actual content. This is how CDNs and browser caches cut load dramatically for returning visitors.
- 401 Unauthorized — The server doesn’t know who you are. You’re not logged in, or your token expired. The name is misleading — it should be called “Unauthenticated”.
- 403 Forbidden — The server knows exactly who you are and has decided you can’t have this. Your user account exists but lacks permission. Different problem from 401, different fix.
- 404 Not Found — Nothing exists at this path. Either the URL is wrong, the resource was deleted, or you mistyped a route in your app.
- 500 Internal Server Error — Your server crashed. Look at the server logs immediately. The browser gets nothing useful here — the error is entirely server-side.
- 503 Service Unavailable — Server is up but refusing connections, usually because it’s overloaded or mid-deployment. If you’ve ever pushed a bad deploy and watched your monitoring light up, this is what you saw.
The pattern across all codes: 2xx means success, 3xx means “go somewhere else”, 4xx means you (the client) did something wrong, 5xx means the server broke. Burn that into memory and you’ll parse error messages 10x faster.
Step 6: The Server Receives Your Request — What Happens on the Other End
Most people picture “the server” as one magical box that receives your URL and sends back a webpage. The reality is there’s usually a small chain of software involved before your HTML arrives, and understanding that chain explains a lot of weird bugs you’ll hit in production.
What a Web Server Actually Is
A web server is just a process running on a machine, listening on a port, waiting for TCP connections. That’s it. Nginx, Apache, Caddy — these are all software that does exactly this. When you install Nginx on a Linux box and start it, you’ll see it bind to port 80 (HTTP) and 443 (HTTPS). You can verify this yourself:
sudo ss -tlnp | grep nginx
# LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1234,master_pid=1233))
# LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=1234,master_pid=1233))
Port 80 and 443 aren’t magic. They’re just agreed-upon conventions. You could run a web server on port 3000 (Node devs do this constantly), but then users would have to type yoursite.com:3000 in the URL. Nginx sitting on 443 and proxying to your Node app on port 3000 is how you avoid that.
Static vs Dynamic: The Fork in the Road
The first decision the server makes is whether it can just hand you a file directly or whether it needs to run code. Static responses mean Nginx or Apache looks at your request path, finds a matching file on disk, and streams it back. Dead simple, extremely fast, no database involved. S3 does this too — you’re really just asking it to return a file from a bucket. Dynamic responses mean your request gets proxied to application code — a Node/Express process, a Rails app, a Django view — which runs logic, probably touches a database, builds a response string, and returns it.
Here’s a minimal Nginx config that does both — serves static assets directly and proxies everything else to a Node app:
server {
listen 443 ssl;
server_name yourapp.com;
# Static files: serve directly from disk
location /static/ {
root /var/www/yourapp;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Everything else: proxy to Node
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
The gotcha I hit early on: if you forget proxy_set_header X-Real-IP $remote_addr, your app sees every request as coming from 127.0.0.1, which breaks IP-based rate limiting and logging. Your app is behind Nginx, so without that header it never sees the real client IP.
The Load Balancer Layer (And Why It Exists)
On any production setup with more than one server, there’s usually a load balancer in front of everything — AWS ALB, HAProxy, or even Nginx itself configured in upstream mode. Its job is to distribute incoming connections across multiple instances of your app so no single machine gets crushed. The thing that caught me off guard the first time I set one up: the load balancer terminates TLS. Your app servers behind it are often receiving plain HTTP internally, not HTTPS. This confused me when I saw request.is_secure() returning False in Django even though users were hitting an HTTPS URL. The fix is checking the X-Forwarded-Proto header, which the load balancer sets to https — and in Django you set SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') in settings.
When the Database Gets Involved
Dynamic responses almost always mean a database query. Your Node or Rails handler receives the request, extracts some parameters (user ID from session, product slug from URL), fires a SQL query or NoSQL lookup, waits for the result, templates it into HTML or serializes it to JSON, and returns it. That wait is where latency lives. A simple SELECT * FROM products WHERE slug = 'blue-widget' might take 2ms on a warm database with a proper index. The same query on a cold database with a full table scan on a million-row table can take 800ms — and your user feels all of it.
Pre-built responses sidestep this entirely. Static site generators like Next.js (in static export mode), Hugo, or Jekyll build all your HTML at deploy time. The “database query” happens once during the build, not on every request. This is why static sites on a CDN can serve a page in under 50ms globally — there’s nothing to compute. The trade-off is staleness: if a product goes out of stock, you either need to rebuild the whole site or accept that the static page doesn’t reflect it. For content that doesn’t change minute-to-minute (docs, marketing pages, blog posts), static is almost always the right call.
Response Headers Are Instructions, Not Metadata
A lot of beginners think of response headers as informational. They’re actually commands to the browser. Content-Type: application/json tells the browser to parse the body as JSON, not render it as HTML. Get that wrong and Chrome will try to display your API response as a webpage. Cache-Control: max-age=31536000, immutable tells the browser (and any CDN in between) to cache this resource for a year and never revalidate it — which is what you want for versioned static assets like main.a3f8c2.js. Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict creates a cookie that JavaScript can’t read, only goes over HTTPS, and won’t be sent on cross-site requests — three security properties baked into one header.
I’d argue Cache-Control is the most operationally important header to get right. Set it wrong on your HTML responses and you’ll have users stuck on old versions of your app for hours after a deploy. A safe default for HTML: Cache-Control: no-cache (which, confusingly, means “revalidate before using the cached version” — not “never cache”). For your JS and CSS bundles with hashed filenames: Cache-Control: public, max-age=31536000, immutable. Different assets, completely different caching strategies.
Step 7: CDNs — Why the Server Answering You Might Be 50 Miles Away, Not 5,000
Physics is the constraint nobody tells you about when you start learning web development. Light travels through fiber optic cable at roughly 200,000 km/s — about two-thirds the speed of light in a vacuum. A round-trip request from New York to Tokyo covers approximately 22,000 km. Do the math: you’re looking at a minimum of ~110ms just from the physics, before the server does anything. Add DNS resolution, TLS handshake, actual processing time, and response transmission, and that 110ms floor easily becomes 300-400ms. A CDN doesn’t cheat physics — it just moves the content close enough to you that the physics stops being the problem.
Here’s the core idea: a CDN operator (Cloudflare, Fastly, AWS CloudFront) runs hundreds of data centers called edge nodes or Points of Presence (PoPs) around the world. The first time someone in Frankfurt requests your homepage, the request travels to your origin server (wherever that lives), gets the response, and the CDN caches that response at the Frankfurt edge node. Every subsequent request from Europe hits Frankfurt — maybe 15ms away — instead of your server in Virginia. The origin only gets called again when the cache expires or you explicitly bust it. That’s the whole model. Simple concept, genuinely tricky in practice.
You can see exactly what the CDN is doing by looking at response headers. Here’s a quick curl command that shows you the cache status:
curl -I https://yourdomain.com/static/main.js
Look for headers like these in the response:
CF-Cache-Status: HIT
Age: 3842
Cache-Control: public, max-age=86400
CF-Cache-Status: HIT means Cloudflare served this from its edge cache — your origin server never saw the request. Age: 3842 tells you the cached copy is 3,842 seconds old. If you see CF-Cache-Status: MISS, the edge didn’t have it and had to fetch from origin. The first request to any given edge node is always a MISS — that’s normal. After that, it should be HITs until the TTL expires. If you’re seeing consistent MISSes on static files, you either have a misconfigured Cache-Control header on origin or the CDN is respecting a Set-Cookie header and skipping the cache entirely (this trips up a lot of people — CDNs often refuse to cache responses that set cookies).
Cloudflare, Fastly, and CloudFront are meaningfully different products. Cloudflare’s free tier is genuinely useful — unlimited bandwidth, automatic edge caching for static assets, and their network is massive. The thing that caught me off guard was that Cloudflare’s free tier proxies everything through their network, which means your origin’s IP is hidden by default. That’s good for security but means your server logs show Cloudflare IPs instead of real visitor IPs unless you configure CF-Connecting-IP header handling. Fastly is built for developers who want fine-grained control — their VCL (Varnish Configuration Language) lets you write actual cache logic, but the learning curve is steep and pricing is usage-based, which gets expensive fast if you’re serving large files. CloudFront integrates tightly with the AWS ecosystem; if your origin is already on S3 or EC2, it’s the lowest-friction option. Pricing is $0.0085 per GB for the first 10TB/month out of US/EU regions.
Cache invalidation is where CDNs will actually ruin your day. You deploy a new version of your app, the HTML references the same filename app.js, and edge nodes across the world are sitting on the old cached version. Users see broken layouts because the new HTML references a component that doesn’t exist in the cached JS. The standard fix is cache-busting: add a content hash to the filename so every deploy generates a different URL. Webpack and Vite do this automatically. But if you’re caching HTML itself (which you sometimes should for performance), you need a purge strategy. Cloudflare’s API lets you purge by URL:
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"files":["https://yourdomain.com/index.html"]}'
Wire this into your deployment pipeline so it runs automatically after every deploy. I’ve been burned by forgetting this step on a Friday afternoon push — users were seeing the old homepage layout for hours because I’d cached HTML for 24 hours without a purge step. The mental model shift is this: a CDN makes your static assets blazing fast almost for free, but it means your infrastructure now has distributed state that needs to be managed. That’s not a reason to avoid CDNs — it’s just the trade-off you’re accepting when you put a cache layer in front of your origin.
Step 8: The Browser Receives the Response — Now It Has to Build a Page
The HTTP response lands in the browser as a stream of bytes. The browser doesn’t wait for the whole thing to arrive before doing anything — it starts parsing HTML immediately, character by character, building the Document Object Model (DOM) as it goes. Think of the DOM as a live tree structure in memory: every tag becomes a node, nested tags become child nodes. The thing that caught me off guard when I first learned this is that the DOM isn’t the HTML file — it’s a parsed, structured representation that JavaScript can read and manipulate. The browser is building that tree in real time, top to bottom, while bytes are still arriving over the network.
Here’s where the classic advice about putting <script> tags at the bottom of your HTML comes from, and why it mattered for a decade. When the HTML parser hits a <script> tag without async or defer, it stops everything. No more DOM construction. It waits for the script to download, then execute, then resumes. That’s why pages used to visibly freeze — especially on slow connections — if a third-party analytics script was buried in the <head>. Moving scripts to the bottom of <body> was the fix before defer became reliable. Today I default to <script defer src="..."> in the head for most things. defer downloads the script in parallel but waits to execute until after DOM parsing finishes, in order. async executes as soon as it downloads, order be damned — fine for analytics, risky for anything that depends on DOM structure.
<!-- parser-blocking — avoid this in <head> --> <script src="/app.js"></script> <!-- downloads in parallel, executes after DOM is ready --> <script defer src="/app.js"></script> <!-- downloads and executes as soon as possible, no order guarantee --> <script async src="/analytics.js"></script>
CSS gets its own parallel process. While the HTML parser is building the DOM, the browser is also parsing every stylesheet it encounters into a CSS Object Model (CSSOM) — same tree idea, but for styles. The critical thing here: the browser will not render anything until it has both the DOM and the CSSOM. CSS is render-blocking by default. A heavy stylesheet hosted on a slow CDN will hold up your first paint just as badly as a blocking script. Once both trees exist, the browser merges them into a render tree — only the nodes that are actually visible (so no display: none elements, no <head> content) with their computed styles attached.
That render tree then goes through the critical rendering path, which is four distinct steps, each with its own cost:
- Layout (Reflow): The browser calculates the exact position and size of every element on screen. This is expensive. Changing a single element’s width can trigger layout recalculation for its parent and all its siblings.
- Paint: The browser fills in pixels — colors, borders, shadows, text. Also expensive, especially for effects like
box-shadowand gradients. - Composite: The browser splits the page into layers (especially anything using
transformoropacity) and hands them to the GPU to be assembled. This step is cheap and happens off the main thread, which is why animatingtransformandopacityis always faster than animatingwidthortop.
The fastest way to actually see all of this in action is the Waterfall chart in DevTools (Chrome, Firefox, Edge — all have it under the Network tab). Each row is a resource request, and the horizontal bar shows timing broken into color-coded segments. The colors vary slightly by browser, but the pattern is consistent: a thin grey/white section at the start is the queue + stall time (the request waiting for a connection slot), then DNS lookup, then TCP handshake, then TLS negotiation, then the actual request/response. I use this to diagnose problems all the time — a long grey block at the start usually means the browser hit its per-domain connection limit and the request had to wait. A long green/teal block is TTFB (Time to First Byte), which points at server-side slowness, not network. When I see a cascade where dozens of small requests all start after one large one finishes, that’s usually a render-blocking resource holding up discovery of everything below it. In Chrome DevTools, switch to the Coverage tab alongside the waterfall to see exactly how much of your CSS and JS is unused on initial load — seeing 80% of a 400KB CSS file marked as unused is a pretty fast way to decide what to fix first.
Step 9: HTTP/2 and HTTP/3 — Why Modern Browsers Are Faster Than You Might Expect
The 6-Connection Bottleneck That Haunted the Web for a Decade
HTTP/1.1 had a brutal constraint that most beginners never hear about: browsers were limited to roughly 6 parallel TCP connections per domain. That sounds fine until you load a typical page. A modern site can easily have 80+ resources — scripts, stylesheets, fonts, images, API calls. With 6 lanes open, everything else sits in a queue. You could literally watch this happen in DevTools: a cascade of requests stacked up, each one waiting for a previous request to finish before it could even start. Frontend teams spent years working around this with hacks like domain sharding (splitting assets across static1.example.com, static2.example.com) just to get more parallel connections. HTTP/2 killed the need for those tricks overnight.
HTTP/2 Multiplexing: One Connection, Many Streams
HTTP/2’s core trick is multiplexing — sending multiple request/response pairs simultaneously over a single TCP connection. No queuing. No waiting. Each request gets its own “stream” ID, and the server can interleave responses freely. The thing that caught me off guard when I first looked at this in DevTools was how visually obvious it is. Open Chrome DevTools, go to the Network tab, right-click any column header, and enable Protocol. You’ll see h2 next to requests served over HTTP/2 and http/1.1 next to the stragglers. On a well-configured CDN, almost everything should show h2. If you see a mix, that’s often a misconfigured origin server that the CDN is falling back to HTTP/1.1 for — worth investigating.
DevTools → Network tab Right-click any column header → check "Protocol" Look for: h2 → HTTP/2 h3 → HTTP/3 http/1.1 → old protocol (flag this)
HTTP/3 Ditches TCP Entirely — Here’s Why That Matters on Mobile
HTTP/3 is the genuinely surprising one. It drops TCP completely and runs over QUIC, which is built on UDP. That sounds counterintuitive — UDP has no reliability guarantees, so how does this work? QUIC reimplements connection reliability at the application layer, but with a key advantage: connection establishment is faster (0-RTT in some cases, compared to TCP’s handshake plus TLS negotiation), and packet loss on one stream doesn’t block other streams. With TCP, a single lost packet stalls the entire connection until it’s retransmitted — called head-of-line blocking. On a flaky mobile network where packets drop constantly, HTTP/3 stays noticeably more responsive. You’ll see h3 in the Protocol column when this is in use. Major CDNs like Cloudflare and Fastly support it already; your origin server probably doesn’t, which is fine — the CDN handles the HTTP/3 handoff and proxies back to your server over HTTP/2 or HTTP/1.1 internally.
Server Push: Great Idea, Mostly Dead in Practice
HTTP/2 shipped with a feature called Server Push that sounded like magic: the server could proactively send assets it knew you’d need before you even asked for them. Request the HTML, get the CSS and hero image pushed automatically. No round trip. In practice this almost never gets used correctly, and browser vendors have been pulling back support. Chrome deprecated its HTTP/2 Server Push support citing poor performance in real-world deployments — servers pushed too aggressively, sending assets the browser already had cached, wasting bandwidth. The idea required servers to be smarter than they typically are about predicting cache state. The 103 Early Hints status code is the more pragmatic replacement: the server sends a preliminary response with Link headers pointing to likely resources, and the browser can preconnect or prefetch while the full response is still being processed. It’s more conservative but actually works. If you see Server Push in a tutorial from before 2022, mentally file it under “deprecated good idea.”
- HTTP/1.1: Max ~6 parallel connections per domain, head-of-line blocking per connection
- HTTP/2: Single TCP connection, multiplexed streams, still has TCP-level head-of-line blocking
- HTTP/3: QUIC over UDP, eliminates head-of-line blocking entirely, faster on lossy networks
- Server Push: Technically available in HTTP/2, practically don’t bother — use
103 Early Hintsinstead
The practical upshot for you as a developer: if you’re deploying behind a CDN like Cloudflare, you’re already getting HTTP/2 and probably HTTP/3 for free without touching your origin server config. If you’re serving directly from Nginx or Apache, you need to explicitly enable HTTP/2 in your config (listen 443 ssl http2; in Nginx). Most hosting platforms handle this automatically now, but it’s always worth a 30-second check in DevTools to confirm your site isn’t accidentally serving everything over HTTP/1.1.
Tools to Watch All of This Happening in Real Time
Start with the Network Tab — Seriously, Just Open It
If you only learn one tool from this entire guide, make it the Chrome DevTools Network tab. Open any page, hit F12, go to Network, hard-reload with Ctrl+Shift+R, and suddenly everything we’ve talked about becomes visible. You can see every DNS lookup, every TCP connection, every HTTP request with its full headers, response codes, timing breakdown, and payload. The waterfall view alone taught me more about how browsers actually load pages than a year of reading documentation. Click any request, hit the Timing tab, and you’ll see exactly how long was spent in DNS lookup vs TCP handshake vs waiting for the first byte (TTFB) vs downloading the body. That’s not abstract anymore — it’s your specific site, your specific server, right now.
The thing that caught me off guard early on: the Network tab lies a little when you’re authenticated. Cookies, cached resources, service workers — they all change what you see. Always test in an Incognito window with cache disabled (the checkbox is in the Network tab itself) before you draw conclusions. Also, filter by Fetch/XHR when debugging API calls — otherwise you’re drowning in font files and favicon requests.
curl for When You Need the Browser Out of the Way
Browsers add a lot of noise. They follow redirects silently, inject headers, cache aggressively, and generally make it hard to know what’s actually happening at the HTTP level. curl -v strips all of that. Here’s what I run constantly:
# Full verbose output — shows TLS handshake, request headers, response headers, body
curl -v https://yourdomain.com
# Just the response headers, no body — fast for checking redirects and cache headers
curl -I https://yourdomain.com
# Follow redirects and show each hop
curl -Lv https://yourdomain.com
# Check what your CDN is actually returning vs your origin
curl -H "Cache-Control: no-cache" -v https://yourdomain.com
The -I flag is underrated. I use it constantly to verify that Cache-Control, Strict-Transport-Security, and X-Cache headers are set correctly after a deployment. Takes two seconds. No browser, no guessing.
dig and nslookup for DNS Sanity Checks
DNS problems are infuriating because they’re invisible until they’re not. dig is your best friend here. The difference between these two commands matters:
# Fast answer — just the IP(s) your local resolver returned
dig +short github.com
# Full trace from root nameservers down — shows you exactly where the resolution chain goes
dig github.com +trace
dig +short is what you use when you just want to confirm a record exists and has the right value. dig +trace is what you use when something is wrong and you need to know where in the chain it’s going sideways — maybe your registrar’s nameserver is returning stale data, or a subdomain delegation is broken. I’ve spent hours debugging “why is this subdomain not resolving” situations that +trace cracked in 30 seconds. nslookup works too and is available on Windows without installing anything, but dig gives you more control. On Windows, install it via Chocolatey or just use WSL.
Wireshark: Save It for Real Mysteries
Wireshark captures actual packets off the network interface. It’s the right tool when you genuinely cannot figure out what’s happening at a lower level than HTTP — TLS negotiation failures, weird TCP behavior, something that curl can’t show you. I reach for it maybe once every few months. The learning curve is real: the UI is dense, filters have their own query syntax, and you need to understand what you’re looking at or the output is just noise. If you want to start, filter by http or tcp.port == 443 and look at individual packet streams. But honestly, for 95% of the problems beginners hit, DevTools and curl will get you there first. Don’t use Wireshark as a first resort — use it when every other tool has failed you.
WebPageTest Over Lighthouse for Network Reality
Lighthouse runs locally in your browser, on your machine, on your network. WebPageTest (webpagetest.org) runs from real servers in locations you pick, on actual connection profiles (cable, 4G, slow 3G), and shows you the full waterfall with accurate DNS/TCP/TLS timing. It’s free. The difference in what you learn is significant — Lighthouse will give you a performance score, but WebPageTest will show you that your CDN isn’t actually serving your assets from the edge, or that your TTFB from Frankfurt is 900ms because your origin is in us-east-1 and you forgot to configure caching rules. The CDN behavior tab and the connection view are especially useful once you’ve read about how DNS and TLS work. It makes abstract concepts concrete in a way that a score out of 100 never does.
The Mistakes Beginners Make Once They Know All This
DNS Propagation Will Humiliate You If You Trust It
The first mistake that trips up almost everyone: you update a DNS record, set the TTL to 60 seconds, wait two minutes, and wonder why it’s not working. Here’s the brutal reality — TTL is a suggestion, not a contract. Upstream resolvers, ISPs, and sometimes the OS resolver cache will hold onto old records well past the TTL. I’ve seen records with a 60-second TTL still serving stale data 45 minutes later because a carrier-grade NAT somewhere in the middle decided it knew better. Before you cut over DNS for anything real, drop the TTL to something low (300s or less) at least 24 hours before the change, not at the same time. And use dig @8.8.8.8 yourdomain.com alongside dig @1.1.1.1 yourdomain.com to check what different resolvers are returning — you’ll often get different answers, which tells you propagation is still in progress.
The second mistake is debugging weird behavior without ever looking at response headers. Half the time when a page is showing stale content or a fetch is returning something unexpected, the answer is sitting right there in the headers. Open your browser DevTools, go to the Network tab, click the request, and actually read what came back. You’re looking for Cache-Control, Expires, ETag, X-Cache, and Age. A header like Cache-Control: public, max-age=86400 on a response you thought was dynamic means the browser is going to serve that from cache for a full day. If you never set it explicitly in your app, check what your framework defaults to — Express doesn’t set it for you, but some hosting platforms inject their own. Run this and read every line:
curl -I https://yourdomain.com/api/endpoint
The HTTPS misconception is one I see cause real problems. Developers ship a site, see the padlock, and mentally file it under “secure.” TLS encrypts data between the browser and the server. It says nothing about what happens to that data once it hits the server — whether it’s stored in plaintext, logged, shipped to a third-party analytics service, or sitting in an S3 bucket with public read permissions. The padlock means the connection is encrypted, not that the other end is trustworthy or competent. This matters when you’re explaining to a client or a non-technical stakeholder why HTTPS alone doesn’t make their user data safe — you need that distinction to be crisp.
Blaming the origin server when it’s actually the CDN cache is a classic time-waster. I’ve spent an embarrassing amount of time redeploying code to fix a bug that was gone from the server 20 minutes earlier — the CDN was just serving a cached version. The fastest sanity check is to bypass the cache manually and compare responses:
# Hit the CDN normally
curl -I https://yourdomain.com/some-page
# Request a fresh copy, bypassing cache
curl -H 'Cache-Control: no-cache' -I https://yourdomain.com/some-page
# Or hit the origin directly if you know the IP
curl -H 'Host: yourdomain.com' https://your-origin-ip/some-page
If the direct-to-origin response is correct but the CDN is still serving the old version, you need to purge the cache — not redeploy. Cloudflare, Fastly, and CloudFront all have purge APIs. Cloudflare’s is straightforward with a curl call to their API with your zone ID. Check the X-Cache or CF-Cache-Status response headers too — a value of HIT tells you the CDN is serving from cache without touching your server at all.
Mobile network latency is the one that bites developers who do all their testing on fast office WiFi or a wired connection. On a good 4G connection you might have 50-80ms of latency; on a congested urban network or when the phone is switching towers, 200-400ms is realistic. Every separate HTTP request compounds this. A page that makes 12 requests to load — fonts, scripts, API calls, tracking pixels — can feel instant at 5ms latency and genuinely painful at 150ms. Use Chrome DevTools’ network throttling (Fast 4G, Slow 4G presets under the Network tab) while you’re building, not as an afterthought. Better yet, test on a real device tethered to mobile data before you call anything production-ready. The gap between “works on my machine” and “works on a phone in a suburb with two bars” is where a lot of real user frustration lives.