2025/03/18

Share

on:

Anatomy of a Fetch request

share

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 main branch 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:

  • input is a RequestInfo, a union of Request or USVString. 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.
  • init is a RequestInit dictionary. 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 see body, 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.

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:

  1. Content Security Policy: if the page’s connect-src doesn’t allow example.com, you get a network error right here, without a single byte leaving the machine.
  2. Mixed content: an http:// fetch from an https:// page is blocked (that check lives in ResourceFetcher).
  3. Same-origin or not? Same-origin requests, data: URLs and navigations go down the “scheme fetch” path. A same-origin mode request to another origin fails immediately. no-cors requests get an opaque response tainting. Ours is cross-origin with mode cors, 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.
  • ResourceLoadScheduler may hold it back if the frame has too many requests in flight or is being throttled in the background. fetch() defaults to High priority, which you can change with priority: "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:

  • request is a network::ResourceRequest: URL, method, headers, body, credentials mode, initiator origin, priority, CORS mode… everything.
  • loader is the receiving end of a pipe the renderer will use to talk to this request later: change priority, or cancel it when you call abort().
  • client is a pipe going the other way: the network service will call OnReceiveResponse, OnReceiveRedirect and OnComplete on 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() and SCM_RIGHTS for 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(). A GET with no custom headers is a “simple” request, so no OPTIONS preflight. Add Content-Type: application/json or an Authorization header and PreflightController would first fire an OPTIONS request (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(): sets Accept-Encoding (gzip, deflate, br, zstd) and Accept-Language if missing.
  • AddCookieHeaderAndStart(): asks the cookie store for matching cookies. Credentials mode is same-origin and we’re cross-origin, so in our case: none. With credentials: "include" this is where Cookie: gets added, after SameSite, partitioning and third-party cookie rules have had their say.
  • StartTransaction(): asks the context’s HttpTransactionFactory for 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-Since and goes to the network, hoping for a 304.
  • 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:

  1. Proxy resolution: ProxyResolutionService evaluates your proxy config, possibly running a PAC script, to decide between DIRECT and a proxy chain.
  2. Protocol choice: is there an existing HTTP/2 session to this origin we can multiplex onto? Is there an Alt-Svc entry or a DNS HTTPS record advertising HTTP/3? If so it may race a QUIC job against a TCP job and use whichever connects first.
  3. 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 HttpStreamPool implementing Happy Eyeballs v3, behind the HappyEyeballsV3 feature, 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, AAAA and HTTPS records in parallel. The HTTPS record can advertise ALPN protocols (that’s how Chrome can learn about HTTP/3 without a previous Alt-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_EINTR retries if a signal interrupts the call.
  • MSG_NOSIGNAL stops the kernel from delivering SIGPIPE and killing the process if the peer has already closed the connection; you get EPIPE instead.
  • If the kernel’s send buffer is full, send() returns EAGAIN, and Chromium goes back to WatchFileDescriptor, 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:

  1. __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.
  2. 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.
  3. tcp_transmit_skb builds the TCP header: ports, sequence and ack numbers, window, options, checksum (often left for the NIC to compute).
  4. ip_queue_xmit routes the packet, picks the source address and builds the IP header. ip_output → neigh_output resolves the next hop’s MAC address via ARP or NDP, almost certainly already cached.
  5. dev_queue_xmit enqueues it in the interface’s queueing discipline (often fq_codel or fq), which may pace packets out.
  6. The driver’s ndo_start_xmit places 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:

  1. 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 (or tcp_v6_rcv) → tcp_rcv_established puts the payload on the socket’s receive queue, and sk_data_ready wakes epoll.
  2. Chromium’s IO thread wakes up, SocketPosix::DoRead calls read(), SSL_read() decrypts and verifies the record, and SpdySession decodes a HEADERS frame (HTTP/1.1: HttpStreamParser::DoReadHeaders parses the status line and headers) into HttpResponseHeaders.
  3. Back up through HttpNetworkTransaction (auth challenges handled here) and HttpCache::Transaction, which decides whether the response is cacheable and starts writing the entry.
  4. URLRequestHttpJob stores any Set-Cookies (SaveCookiesAndNotifyHeadersComplete), processes Strict-Transport-Security, and sets up decompression for Content-Encoding: br, gzip or zstd.
  5. network::URLLoader::OnResponseStarted creates the data pipe for the body.
  6. CorsURLLoader runs CheckAccess(): does Access-Control-Allow-Origin match https://immutablepizza.com (or *)? If not, the response is dropped right here. The renderer only learns that it failed; that’s why your console says TypeError: Failed to fetch while DevTools, which reads the network service’s error, tells you the real reason.
  7. URLLoaderClient::OnReceiveResponse(head, body, cached_metadata) crosses Mojo back into the renderer.
  8. ResourceRequestSender::OnReceivedResponse → ResourceLoader → ThreadableLoader → FetchManager::Loader::DidReceiveResponse, which builds a Response around a BodyStreamBuffer and 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 see HOST_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(), the EINPROGRESS, and finally sendto(..., MSG_NOSIGNAL, NULL, 0).

  • Wireshark with SSLKEYLOGFILE=/tmp/keys.log set when launching Chrome lets you decrypt the TLS and see the HPACK-encoded HEADERS frame.

  • Read the source: source.chromium.org is fully indexed. net/socket/socket_posix.cc is 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.

Bibliography