What happens when you fetch()
Follow one fetch() call all the way down — the work the browser does before any packet leaves, DNS, TCP, TLS, the raw request bytes, and the exact moment await res.json() resolves.
What happens when you fetch()
You write const res = await fetch('https://api.hisaab.measdev.me/groups') and, a moment later, you have JSON. That "moment" is the most eventful part of your program, and almost none of it is JavaScript. Between the call and the resolved promise, your machine parses a URL, decides whether it's even allowed to send the request, looks in three or four caches, turns a name into an address, opens a connection, negotiates encryption, writes a few hundred bytes onto a socket, and waits — mostly it waits.
This lesson is the map. Each stop gets a paragraph or two here and a full lesson of its own later. Follow the request top to bottom, and keep one number in your head the whole way: the speed of light doesn't care how good your code is.
Before a single byte leaves the machine
The most surprising thing about fetch() is how much happens with the network card sitting idle.
URL parsing. The browser splits the string into scheme (https), host (api.hisaab.measdev.me), port (implied 443), path, and query. A malformed URL fails here, synchronously, before any I/O.
The cache check. For a GET, the browser looks in the HTTP cache first. If it holds a fresh response (Cache-Control: max-age still valid), you get it back with zero network I/O — the fastest request is the one you never send. If the cached copy is stale, the browser may send a conditional request (If-None-Match with the stored ETag) hoping for a cheap 304 Not Modified.
The CORS decision. Your page is on fable.measdev.me; the request goes to api.hisaab.measdev.me. Different origin. For a "simple" GET the browser sends the request and then checks the Access-Control-Allow-Origin header on the way back — if it's wrong, your JS is blocked from reading a response the server already sent. For anything non-simple (a PUT, a DELETE, a custom header like Authorization or Idempotency-Key), the browser first sends a separate OPTIONS preflight request and waits for permission before sending the real one. That's a full extra round trip you pay before your actual call begins. (CORS is a browser rule, not a server one — curl never does any of this.)
Connection reuse. Before opening anything new, the browser checks its connection pool. If it already has a live, warm TCP+TLS connection to api.hisaab.measdev.me:443, it reuses it and skips the next three sections entirely. This is why the second request to a host is dramatically cheaper than the first. Everything below is the cold-start path.
Turning the name into an address
api.hisaab.measdev.me is not something you can send a packet to. The network moves bytes between IP addresses, so the name has to become something like 203.0.113.10 first. That's DNS, and it's a lookup chain with caches at every hop — your browser, your OS, and a recursive resolver that may walk from the root servers down to the domain's authoritative nameserver.
On a warm cache this is sub-millisecond. On a cold miss it can be 20–120ms of its own round trips before your real request has even started. DNS gets its own lesson next — DNS: how a name becomes an address — so here just note that it happens, it's cached, and a cold miss is not free.
Opening the pipe: TCP
Now you have an IP. TCP (Transmission Control Protocol) is what gives you an ordered, reliable byte stream on top of a network that loses and reorders packets freely. Before any data flows, both sides do the three-way handshake: SYN → SYN-ACK → ACK. That's one full round trip where nothing useful is exchanged — you're just agreeing to talk.
"One round trip" is the unit that matters. If the server is 70ms away, the handshake alone costs ~70ms before you've sent a request byte. We take TCP apart — handshake, sequence numbers, retransmission, why it's a stream and not messages — in TCP: the connection under everything.
Locking it: TLS
The scheme was https, so once TCP is up, TLS (Transport Layer Security — the "S" in HTTPS) runs its own handshake on top: agree on a cipher, verify the server's certificate chains to a trusted authority, derive session keys. With TLS 1.3 that's one more round trip; with the older TLS 1.2 it's two. Only after this does anything you'd recognize as "the request" go out — and now it's encrypted.
Tally the cold start to a server 70ms away: ~70ms DNS (worst case) + 70ms TCP + 70ms TLS 1.3, roughly 210ms before your first request byte lands. The certificate math, why the padlock means "encrypted and authenticated" but not "safe", and session resumption are in TLS: how the padlock works.
The request, as actual bytes
Here's what finally travels down the socket. HTTP is text (in HTTP/1.1 — HTTP/2 binary-frames the same information):
GET /groups HTTP/1.1
Host: api.hisaab.measdev.me
Authorization: Bearer eyJhbGciOi...
Accept: application/json
User-Agent: Mozilla/5.0 (...)
Accept-Encoding: gzip, brThat trailing blank line is load-bearing: it's how the server knows the headers are done. A GET has no body, so that's the whole request — a few hundred bytes. The server does its work and writes back:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: gzip
Content-Length: 1287
Cache-Control: private, max-age=0
Access-Control-Allow-Origin: https://fable.measdev.me
{"groups":[{"id":"01H...","name":"Goa Trip"}, ...]}The status line tells you what happened, the headers describe the body, a blank line separates them, and the body follows. Content-Length tells the receiver exactly how many body bytes to expect. Note Access-Control-Allow-Origin — that's the CORS answer from earlier, arriving with the response. And the body is gzipped; Content-Encoding: gzip means those 1287 bytes decompress to something larger before your code sees it. (The JSON body shown here is decompressed for readability.)
What the server process actually sees
On the other end, none of this is magic either. The operating system hands those request bytes to a listening process through a socket — a file-descriptor the kernel fills as packets arrive. NestJS (Fable's backend framework) is sitting in an accept()/read loop getting fed that byte stream, parsing the request line and headers, and routing to a handler. The server writes its response back to the same socket. We go down to that kernel boundary — sockets, file descriptors, accept(), backpressure — in Sockets and the kernel.
Coming back: streaming and when json() resolves
This is the part that trips up people who think of fetch as one atomic event. fetch()'s promise resolves as soon as the response headers arrive — status line and headers, not the body. That's why you can check res.ok and res.status immediately while the body is potentially still in flight. The body is a stream (res.body is a ReadableStream), arriving in chunks as TCP delivers packets.
await res.json() is a second await for a reason: it reads the stream to completion, then parses. It resolves only after the last body byte has arrived and been decompressed and parsed. So a slow 5MB response gives you a resolved fetch promise almost instantly and a res.json() that hangs for seconds. If you only need the first few bytes, reading res.body directly lets you start before the whole payload lands.
The number that should scare you
Human intuition is terrible at these scales. Normalize everything to your CPU and it becomes visceral — this is the classic "latency numbers" table, rounded:
| Operation | Real time | If 1 CPU cycle were 1 second |
|---|---|---|
| L1 cache reference | ~1 ns | ~1 s |
| Main memory (RAM) reference | ~100 ns | ~1.5 min |
| SSD random read | ~100 µs | ~1 day |
| Same-datacenter round trip | ~0.5 ms | ~5.5 days |
| Mumbai → Singapore round trip | ~70 ms | ~2 years |
| Mumbai → US-East round trip | ~200–250 ms | ~6–7 years |
The gap between "same datacenter" and "another region" is three orders of magnitude. A round trip that leaves your region costs years in CPU-time. Every architecture decision that matters for latency is really a decision about how many of those long round trips sit on the critical path of one user action.
Go deeper
- High Performance Browser Networking — "Primer on Latency and Bandwidth" — The single best free networking book for web devs; this chapter makes the "latency is distance" point rigorously, with the physics.
- alex/what-happens-when — "What happens when you type google.com and press Enter?" — The exhaustive canonical answer to this exact question, from keypress to pixels — go here when one paragraph per stage is not enough.
- HTTP: Learn Your Browser's Language — Julia Evans — The whole HTTP mental model in friendly illustrated pages — the fastest way to make this lesson stick.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- A GET to an endpoint you called 200ms ago returns instantly with no packets sent. Which check made that happen, and what header controlled it?
- You add an Authorization header to a cross-origin GET and suddenly see an OPTIONS request in the network tab first. Why, and what did it cost you in round trips?
- Order these by wall-clock cost for a cold request to a server 70ms away: TLS 1.3 handshake, HTTP request/response, TCP handshake, the server-side handler. Roughly what fraction is round trips vs CPU?
- fetch() resolved but your data is still undefined for two seconds. Explain, in terms of what fetch resolves on versus what res.json() waits for.
- A colleague wants to shave 3ms off a database query to speed up a slow screen whose request crosses a region twice. Why might that be the wrong lever, and what number tells you so?
- Reusing a warm connection skips three whole stages of the cold path. Which three, and roughly how many milliseconds does that save against a 70ms-away host?