2025/03/18
Anatomy of a Fetch request
- The whole trip on one screen
- 1. V8 and the bindings
- 2. Building a Request
- 3. Main fetch, in Blink
- 4. Leaving the renderer
- 5. Mojo, briefly
- 6. The network service: CORS first
- 7. URLRequestHttpJob: HSTS and cookies
- 8. HttpCache::Transaction
- 9. HttpNetworkTransaction and choosing a protocol
- 10. The socket pool
- 11. DNS
- 12. TCP: socket(), connect(), and waiting
- 13. TLS: BoringSSL
- 14. Serializing the request
- 15. The actual write
- 16. Into the kernel
- 17. The way back
- Mapping it onto DevTools
- See it for yourself
- Closing words
This article stems from one of my favourite interview questions: “Can you explain how a fetch request works?”. It’s not just about the specific subject of the question, but about how intentional the approach to the understanding of the inner workings of a technology is. I believe that understanding how things work is a key part of being a good technical professional, and being intentional about it is what makes the difference between a good and a great one.
Most answers stop at “it sends an HTTP request and returns a promise”. This one
doesn’t. We’re going to follow a single call from the line of JavaScript you
typed all the way down to the send() syscall that hands your bytes to the
kernel, and then a bit further, to the network card. Then we’ll ride the
response back up.
Foreword: “browser” here means Chromium (Chrome, Edge, Brave, Opera…). Other engines follow the same spec but the plumbing differs. Class names come from Chromium’s
mainbranch and do move around between releases; the shape of the stack has been stable for years. Kernel details are Linux, because its source is the easiest to go and read. macOS is similar, with kqueue instead of epoll.
Our request, fired from a page on https://immutablepizza.com:
const res = await fetch("https://example.com/api/pizza");
Cross-origin, a plain GET, no options. About as boring as it gets, which is
exactly why it’s a good specimen.
The whole trip on one screen
Here’s the map. Every section below zooms into one of these lines.
RENDERER (sandboxed, no network access)
V8 → WebIDL bindings convert the arguments
GlobalFetch::fetch Request constructor
FetchManager::Loader::Start spec "main fetch": CSP, mode
PerformHTTPFetch build a ResourceRequest
ThreadableLoader
→ ResourceFetcher → ResourceLoader
→ ResourceRequestSender::SendAsync
→ URLLoaderFactory.CreateLoaderAndStart(...)
────────── Mojo IPC (Mach / Unix socket / pipe) ──────────
NETWORK SERVICE
CorsURLLoader Origin, preflight?
network::URLLoader → net::URLRequest::Start
URLRequestHttpJob HSTS, cookies, Accept-*
HttpCache::Transaction cache lookup
HttpNetworkTransaction Host, Connection, auth
HttpStreamFactory proxy, h1 vs h2 vs h3
TransportClientSocketPool reuse a socket, or...
HostResolverManager DNS
TCPClientSocket::Connect socket(), connect()
SSLClientSocketImpl BoringSSL handshake
SpdySession / HttpStreamParser serialize the request
SSL_write → SocketPosix::DoWrite
→ send(fd, buf, len, MSG_NOSIGNAL)
──────────────────── syscall boundary ────────────────────
KERNEL
tcp_sendmsg → tcp_write_xmit → ip_queue_xmit
→ dev_queue_xmit → NIC driver → the wire
Two processes, one IPC hop, one syscall boundary, and a surprising number of state machines. Let’s go.
1. V8 and the bindings
fetch looks like a plain global function, but V8 knows nothing about it. V8
is a JavaScript engine; HTTP isn’t its business. fetch is installed on the
global object by Blink, the rendering engine, through code generated from
WebIDL.
The contract lives in the Fetch spec:
partial interface mixin WindowOrWorkerGlobalScope {
[NewObject] Promise<Response> fetch(RequestInfo input, optional RequestInit init = {});
};
At build time Chromium runs its bindings generator over Blink’s IDL files and
spits out C++ glue: a V8 FunctionTemplate whose callback does the boring but
very precise job of converting JavaScript values into C++ types, exactly as
WebIDL prescribes:
inputis aRequestInfo, a union ofRequestorUSVString. A string gets converted to a USVString, which means lone UTF-16 surrogates are replaced with U+FFFD before anyone even looks at the URL.initis aRequestInitdictionary. WebIDL converts dictionary members in lexicographic order, invoking getters as it goes. You can actually observe this: pass an object with logging getters and you’ll seebody,cache,credentials,headers,method… read alphabetically.
Only then does the generated code call into the real implementation,
GlobalFetch::fetch() in third_party/blink/renderer/core/fetch/global_fetch.cc.
2. Building a Request
GlobalFetch::ScopedFetcher::Fetch first constructs a Request, running the
spec’s
Request constructor, which is
where most of the defaults you never set come from:
| Field | Value for our call | Why |
|---|---|---|
| URL | parsed by the WHATWG URL parser | relative URLs resolve against the page |
| method | GET |
default |
| mode | cors |
the default for fetch() |
| credentials | same-origin |
cookies only for same-origin requests |
| redirect | follow |
up to 20 redirects |
| referrer | about:client → the page URL |
then trimmed by the referrer policy |
| referrer policy | strict-origin-when-cross-origin |
so example.com only sees https://immutablepizza.com/ |
| destination | empty ("") |
it’s a fetch, not an image or a script |
The result is a FetchRequestData, Blink’s internal mirror of the spec’s
request concept, and it’s handed to FetchManager::Fetch(). That method creates
the Promise<Response> you’ll eventually await, a FetchManager::Loader to do
the actual work, and returns the promise straight away. Your JavaScript is
already running the next line while everything below happens asynchronously.
3. Main fetch, in Blink
FetchManager::Loader::Start() is a fairly literal transcription of the spec’s
main fetch algorithm; the source is
full of quoted spec steps. In order:
- Content Security Policy: if the page’s
connect-srcdoesn’t allowexample.com, you get a network error right here, without a single byte leaving the machine. - Mixed content: an
http://fetch from anhttps://page is blocked (that check lives inResourceFetcher). - Same-origin or not? Same-origin requests,
data:URLs and navigations go down the “scheme fetch” path. Asame-originmode request to another origin fails immediately.no-corsrequests get an opaque response tainting. Ours is cross-origin with modecors, so it proceeds to an HTTP fetch with the CORS flag set.
Then FetchLoaderBase::PerformHTTPFetch() turns FetchRequestData into a
blink::ResourceRequest: method, headers, credentials mode, cache mode, redirect
mode, integrity, priority, referrer, plus SetUseStreamOnResponse(true), which
tells the loader not to buffer the body because it will be exposed as a stream.
From here the request enters Blink’s generic resource loading machinery, the same used by images, scripts and stylesheets:
ThreadableLoader→ResourceFetcher::RequestResource→ResourceLoader.ResourceLoadSchedulermay hold it back if the frame has too many requests in flight or is being throttled in the background.fetch()defaults toHighpriority, which you can change withpriority: "low".- If the page is controlled by a service worker, this is where the request
gets diverted to it as a
FetchEvent, and may never touch the network at all.
Finally ResourceRequestSender::SendAsync() wraps it in a ThrottlingURLLoader
(extension points for things like Safe Browsing) and makes the call that leaves
the process.
4. Leaving the renderer
The renderer process is sandboxed. On Linux it lives under seccomp-bpf and in its own namespaces; on macOS under a Seatbelt profile. It can’t open files, it can’t open sockets: the sandbox simply refuses the syscall. All it can do is ask, politely, over IPC.
What it asks through is a network::mojom::URLLoaderFactory remote. Here’s the
interface:
// services/network/public/mojom/url_loader_factory.mojom
CreateLoaderAndStart(pending_receiver<URLLoader> loader,
int32 request_id,
uint32 options,
URLRequest request,
pending_remote<URLLoaderClient> client,
MutableNetworkTrafficAnnotationTag traffic_annotation);
Read the arguments carefully, because they’re the whole design:
requestis anetwork::ResourceRequest: URL, method, headers, body, credentials mode, initiator origin, priority, CORS mode… everything.loaderis the receiving end of a pipe the renderer will use to talk to this request later: change priority, or cancel it when you callabort().clientis a pipe going the other way: the network service will callOnReceiveResponse,OnReceiveRedirectandOnCompleteon it.
The important security detail: the renderer didn’t create this factory. The
browser process minted it and handed it over when the frame was created,
with parameters the renderer can’t change, like request_initiator_origin_lock.
If a compromised renderer claims a request comes from https://yourbank.com, the
network service compares that with the lock, notices it doesn’t match, and
treats the renderer as hostile. The renderer gets to describe the request; it
doesn’t get to decide who it is.
5. Mojo, briefly
Mojo is
Chromium’s IPC system. The .mojom file above compiles into C++ proxies and
stubs; calling CreateLoaderAndStart on the proxy serializes the arguments into
a message, routes it through Mojo’s ports layer to the right node (process), and
writes it to a platform channel:
- macOS: Mach messages (the source literally tags its messages
'MOJO') - Linux: a Unix domain socket, with
sendmsg()andSCM_RIGHTSfor passing handles - Windows: named pipes
Bodies don’t travel as messages. Uploads and responses use data pipes: shared memory ring buffers with a producer on one side and a consumer on the other. That matters later: your response body will never be copied through an IPC message, it’ll be streamed through shared memory.
Fun fact: so far, from fetch() to here, we haven’t made a single networking
syscall. Just one IPC write.
6. The network service: CORS first
On desktop, the network service runs in its own utility process (look for
--utility-sub-type=network.mojom.NetworkService in ps). Since around Chrome 79 it is
also where CORS is enforced, not the renderer. “Out-of-renderer CORS” exists
precisely so a compromised renderer can’t just skip the checks.
The message lands in cors::CorsURLLoaderFactory::CreateLoaderAndStart, which
validates it (is this initiator allowed for this factory? are these headers
even legal?) and creates a CorsURLLoader. For our request it:
- Adds
Origin: https://immutablepizza.com. - Calls
NeedsPreflight(). AGETwith no custom headers is a “simple” request, so noOPTIONSpreflight. AddContent-Type: application/jsonor anAuthorizationheader andPreflightControllerwould first fire anOPTIONSrequest (cached in the preflight cache afterwards). - Adds the
Sec-Fetch-*metadata headers (services/network/sec_header_helpers.cc):Sec-Fetch-Site: cross-site,Sec-Fetch-Mode: cors,Sec-Fetch-Dest: empty.
Then it hands off to the inner network::URLLoader, which creates a
net::URLRequest from the URLRequestContext (one per profile, roughly: it
owns the cookie store, the cache, the host resolver, the socket pools…) and
calls Start().
From here on we’re in //net, Chromium’s network stack proper. Almost
everything in it is an asynchronous state machine: a DoLoop() that
switches on next_state_, runs a step, and either continues or returns
ERR_IO_PENDING and waits for a callback. Once you see the pattern, the code
becomes much easier to read.
7. URLRequestHttpJob: HSTS and cookies
URLRequest picks a job for the URL scheme: URLRequestHttpJob for http(s).
Before the job even exists, its factory checks HSTS. If you’d asked for
http://example.com and example.com were in the HSTS preload list (or had sent
Strict-Transport-Security before), you’d get a synthetic internal 307
redirect to https://, with no network involved. 307 rather than 301 so a POST
stays a POST.
Then URLRequestHttpJob::Start():
AddExtraHeaders(): setsAccept-Encoding(gzip, deflate, br, zstd) andAccept-Languageif missing.AddCookieHeaderAndStart(): asks the cookie store for matching cookies. Credentials mode issame-originand we’re cross-origin, so in our case: none. Withcredentials: "include"this is whereCookie:gets added, after SameSite, partitioning and third-party cookie rules have had their say.StartTransaction(): asks the context’sHttpTransactionFactoryfor a transaction. That factory is the cache.
8. HttpCache::Transaction
HttpCache::Transaction is the biggest state machine in the stack. Its states
read like a novel: STATE_GET_BACKEND, STATE_OPEN_OR_CREATE_ENTRY,
STATE_ADD_TO_ENTRY, … It looks up the cache entry for the key (URL plus
network isolation key, because the cache is partitioned by top-level site so that
evil.com can’t probe whether you’ve visited example.com), and then:
- Fresh hit: serves it from disk. Done, no network.
- Stale but validatable: adds
If-None-Match/If-Modified-Sinceand goes to the network, hoping for a304. - Miss: creates a real network transaction and becomes a pass-through that also writes the response into the entry as it streams by.
Ours is a miss.
9. HttpNetworkTransaction and choosing a protocol
HttpNetworkTransaction::BuildRequestHeaders() adds the last transport-level
headers: Host: example.com and Connection: keep-alive. It also handles auth
challenges (401/407) and retries on stale connections later on.
Then it needs a stream: something it can write a request into and read a
response from. It asks HttpStreamFactory, whose JobController does more than
you’d think:
- Proxy resolution:
ProxyResolutionServiceevaluates your proxy config, possibly running a PAC script, to decide betweenDIRECTand a proxy chain. - Protocol choice: is there an existing HTTP/2 session to this origin we can
multiplex onto? Is there an
Alt-Svcentry or a DNSHTTPSrecord advertising HTTP/3? If so it may race a QUIC job against a TCP job and use whichever connects first. - The “main job” for our cold connection: get a socket from the pool.
10. The socket pool
TransportClientSocketPool holds connections grouped by destination (scheme,
host, port, privacy mode, network isolation key…). The limits are right there
in net/socket/client_socket_pool_manager.cc:
g_max_sockets_per_group = { 6, 255 }; // normal, WebSocket
g_max_sockets_per_pool = { 256, 256 };
6 connections per host, 256 total, for HTTP/1.1. That’s the reason the 7th parallel request to the same HTTP/1.1 host sits in “Queueing” in DevTools. With HTTP/2 there’s normally a single connection per origin and the limit stops mattering.
If there’s an idle keep-alive socket in our group, we take it and skip the next
three sections entirely. That’s why the second request to a host is so much
faster than the first. We don’t have one, so the pool starts a ConnectJob, in
our case an SSLConnectJob wrapping a TransportConnectJob.
Newer Chromium builds are migrating this layer to an
HttpStreamPoolimplementing Happy Eyeballs v3, behind theHappyEyeballsV3feature, still off by default at the time of writing. Same idea, different classes.
11. DNS
TransportConnectJob first needs an IP address, so it asks HostResolverManager.
- Check the host cache. Hit? Done.
- Otherwise, run a resolution job. Depending on platform and settings, that’s
either Chromium’s built-in async DNS client (which reads the system config
but builds and parses DNS packets itself), DNS-over-HTTPS if secure DNS is
enabled (yes, that’s an HTTP request inside your HTTP request), or a fallback
to the system’s
getaddrinfo()on a worker thread. - It typically asks for
A,AAAAandHTTPSrecords in parallel. TheHTTPSrecord can advertise ALPN protocols (that’s how Chrome can learn about HTTP/3 without a previousAlt-Svc) and Encrypted Client Hello keys.
12. TCP: socket(), connect(), and waiting
With an address list in hand, TransportConnectJob connects. If it has both IPv6
and IPv4 addresses it tries IPv6 first and starts a Happy Eyeballs race: if
IPv6 hasn’t connected after kIPv6FallbackTime, which is
base::Milliseconds(300), it starts an IPv4 attempt in parallel.
Each attempt ends up in TCPSocketPosix and SocketPosix, and this is where
we finally meet real syscalls:
fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
fcntl(fd, F_SETFL, O_NONBLOCK); // everything in //net is non-blocking
connect(fd, &addr, sizeof(addr)); // returns -1, errno = EINPROGRESS
A non-blocking connect() returns immediately: the kernel sends the SYN and
we move on. Chromium registers the fd with its IO thread’s message pump
(WatchFileDescriptor, backed by epoll on Linux, kqueue on macOS) and
waits for it to become writable. When the SYN-ACK arrives and the kernel
completes the handshake, epoll wakes the IO thread, and Chromium calls
getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) to find out whether it actually
worked.
Once connected, TCPSocketPosix calls SetTCPNoDelay(fd, true), which
disables Nagle’s algorithm: small writes go out immediately instead of
waiting to be coalesced. Latency over efficiency. It also enables
SO_KEEPALIVE with a 45 second delay.
Cost so far: one round trip.
13. TLS: BoringSSL
SSLConnectJob wraps the TCP socket in an SSLClientSocketImpl, which drives
BoringSSL, Google’s fork of OpenSSL. BoringSSL doesn’t touch the socket
itself: Chromium gives it a custom BIO (SocketBIOAdapter) so all reads and
writes go through //net’s async socket.
SSLClientSocketImpl configures the connection (ALPN with h2 and
http/1.1, session resumption, ECH if the DNS HTTPS record had keys) and calls
SSL_do_handshake(). BoringSSL builds a TLS 1.3 ClientHello and writes it
into the BIO.
Here’s a fun one. Current Chrome sends a post-quantum hybrid key share,
X25519MLKEM768. An ML-KEM-768 public key alone is 1,184 bytes, so the
ClientHello is now around 1.7KB: more than a single TCP segment (1,460 bytes
of payload on a typical Ethernet path). Your handshake’s first flight now goes
out as two packets. A few middleboxes that assumed a ClientHello always fits in
one packet broke when this shipped.
The server answers with ServerHello, its certificate chain, and Finished, all
in one flight. Chrome then:
- Verifies the certificate chain against the Chrome Root Store with its own built-in verifier, rather than delegating to the OS as it used to on most platforms.
- Checks Certificate Transparency: the cert must come with signed timestamps proving it was logged publicly.
- Reads the ALPN result. The server picked
h2, so this connection will speak HTTP/2.
Cost: one more round trip (TLS 1.3 is 1-RTT; resumed sessions can do 0-RTT for some requests). The socket goes back to the pool’s caller as a usable, encrypted stream.
14. Serializing the request
Now we finally write an HTTP request. What that looks like depends on ALPN.
HTTP/1.1: HttpBasicStream generates the request line, and HttpStreamParser
runs its own state machine (DoSendHeaders → DoSendBody → DoReadHeaders →
DoReadBody). The bytes are plain text:
GET /api/pizza HTTP/1.1
Host: example.com
Connection: keep-alive
sec-ch-ua: "Chromium";v="...", "Not_A Brand";v="..."
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...
Accept: */*
Origin: https://immutablepizza.com
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://immutablepizza.com/
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: en-GB,en;q=0.9
You wrote one line of JavaScript. Look at how many layers had a say:
| Header | Added by |
|---|---|
Accept: */* |
Fetch spec default |
sec-ch-ua*, User-Agent |
the browser’s client hints and UA settings |
Origin, Sec-Fetch-* |
network service (CORS, fetch metadata) |
Referer |
Blink computes it, policy trims it, //net sends it |
Accept-Encoding, Accept-Language |
URLRequestHttpJob::AddExtraHeaders |
Host, Connection |
HttpNetworkTransaction::BuildRequestHeaders |
HTTP/2, our actual case: no text at all. SpdyHttpStream hands the headers
to a SpdyStream on the connection’s SpdySession. The headers become
pseudo-headers (:method, :scheme, :authority, :path) plus lowercase
regular headers, get HPACK-compressed (a static table, a dynamic table of
previously sent headers, and Huffman coding, so the second request’s headers
might take a couple dozen bytes), and get wrapped in a HEADERS frame with
END_STREAM set on a new odd-numbered stream id. On a brand new connection,
that frame is queued right behind the connection preface
(PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n) and a SETTINGS frame.
HTTP/3 would be the same idea with QPACK, over QUIC, over UDP. Different
syscalls (sendmsg on a UDP socket), and the TCP and TLS handshakes collapse
into one round trip.
15. The actual write
The frame bytes go to SSLClientSocketImpl::Write(), which calls BoringSSL’s
SSL_write(). BoringSSL wraps them in a TLS record, encrypting with AES-GCM or
ChaCha20-Poly1305 depending on what was negotiated and whether the CPU has AES
instructions, appends a 16-byte authentication tag, and pushes the ciphertext
into the SocketBIOAdapter. The adapter flushes into TCPClientSocket::Write()
→ SocketPosix::Write() → SocketPosix::DoWrite(), and here it is, the bottom
of userland:
// net/socket/socket_posix.cc
ssize_t send_rv =
HANDLE_EINTR(send(socket_fd_, buf->data(), buf_len, MSG_NOSIGNAL));
HANDLE_EINTRretries if a signal interrupts the call.MSG_NOSIGNALstops the kernel from deliveringSIGPIPEand killing the process if the peer has already closed the connection; you getEPIPEinstead.- If the kernel’s send buffer is full,
send()returnsEAGAIN, and Chromium goes back toWatchFileDescriptor, this time for writability, and tries again when epoll says there’s room.
And this is important: when send() returns, the server has received
nothing. A successful write means “the kernel has copied your bytes into its
buffer”. Nothing more.
16. Into the kernel
On x86-64 Linux, glibc’s send() is the sendto syscall. Inside the kernel:
__sys_sendto→sock_sendmsg→tcp_sendmsg: the bytes are copied from the Chromium process’s memory into socket buffers (sk_buffs) on the socket’s write queue.tcp_push→tcp_write_xmit: TCP decides how much it’s allowed to send right now. On a fresh connection the congestion window starts at 10 segments (RFC 6928), roughly 14KB. Our request fits easily; a big upload wouldn’t, and would trickle out as ACKs grow the window.tcp_transmit_skbbuilds the TCP header: ports, sequence and ack numbers, window, options, checksum (often left for the NIC to compute).ip_queue_xmitroutes the packet, picks the source address and builds the IP header.ip_output→neigh_outputresolves the next hop’s MAC address via ARP or NDP, almost certainly already cached.dev_queue_xmitenqueues it in the interface’s queueing discipline (oftenfq_codelorfq), which may pace packets out.- The driver’s
ndo_start_xmitplaces a descriptor in the NIC’s transmit ring, the NIC DMAs the bytes straight out of RAM, and they leave as an Ethernet frame or a Wi-Fi frame. With TSO the kernel can hand the NIC one large buffer and let the hardware cut it into segments.
Somewhere around here, a single line of JavaScript turned into light in a fibre.
17. The way back
The server responds. The trip up is the same stack in reverse, so here’s the fast version:
- The NIC DMAs the frame into a receive ring and raises an interrupt.
NAPI polls the ring in softirq context,
ip_rcv→tcp_v4_rcv(ortcp_v6_rcv) →tcp_rcv_establishedputs the payload on the socket’s receive queue, andsk_data_readywakes epoll. - Chromium’s IO thread wakes up,
SocketPosix::DoReadcallsread(),SSL_read()decrypts and verifies the record, andSpdySessiondecodes aHEADERSframe (HTTP/1.1:HttpStreamParser::DoReadHeadersparses the status line and headers) intoHttpResponseHeaders. - Back up through
HttpNetworkTransaction(auth challenges handled here) andHttpCache::Transaction, which decides whether the response is cacheable and starts writing the entry. URLRequestHttpJobstores anySet-Cookies (SaveCookiesAndNotifyHeadersComplete), processesStrict-Transport-Security, and sets up decompression forContent-Encoding: br,gziporzstd.network::URLLoader::OnResponseStartedcreates the data pipe for the body.CorsURLLoaderrunsCheckAccess(): doesAccess-Control-Allow-Originmatchhttps://immutablepizza.com(or*)? If not, the response is dropped right here. The renderer only learns that it failed; that’s why your console saysTypeError: Failed to fetchwhile DevTools, which reads the network service’s error, tells you the real reason.URLLoaderClient::OnReceiveResponse(head, body, cached_metadata)crosses Mojo back into the renderer.ResourceRequestSender::OnReceivedResponse→ResourceLoader→ThreadableLoader→FetchManager::Loader::DidReceiveResponse, which builds aResponsearound aBodyStreamBufferand resolves the promise.
That last step is worth stopping on. The promise resolves when the headers
arrive, not when the body has finished downloading. That’s why
await fetch() and await res.json() are two separate awaits. A CORS response
also comes with a filtered header list: res.headers only shows the
CORS-safelisted headers and whatever the server listed in
Access-Control-Expose-Headers.
The resolution itself is a microtask, queued when the Mojo message’s task runs on the main thread. If the main thread is busy running some long task, your response just sits there, fully received, waiting for its turn.
Then, as you read the body, the network service’s URLLoader::ReadMore() reads
decompressed bytes from the URLRequest into the shared memory data pipe, and
Blink’s side exposes it as the ReadableStream behind res.body. res.json()
drains that stream, decodes it as UTF-8, and hands the string to V8’s
JSON.parse. The response never got copied through an IPC message.
Mapping it onto DevTools
The DevTools Timing tab and the PerformanceResourceTiming API are this
article in table form:
| DevTools phase | What’s happening | Section |
|---|---|---|
| Queueing | ResourceLoadScheduler, or waiting for one of the 6 sockets |
3, 10 |
| Stalled | proxy resolution, waiting for a socket or a stream | 9, 10 |
| DNS Lookup | HostResolverManager |
11 |
| Initial connection | TCP handshake (+ TLS) | 12, 13 |
| SSL | TLS handshake | 13 |
| Request sent | serializing and send()ing the request |
14, 15 |
| Waiting for server response | time to first byte: one RTT plus server think time | 16, 17 |
| Content Download | reading the body | 17 |
On a cold HTTPS connection that’s roughly DNS + 1 RTT (TCP) + 1 RTT (TLS) + 1 RTT (request/response) before the first byte. A reused connection is just the last one. HTTP/3 merges the first two.
See it for yourself
You don’t have to take my word for any of this.
-
chrome://net-export: records a NetLog of every event in//net. Load the file in the NetLog viewer and you’ll seeHOST_RESOLVER_MANAGER_JOB,TCP_CONNECT,SSL_CONNECT,HTTP2_SESSION_SEND_HEADERS… every section of this article, timestamped. -
strace the network service (Linux):
strace -f -e trace=socket,connect,sendto,recvfrom,read,write \ -p "$(pgrep -f 'utility-sub-type=network.mojom.NetworkService')"Fire a fetch from the console and watch the
socket(), theEINPROGRESS, and finallysendto(..., MSG_NOSIGNAL, NULL, 0). -
Wireshark with
SSLKEYLOGFILE=/tmp/keys.logset when launching Chrome lets you decrypt the TLS and see the HPACK-encodedHEADERSframe. -
Read the source: source.chromium.org is fully indexed.
net/socket/socket_posix.ccis a good place to start, then climb.
Closing words
So when someone asks “how does fetch work?”, the honest answer is: a WebIDL
binding, a spec algorithm transcribed into C++, a sandbox and the IPC system
built to get around it, a CORS gatekeeper in another process, a cache, a
protocol negotiation, a connection pool, a DNS resolver, two handshakes, an
encryption library, a send() and a kernel. All of it to give you back a
promise you’ll probably await without thinking twice.
Which is fine. That’s what good abstractions are for. But knowing what’s under it is what lets you explain why the 7th request is slow, why CORS errors are vague, or why the first call to an API costs three round trips and the second costs one.