← Root to End Browser track · 1 of 3 · captured from real Chromium 141

What fetch() really sends

The event-loop page said "the network process performs our fetch" and moved on. This page follows that request for real. We type one line, fetch('https://api.example.test/api/users'), and trace it out of the renderer, through Chrome's network service, DNS, TCP, a TLS 1.3 handshake and HTTP, past the CORS checks, and back into a resolved promise. Every byte shown was captured from that request.

Browser track: 1 · What fetch() really sends · next: 2 · From HTML bytes to pixels →

How this was captured: headless Chromium 141.0.7390.37 on Linux loaded a page from app.example.test:8080 that fetched from api.example.test. Both names were answered by our own DNS server, and the API was our own TLS server; the scripts are in the folder's data/ directory. Chrome recorded everything in a NetLog. The certificate is self-signed, so Chrome ran with --ignore-certificate-errors; we show where that matters. Source excerpts come from Chromium, BoringSSL and Linux upstream (October 2026), trimmed with // ….

Frame 0 · the whole trip

Replay Chrome's own log of our request

Chrome can record everything its network stack does into a NetLog: each event, its timestamp in milliseconds, and its parameters. Below is our request, replayed from that log. Times are measured from the moment the network service created the request. It all happened on one machine, so the network itself is nearly free; on the real internet, each arrow that crosses to the server costs a round trip.

What it is. 24 events selected from Chrome 141's own NetLog for our request, with their real timestamps and parameters.

Checked against. It is the real log; the full file is data/netlog.json and opens in the NetLog viewer.

Not covered. The real internet: client and server were on one machine, so round trips cost almost nothing; the certificate was self-signed and Chrome ran with --ignore-certificate-errors; our server chose HTTP/1.1.

Every event in this replay
Frame 2 · the network service

A URLLoader starts, and CORS decides on a preflight

In the network service, a URLLoader wraps the request in net/'s URLRequest, asks the resource scheduler whether it may start, and calls Start(). Before that, the CORS layer asks one question: does this request need a preflight? Only if the method is not GET, HEAD or POST, or a header is not on the safelist. Our first fetch is a plain GET, so the NetLog says preflight_required: false.

services/network/cors/cors_url_loader.cc · NeedsPreflight()trimmed

    
services/network/url_loader.cc · ScheduleStart()verbatim

    
Frame 3 · DNS

From a name to an address: getaddrinfo() and two small UDP packets

On Linux, this Chrome resolved the name with the operating system's resolver: the NetLog shows HOST_RESOLVER_SYSTEM_TASK. Chrome calls getaddrinfo() on a worker thread because it blocks. glibc reads /etc/resolv.conf, sends a UDP query to port 53, and waits. (Chrome also has a built-in DNS client, used for DNS-over-HTTPS and on some platforms; the log shows which one ran.)

The packets are tiny. Click any field or byte below: the query is 34 bytes and the answer is 50. The name travels as length-prefixed labels, 3 api 7 example 4 test 0, and the answer points back at it with a 2-byte compression pointer instead of repeating it. Our server also saw an AAAA (IPv6) query for the same name.

net/dns/host_resolver_system_task.cc · SystemHostResolverCall()trimmed

  

What it is. The bytes are real captures; the field names and values come from our dissector in wire-model.js.

Checked against. scapy, an independent packet parser, on the same bytes: 18 checks covering DNS and TLS fields (verify/check-dissectors.py).

Not covered. Fields that do not occur in these captures, most DNS record types, and decrypting the encrypted TLS records.

Frame 4 · TCP

connect(), and the kernel sends a SYN

With an address, Chrome opens a non-blocking socket and calls connect(). The call returns EINPROGRESS immediately; Chrome watches the socket and is told when it becomes writable. Inside the kernel, tcp_connect() builds a segment with only the SYN flag set, transmits it, and arms a retransmit timer in case no SYN-ACK arrives.

That exchange, SYN → SYN-ACK → ACK, is one full round trip before any data can flow. In our capture it took 1 ms because the server was on the same machine. Across the world it costs whatever the round-trip time is, typically 20 to 200 ms.

net/socket/socket_posix.cc · DoConnect()verbatim

    
linux/net/ipv4/tcp_output.c · tcp_connect()trimmed

    

We met this kernel side before. The threads page showed a socket waking a sleeping reader through its wait queue; the network service's IO thread sleeps in epoll_wait on exactly such sockets.

Frame 5 · TLS 1.3

The ClientHello: 1,822 bytes, mostly a post-quantum key

Chrome now runs a TLS handshake through BoringSSL. Before the first byte, it sets the hostname (SNI) and the protocols it can speak (ALPN), then calls SSL_do_handshake(), which writes the ClientHello. TLS 1.3 finishes in one round trip because the ClientHello already carries public keys: the client guesses which key-exchange groups the server will accept.

Chrome's guess is two keys: classic x25519 (32 bytes) and the hybrid X25519MLKEM768, which adds a 1,184-byte ML-KEM post-quantum key. That is why this ClientHello is 1,822 bytes instead of a few hundred. Our server, Node 22 with OpenSSL, chose the hybrid group, and the NetLog confirms it: key_exchange_group: 4588 is 0x11EC.

Look for the GREASE values like 0x9a9a: deliberately meaningless entries that keep servers from breaking when real new values appear. Look also for the hostname, which is still in plaintext.

net/socket/ssl_client_socket_impl.ccSNI, ALPN, handshake; trimmed

    
net/ssl/ssl_config_service.cc · kDefaultSSLSupportedGroupsverbatim

    
boringssl/ssl/handshake_client.cc · ssl_add_client_hello()verbatim

  

Everything after the ServerHello is encrypted, including the server's certificate. In our capture Chrome still verified that certificate and logged net_error -202 (ERR_CERT_AUTHORITY_INVALID, because it is self-signed) before the flag let it continue. On a real site this is where an untrusted certificate stops the request. The handshake took 12 ms here: about 6 ms for the server to compute its key share and signature, and 5 ms for Chrome's certificate check. The network round trip itself cost almost nothing.

Frame 6 · HTTP/1.1

The request is just text, written into the encrypted pipe

ALPN offered h2 and http/1.1; our server picked HTTP/1.1, so what Chrome writes is readable text. GenerateRequestLine builds GET /api/users HTTP/1.1\r\n, the headers follow one per line, and a blank line ends them. HttpStreamParser::SendRequest logs those headers to the NetLog (that is the event we replayed), and DoSendHeaders writes the bytes. BoringSSL encrypts them into a TLS record on the way out.

All three requests used the same connection. The first fetch paid for DNS, TCP and TLS. The preflight and the POST found an idle socket in the pool and went straight to HTTP. That is Connection: keep-alive at work.

net/http/http_util.cc · GenerateRequestLine()verbatim

    
net/http/http_stream_parser.ccSendRequest and DoSendHeaders; trimmed

    

Frame 7 · CORS

Who may read the answer: preflights, allow-lists, and a lab

Our page is http://app.example.test:8080; the API is https://api.example.test. Different origins. The browser will send the request, but it lets our script read the response only if the server opts in with Access-Control-Allow-Origin. That is the core of CORS: it protects the user's data on other sites from our script, not the server from requests.

For anything beyond a "simple" request, the browser first asks permission with an OPTIONS preflight. Our second fetch was a POST with Content-Type: application/json, which is not on the safelist, so the NetLog says preflight_required_reason: disallowed_header. The OPTIONS and its 204 reply are in the HTTP viewer above.

The lab below implements the rules. It agrees with real Chrome 141 on all 2,880 combinations we ran (every method, header set and credentials mode against every server policy), including one place where Chrome is more lenient than the spec: Access-Control-Allow-Headers: * still lets Authorization through.

What it is. Our implementation of the Fetch standard's CORS rules as Chrome applies them: when a preflight is needed, and whether the response may be read.

Checked against. Real Chrome 141 on 2,880 combinations of method, headers, credentials and server policy: whether a preflight was sent and whether fetch() succeeded (verify/cors-matrix.js). One switch shows where the spec is stricter than Chrome.

Not covered. Preflight status codes other than 2xx, redirects, the preflight cache, Private Network Access, Access-Control-Expose-Headers, and Firefox or Safari behaviour.

Our request
The API server's reply headers
Try
    Preflight Chrome would send
    Then the request itself
    services/network/public/cpp/cors/cors.ccsafelists; trimmed
    
        
    services/network/cors/preflight_result.cc · EnsureAllowedCrossOriginHeaders()trimmed
    
        

    The flag in the second excerpt, kCorsNonWildcardRequestHeadersSupport, is declared FEATURE_DISABLED_BY_DEFAULT in services/network/public/cpp/features.cc. Until it ships, Chrome lets * cover Authorization and records a deprecation warning, while the Fetch standard says it must not. We found this because the lab disagreed with Chrome on exactly 40 of the 2,880 cases. A server should list authorization explicitly.

    Frame 8 · back to our code

    A task resolves our promise, then res.json() resolves another

    When the headers arrive, the network service sends them to the renderer with OnReceiveResponse, along with the read end of a Mojo data pipe for the body. In the renderer, that message arrives on the IO thread and becomes a task on CrRendererMain: the same wake chain the event-loop page traced. In that task, Blink builds a Response and calls Resolve, which queues our .then or await continuation as a microtask.

    Our promise resolves as soon as the headers are in, before the body. res.json() starts a second wait: it reads the data pipe to the end, then parses the text with V8's JSON parser and resolves its own promise. That is why fetch needs two awaits.

    services/network/url_loader.cc · SendResponseToClient()verbatim
    
        
    blink/…/fetch/fetch_manager.cc · DidReceiveResponse()trimmed
    
        
    blink/…/fetch/body.cc · BodyJsonConsumerverbatim
    
      

    The page can see this timeline too. The Resource Timing API exposes a timestamp at each boundary (the API sent Timing-Allow-Origin: *, which cross-origin pages need before they can read them). These are the real values our page read for its two fetches:

    performance.getEntriesByType("resource"), real values

    The second request reused the connection, so its DNS, TCP and TLS bars are zero. Its CORS preflight does not show here as its own entry; the preflight's time folds into the gap before requestStart.

    Frame 9 · practice

    Predict, then check

    See it on our own machine

    What to seeHow
    Our own NetLogOpen chrome://net-export, start logging, load a page, stop, and open the file in the NetLog viewer. Ours is in data/netlog.json.
    Timing per stageDevTools → Network → click a request → Timing. The bars are the Resource Timing values above.
    DNS packetsdig api.github.com (or nslookup on Windows) prints the answer section, TTL and server.
    A real TLS 1.3 handshakeopenssl s_client -connect example.com:443 -tls1_3 -msg prints each handshake message.
    The system callsstrace -f -e trace=network curl -s https://example.com -o /dev/null shows socket, connect, sendto and recvfrom.
    Raw HTTP/1.1curl -v --http1.1 https://example.com prints the request line, headers and status line.

    Exercises

    1. Record a NetLog while loading any HTTPS site. Find the SSL_CONNECT end event and decode key_exchange_group and cipher_suite with the tables in our dissector.
    2. In DevTools, compare the Timing tab of the first and second request to the same API. Which bars disappear, and why?
    3. Write a tiny server that sends Access-Control-Allow-Origin: * and fetch it with credentials: "include". Read the exact console message, then fix the server two different ways.
    4. Use dig +noedns api.github.com and dig api.github.com and compare the "Additional" section. What is the OPT record for?
    5. Run openssl s_client -groups x25519 and then -groups X25519MLKEM768 against a site (OpenSSL 3.5 or newer). Compare the size of the ClientHello in the -msg output.
    Exam practice

    Exam-style questions, with model answers

    Five questions in the style of a computer networks or web systems exam. Write our answer on paper first, then open the model answer and mark ourselves honestly. The marks follow the usual pattern of university theory exams; they are not copied from any real paper.

    Self-marks stay on this page only; refreshing clears them.
    A fresh browser (empty caches) on http://app.example.test:8080 runs await fetch("https://api.example.test/api/users") and then a JSON POST to the same URL. List, in order, every message exchanged between the browser and the network, say which side sends each, and mark which are encrypted.
    #From → toMessageEncrypted?
    1–2client → DNS server → clientDNS query for api.example.test (A, and AAAA), then the answerno (plain UDP)
    3–5client ↔ serverTCP SYN, SYN-ACK, ACKno
    6client → serverClientHello (SNI, ALPN, key shares)no
    7server → clientServerHello (chosen suite and key share), then EncryptedExtensions, Certificate, CertificateVerify, FinishedServerHello no; the rest yes
    8client → serverFinished (after a compatibility ChangeCipherSpec)yes
    9server → clientNewSessionTicket ×2 (for resumption)yes
    10–11client ↔ serverGET /api/users, then 200 OK with JSONyes
    12–13client ↔ serverOPTIONS preflight, then 204 No Content with allow-listsyes
    14–15client ↔ serverPOST /api/users, then 200 OKyes

    No second DNS lookup, TCP handshake or TLS handshake: the later requests reuse the keep-alive connection. This is the order in our real capture.

    Marking: 1 for DNS before TCP, 1 for the three-way handshake, 2 for the TLS messages and which are encrypted, 1 for the preflight before the POST, 1 for noting connection reuse.

    The page origin is http://app.example.test:8080. For each request, say whether Chrome sends a preflight and whether our script can read the response.
    1. GET with Accept: application/json; server sends Allow-Origin with our origin.
    2. POST with Content-Type: application/json; server sends our origin and Allow-Headers: content-type.
    3. PUT with no headers; server sends our origin and Allow-Methods: GET, POST.
    4. GET with credentials: "include"; server sends Allow-Origin: * and Allow-Credentials: true.
    5. DELETE with Authorization; server sends our origin, Allow-Methods: * and Allow-Headers: *.
    Preflight?Readable?Why
    anoyesSafelisted method and header; origin allowed.
    byesyesapplication/json is not safelisted; the preflight allows content-type, and POST is safelisted so Allow-Methods is not needed.
    cyesnoPUT is not safelisted and not in Allow-Methods, so the preflight fails and the PUT is never sent.
    dnonoWith credentials, Allow-Origin must name the origin; * is rejected. The server still processed the GET.
    eyesChrome 141: yes. Spec: no* covers the method (no credentials). The spec says * never covers Authorization; Chrome has not shipped that rule yet.

    All five rows were produced by this page's CORS model, which matches real Chrome on 2,880 cases.

    Marking: 1 per row; a full mark for (e) needs both the Chrome and the spec answer.

    TLS 1.2 needs two round trips before application data, and TLS 1.3 needs one. Explain what changed in the ClientHello, and what happens if the client guesses wrong.

    In TLS 1.2 the client first learns the server's chosen cipher and key-exchange parameters (round trip 1), and only then sends its own key share (round trip 2). In TLS 1.3 the client sends key shares in the first message, in the key_share extension, for the groups it predicts the server will pick. Chrome sends X25519MLKEM768 and x25519. The server answers with its own share in the ServerHello, both sides derive the handshake keys, and the rest of the server's flight is already encrypted.

    If the server supports none of the offered shares, it replies with a HelloRetryRequest naming a group it does support. The client sends a second ClientHello with that key share, costing one extra round trip.

    Marking: 2 for "key shares sent up front", 1 for the server share in ServerHello, 1 for HelloRetryRequest.

    Our real Resource Timing entry (ms): startTime 29.6, domainLookupStart 32.9, domainLookupEnd 35.5, connectStart 35.5, secureConnectionStart 36.7, connectEnd 49.1, requestStart 49.3, responseStart 50.5, responseEnd 51.9. (a) Compute the time spent in DNS, TCP, TLS, waiting for the first byte, and downloading. (b) Estimate the total for the same fetch to a server 80 ms away (round-trip time), ignoring server and CPU time.

    (a) DNS 35.5 − 32.9 = 2.6 ms; TCP 36.7 − 35.5 = 1.2 ms; TLS 49.1 − 36.7 = 12.4 ms; waiting for the first byte 50.5 − 49.3 = 1.2 ms; download 51.9 − 50.5 = 1.4 ms. Total 51.9 − 29.6 = 22.3 ms, including 3.3 ms of queueing before DNS.

    (b) Count the exchanges that must wait for an answer: TCP 1 RTT, TLS 1.3 1 RTT, and request plus response 1 RTT, so 3 × 80 = 240 ms, plus DNS. A DNS lookup costs up to another round trip if no nearby resolver has the name cached, so between 240 and about 320 ms in total. TLS 1.2 adds one more round trip (+80 ms). Locally the network was nearly free, which is why our TLS time was all computation.

    Marking: 3 for the five durations, 2 for the round-trip estimate with reasons.

    The DNS answer we captured contains the bytes c0 0c where the name should be. Explain these two bytes and why the value is 12.

    This is DNS message compression (RFC 1035, Section 4.1.4). A length byte can only go up to 63, so a byte whose top two bits are 11 marks a pointer instead of a label. The remaining 14 bits, 0x000c = 12, are an offset from the start of the message.

    The DNS header is exactly 12 bytes (ID, flags, and four counts), so offset 12 is where the question's name begins. The answer says "the name at offset 12", reusing api.example.test instead of repeating 18 bytes.

    Marking: 2 for "pointer, top bits 11", 2 for "header is 12 bytes, so it points at the question's name".

    Reading list

    Everything this page cites, by depth

    The numbered markers in the text open the relevant reading right where we need it. Here is all of it in one place: Start here for a first pass, Go deeper for the full treatment, and Primary sources for the RFCs and specifications the code implements.

    Sources

    Original files and data

    Chromium and BoringSSL code is used under their BSD-style licenses and the Linux excerpt under GPL-2.0, quoted for study.