HTTP — Client-Server Communication, Versions, Methods, Headers
HTTP Client-Server: What Actually Happens
sequenceDiagram
participant BROWSER as Browser / Client
participant DNS2 as DNS
participant SERVER as HTTP Server
rect rgb(230, 230, 250)
Note over BROWSER,SERVER: ① DNS resolution (only on first visit — cached after that)
BROWSER->>DNS2: resolve api.example.com
DNS2-->>BROWSER: 93.184.216.34
end
rect rgb(200, 230, 255)
Note over BROWSER,SERVER: ② TCP connection
BROWSER->>SERVER: TCP SYN (port 80 for HTTP)
SERVER-->>BROWSER: TCP SYN-ACK
BROWSER-->>SERVER: TCP ACK
Note over BROWSER,SERVER: connection established — still no HTTP bytes have flowed
end
rect rgb(255, 240, 200)
Note over BROWSER,SERVER: ③ HTTP request
BROWSER->>SERVER: GET /users/123 HTTP/1.1
Note over BROWSER: Host: api.example.com<br/>Accept: application/json<br/>Connection: keep-alive
end
rect rgb(220, 255, 220)
Note over BROWSER,SERVER: ④ Server processes the request
activate SERVER
SERVER->>SERVER: parse URL, route to handler
SERVER->>SERVER: query DB, build response
deactivate SERVER
SERVER-->>BROWSER: HTTP/1.1 200 OK
Note over SERVER: Content-Type: application/json<br/>Content-Length: 145<br/>Cache-Control: max-age=60
end
rect rgb(255, 225, 235)
Note over BROWSER,SERVER: ⑤ Response body
SERVER-->>BROWSER: {"id":123,"name":"Alice"}
end
Note over BROWSER,SERVER: Connection kept alive (keep-alive) — the next request reuses this same TCP connection instead of paying for a new handshake
The diagram shows the connection staying open after the response ("Connection kept alive for next request"). Why does that matter?
HTTPS: Adding TLS Between TCP and HTTP
Plain HTTP sends everything as readable text — anyone on the network can read your passwords, cookies, and data. HTTPS wraps HTTP inside a TLS tunnel so all data is encrypted.
graph LR
classDef insecure fill:#c0392b,stroke:#7b241c,color:#fff
classDef secure fill:#27ae60,stroke:#1e8449,color:#fff
subgraph HTTP["HTTP (Port 80) — INSECURE"]
A1["Browser"] -->|"GET /login<br/>password=alice123<br/>← VISIBLE to anyone on network"| B1["Server"]
end
class A1,B1 insecure
subgraph HTTPS["HTTPS (Port 443) — SECURE"]
A2["Browser"] -->|"Xk39dP#!@Nm2...<br/>← looks like garbage to attackers"| B2["Server"]
B2 -->|"decrypts with private key<br/>reads: password=alice123"| B2
end
class A2,B2 secure
What HTTPS adds on top of HTTP:
sequenceDiagram
participant C as Client (Browser)
participant S as HTTPS Server (google.com)
rect rgb(200, 230, 255)
Note over C,S: ① TCP Handshake — same as HTTP (1 round trip)
C->>S: SYN → port 443
S-->>C: SYN-ACK
C-->>S: ACK ✓ TCP connected
end
rect rgb(200, 255, 200)
Note over C,S: ② TLS Handshake — NEW, not in plain HTTP (1-2 round trips)
C->>S: "I support TLS 1.3, here are my cipher preferences<br/>My ECDHE public key: [key_share]"
S-->>C: "Let's use TLS_AES_256_GCM_SHA384<br/>My ECDHE public key: [key_share]<br/>📜 Certificate: CN=*.google.com<br/>🔏 Signed by: DigiCert (trusted CA)"
Note over C: ✓ Certificate valid?<br/>✓ Domain matches google.com?<br/>✓ Not expired?<br/>✓ DigiCert in my trusted CAs?
C->>S: ✓ Finished (keys derived, handshake verified)
Note over C,S: 🔑 Both derived the SAME session key<br/>Nobody intercepting the network has this key
end
rect rgb(255, 230, 200)
Note over C,S: ③ HTTP over TLS — everything encrypted
C->>S: [ENCRYPTED] GET /gmail HTTP/2<br/>Cookie: session=abc123<br/>Authorization: Bearer token...
S-->>C: [ENCRYPTED] 200 OK<br/>Your emails here...
Note over C,S: Attacker sees random bytes — cannot read anything
end
Note over C,S: Total before first response byte on a fresh connection: 1 RTT (TCP) + 1 RTT (TLS 1.3) = 2 RTTs
The role of public and private keys:
graph TD
classDef cert fill:#2980b9,stroke:#1b4f72,color:#fff
classDef browser fill:#7f8c8d,stroke:#555,color:#fff
classDef session fill:#27ae60,stroke:#1e8449,color:#fff
classDef priv fill:#c0392b,stroke:#7b241c,color:#fff
subgraph CHAIN["Certificate chain of trust"]
BROWSER["Browser has DigiCert's public key<br/>(pre-installed in OS/browser)"]:::browser
CERT["📜 Certificate contains:<br/>Server's PUBLIC KEY 🔓<br/>Domain: *.google.com<br/>Signed by: DigiCert"]:::cert
BROWSER -->|"verify DigiCert's signature<br/>on the certificate"| CERT
end
subgraph EXCHANGE["Key exchange — deriving the shared secret"]
PRIV["Server's PRIVATE KEY 🔒<br/>never leaves the server"]:::priv
SESSION["🔑 Session key (AES)<br/>derived independently by both sides<br/>used to encrypt all data"]:::session
CERT -->|"browser uses server's PUBLIC KEY 🔓<br/>to help establish shared session key"| SESSION
PRIV -->|"server uses private key<br/>to complete key exchange"| SESSION
end
The certificate contains the server's public key. Where does the server's private key ever get sent over the network?
Here's that same negotiation walked through step by step, contrasting TLS 1.2's two round trips with TLS 1.3's one:
ClientKeyExchange + ChangeCipherSpec + Finished. That's round trip 1 spent.
ChangeCipherSpec + Finished. Only now, after 2 full round trips, can the first HTTP byte go out.
Finished together with the actual GET /page request — encrypted, in a single round trip.
Why TLS 1.3 is faster:
- TLS 1.2: client must wait for server cert before deriving keys → 2 RTTs
- TLS 1.3: client sends its ECDHE key_share upfront → server derives keys in first message → 1 RTT
- TLS 1.3 also encrypts the certificate (TLS 1.2 cert is plaintext on the wire)
HTTP/1.1 vs HTTP/2 vs HTTP/3
Three generations of HTTP attacked the same two problems — connection overhead and head-of-line blocking — with three different transport-layer strategies. Here's the timeline, then each version's tradeoffs side by side.
graph LR
classDef v10 fill:#7f8c8d,stroke:#555,color:#fff
classDef v11 fill:#2980b9,stroke:#1b4f72,color:#fff
classDef v2 fill:#e67e22,stroke:#ba6018,color:#fff
classDef v3 fill:#27ae60,stroke:#1e8449,color:#fff
subgraph ERA10["1996"]
H10["HTTP/1.0<br/>One request per TCP connection<br/>Close after each response<br/>High latency — new TCP+TLS per request"]:::v10
end
subgraph ERA11["1997"]
H11["HTTP/1.1<br/>Keep-Alive connections<br/>Pipelining (rarely used)<br/>Head-of-line blocking<br/>6 parallel connections per host (browser limit)"]:::v11
end
subgraph ERA2["2015"]
H2["HTTP/2<br/>Binary framing<br/>Multiplexing: many streams on ONE TCP<br/>Header compression (HPACK)<br/>Server push<br/>Still has TCP HOL blocking"]:::v2
end
subgraph ERA3["2022"]
H3["HTTP/3<br/>QUIC over UDP<br/>No TCP HOL blocking<br/>0-RTT connection resume<br/>Connection migration (change IP, keep session)"]:::v3
end
H10 --> H11 --> H2 --> H3
Keep-Alive lets a connection serve multiple requests sequentially, and pipelining lets a client queue several without waiting for each response — but pipelining is rarely used in practice because a single slow response still blocks everything queued behind it on that connection. To get real parallelism, browsers open up to 6 TCP connections per host, which is exactly why HTTP/1.1 suffers head-of-line blocking at the connection level.
HEADERS/DATA frames, each tagged with a stream ID, and multiplexed onto one TCP connection instead of six. HPACK header compression cuts repeated header bytes across requests, and the server can proactively push resources. It still rides on top of TCP, though — so a single lost packet stalls every multiplexed stream until it's retransmitted, because TCP still enforces in-order delivery for the whole connection.
HTTP/2 multiplexes many streams on a single TCP connection. Does that mean HTTP/2 eliminates head-of-line blocking entirely?
Head-of-Line Blocking
graph TD
classDef blocked fill:#c0392b,stroke:#7b241c,color:#fff
classDef slow fill:#e67e22,stroke:#ba6018,color:#fff
classDef ok fill:#27ae60,stroke:#1e8449,color:#fff
classDef mux fill:#2980b9,stroke:#1b4f72,color:#fff
subgraph H11["HTTP/1.1: 6 TCP connections (browser limit)"]
C1["Conn 1: GET /index.html — done"]:::ok
C2["Conn 2: GET /style.css — done"]:::ok
C3["Conn 3: GET /app.js — SLOW (100ms)"]:::slow
C4["Conn 4: blocked — waiting for a free connection slot"]:::blocked
C5["Conn 5: GET /image.png — done"]:::ok
C6["Conn 6: waiting..."]:::blocked
end
subgraph H2["HTTP/2: 1 TCP connection, many streams"]
MUX["Single TCP connection<br/>multiplexed streams"]:::mux
S1["Stream 1: /index.html"]:::ok
S2["Stream 2: /style.css"]:::ok
S3["Stream 3: /app.js (slow)"]:::slow
S4["Stream 4: /image.png"]:::ok
S5["Stream 5: /font.woff"]:::ok
MUX --> S1 & S2 & S3 & S4 & S5
NOTE["S3 slow? Other streams unaffected<br/>(TCP HOL only if packet loss)"]
end
HTTP/2 stream priority: Each stream has a weight (1-256) and optional dependency. The server can prioritize CSS/JS over images. In practice, most servers use equal priority.
/index.html, /style.css, and /image.png each get their own connection and complete normally.
/app.js takes 100ms and holds Conn 3 the whole time — nothing else is wrong with the network, that connection is just busy.
/app.js stream doesn't consume a whole connection — it's just one stream among several sharing the same wire, so the others keep flowing.
Try It: Live Head-of-Line Blocking Simulator
The stepper above walks through one fixed scenario. Rebuild it with your own resources below — add as many as you want, mark any subset slow or lost, then flip between the three protocols to see how each one schedules the exact same set of resources.
HTTP/2 Binary Framing
GET /users HTTP/1.1
Host: api.example.com
Accept: application/json
Plain text, parsed line by line. Human-readable on the wire, but slower to parse and impossible to compress structurally — each request repeats full header names.
HEADERS frame {
:method: GET
:path: /users
:authority: api.example.com
accept: application/json
}
DATA frame { body bytes }
Same request, split into typed, length-prefixed binary frames. Faster to parse, and headers get HPACK-compressed across requests on the same connection.
Each HTTP/2 frame has:
- Length (3 bytes)
- Type (1 byte): HEADERS, DATA, SETTINGS, WINDOW_UPDATE, PING, GOAWAY
- Flags (1 byte): END_STREAM, END_HEADERS, PADDED, PRIORITY
- Stream ID (4 bytes): which request this belongs to (odd=client, even=server)
An HTTP/2 frame's Stream ID is odd. Who opened that stream?
QUIC / HTTP-3: Wire-Level Depth
The tab above already says QUIC isn't "HTTP/2 semantics moved onto UDP." Here's what that actually means at the wire level.
QUIC is a separate transport protocol, not an HTTP trick. UDP itself gives an application nothing beyond "here are some packets, maybe" — no ordering guarantee, no retransmission, no congestion control. TCP built all three of those in. QUIC, running on top of bare UDP, has to reimplement all three itself: it has its own reliability and ordering scheme and its own independent congestion control and loss-recovery algorithm, standardized in its own RFC and unrelated to TCP's (CUBIC/BBR/etc). That's the tradeoff — QUIC pays the cost of rebuilding what TCP gave for free, and in exchange gets to design that reliability layer without inheriting TCP's transport-level head-of-line blocking.
The TLS handshake and the transport handshake are the same handshake. The HTTPS section above frames TLS as something layered on top of an already-established TCP connection — first the TCP handshake finishes, then a separate TLS handshake runs on that connection. QUIC breaks that mental model on purpose: TLS 1.3 is integrated directly into QUIC's own transport handshake, so the cryptographic handshake and the connection-establishment handshake are literally one exchange, not two sequential ones. That's the entire reason QUIC gets 1-RTT connection establishment (0-RTT for a resumed connection) where TCP+TLS 1.3 needs 2 RTTs — one for TCP's SYN/SYN-ACK/ACK, then a separate one for TLS's ClientHello/ServerHello+Finished on top of it:
Streams are independent at the transport layer itself, not just the application layer. HTTP/2 multiplexes streams too — but it hands those multiplexed frames to TCP, which only understands one ordered byte stream for the whole connection, so a single lost segment stalls every stream behind it (the TCP-level HOL blocking covered above). QUIC moves stream multiplexing into the transport protocol: each QUIC stream carries its own delivery and retransmission state, so a packet loss affecting one stream's data only stalls that stream — the others keep delivering in order, unaffected. Same underlying goal as HTTP/2's streams, but fixed one layer further down, which is why it actually closes the gap HTTP/2 couldn't.
A QUIC connection is identified by a Connection ID, not the traditional 4-tuple. A TCP connection's identity is its (source IP, source port, destination IP, destination port) 4-tuple — change any one of those four values and it is, by definition, a different connection; there's no mechanism for reattaching a live TCP connection to a new 4-tuple. QUIC instead negotiates an explicit Connection ID during the handshake, independent of the underlying IP and port. A phone walking off WiFi onto cellular gets a new source IP the instant it switches — a TCP connection breaks right there and needs a fresh handshake, but a QUIC connection just keeps sending packets tagged with the same Connection ID over the new path, and the server matches them straight back to the same live connection state. That's connection migration.
Why can a QUIC connection survive a client's IP address changing (WiFi → cellular) the way a TCP connection never can?
TLS 1.3 alone is already a 1-RTT handshake. Why does QUIC (also 1-RTT for a fresh connection) still save a full round trip over TCP + TLS 1.3?
TLS 1.2 vs TLS 1.3 — Visual Comparison
The core improvement in TLS 1.3: client sends its ECDHE key upfront, so the server derives encryption keys in the first message — saving one full round trip.
sequenceDiagram
participant C as Browser
participant S as Server
C->>S: ClientHello - supported cipher suites
S-->>C: ServerHello + Certificate + Key params
Note over C: Wait for cert, verify it, generate secret
C->>S: Encrypted pre-master secret + ChangeCipherSpec
S-->>C: ChangeCipherSpec + Finished
Note over C,S: 2 round trips spent - only now can HTTP start
C->>S: GET /page - ENCRYPTED
sequenceDiagram
participant C as Browser
participant S as Server
C->>S: ClientHello plus ECDHE key_share
Note over S: Server derives session keys immediately
S-->>C: ServerHello plus key_share plus Certificate plus Finished
Note over C: Derives same keys, verifies cert
C->>S: Finished plus GET /page - ENCRYPTED, same flight
Note over C,S: 1 round trip done - HTTP data sent with Finished
Real-world impact at 50ms RTT:
- TLS 1.2: 2 × 50ms = 100ms before first byte of response
- TLS 1.3: 1 × 50ms = 50ms before first byte of response
- TLS 1.3 0-RTT resumption: 0ms if reconnecting to same server within session window
At 50ms round-trip time, roughly how much faster is a fresh TLS 1.3 handshake than TLS 1.2, before the first byte of the response arrives?
HTTP Request / Response Structure
Request:
GET /api/users?page=2 HTTP/2
Host: api.example.com
Authorization: Bearer eyJhbGc...
Accept: application/json
Content-Type: application/json
X-Request-ID: abc-123
{"filter": "active"}
Response:
HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: max-age=60, public
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 1700000300
{"users": [...], "total": 150}
HTTP Status Codes
graph LR
classDef info fill:#2980b9,stroke:#1b4f72,color:#fff
classDef success fill:#27ae60,stroke:#1e8449,color:#fff
classDef redirect fill:#f39c12,stroke:#ba6018,color:#fff
classDef clienterr fill:#e67e22,stroke:#ba6018,color:#fff
classDef servererr fill:#c0392b,stroke:#7b241c,color:#fff
subgraph OK["Request succeeded or is in progress"]
S1["1xx Informational<br/>100 Continue<br/>101 Switching Protocols (WebSocket upgrade)"]:::info
S2["2xx Success<br/>200 OK · 201 Created<br/>204 No Content<br/>206 Partial Content (range requests)"]:::success
S3["3xx Redirect<br/>301 Moved Permanently (cache)<br/>302 Found (temp, no cache)<br/>304 Not Modified (ETag matched)<br/>307 Temporary Redirect (keep method)"]:::redirect
end
subgraph ERR["Something went wrong"]
S4["4xx Client Error — you did something wrong<br/>400 Bad Request · 401 Unauthorized<br/>403 Forbidden · 404 Not Found<br/>409 Conflict · 422 Unprocessable Entity<br/>429 Too Many Requests"]:::clienterr
S5["5xx Server Error — the server did something wrong<br/>500 Internal Server Error<br/>502 Bad Gateway (upstream error)<br/>503 Service Unavailable<br/>504 Gateway Timeout"]:::servererr
end
401 vs 403: 401 = you didn't authenticate (no token or invalid token). 403 = you authenticated but don't have permission.
WebSocket: The Upgrade Handshake and Frame Format
101 Switching Protocols above is the trigger for WebSocket — it's how a connection that starts as ordinary HTTP ends up carrying something that isn't HTTP at all.
The Upgrade handshake. A WebSocket connection starts as a completely normal HTTP/1.1 GET request, carrying three extra headers: Upgrade: websocket, Connection: Upgrade, and a client-generated Sec-WebSocket-Key. If the server supports the upgrade, it replies 101 Switching Protocols with its own Upgrade/Connection headers and a Sec-WebSocket-Accept value computed from the client's key via a fixed algorithm/GUID (so the server proves it actually understood the request, not just echoed it). From that 101 response onward, the underlying TCP connection stops being HTTP entirely — no more request lines, no more headers-per-message — it's now a raw stream of WebSocket frames in both directions:
sequenceDiagram
participant C as Client
participant S as Server
rect rgb(255, 240, 200)
Note over C,S: Handshake — still plain HTTP/1.1
C->>S: GET /chat HTTP/1.1, Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
S-->>C: 101 Switching Protocols, Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
end
rect rgb(220, 255, 220)
Note over C,S: From here on this is not HTTP anymore - raw WebSocket frames on the same TCP connection
C->>S: FRAME opcode=text, MASKED, payload="hello"
S-->>C: FRAME opcode=text, unmasked, payload="hi back"
end
Frame format basics. Every WebSocket frame carries an opcode identifying what kind of frame it is (text, binary, close, ping, pong, or continuation for a fragmented message), a payload-length field using a variable-length encoding — 7 bits inline for short payloads, with 16-bit or 64-bit extended-length fields kicking in for larger ones — and a MASK bit. That MASK bit is mandatory for every client-to-server frame: the client must generate a random 32-bit masking key and XOR it against the entire payload before sending, and the server reverses the same XOR to read it. Server-to-client frames are never masked. This isn't symmetric by accident — masking exists specifically to stop cache-poisoning attacks against naive proxies that might otherwise misinterpret unmasked client bytes as plain, cacheable HTTP traffic sitting on the wire.
Ping/pong keepalive. Either side can send a ping control frame at any point; the receiver is required to answer with a pong. This is the mechanism a WebSocket connection uses both to detect a dead peer (no pong back means the other end is gone) and to keep an otherwise-idle connection alive through any piece of infrastructure — proxy, NAT gateway, load balancer — that would silently time out a connection with no bytes flowing across it.
That last point is exactly the failure mode covered in load-balancers.md's Common Issues section: an LB's idle timeout (ALB defaults to 60s) doesn't know or care that a WebSocket connection is intentionally quiet — no bytes flowing for that long looks identical to a dead connection, so the LB kills it. Neither the client nor the server gets a WebSocket close frame or any application-level error; the TCP connection is simply gone. The fix is one of two things: run an application-level ping interval shorter than the LB's idle timeout so bytes are always flowing before its clock runs out, or configure the LB's own idle timeout higher specifically for WebSocket-upgraded connections.
Why must client-to-server WebSocket frames be masked, but server-to-client frames never are?
An LB's idle timeout kills a quiet WebSocket connection. What does that actually look like from the client's side?
HTTP Methods
| Method | Idempotent | Safe | Body | Typical use |
|---|---|---|---|---|
| GET | Yes | Yes | No | Read resource |
| POST | No | No | Yes | Create / trigger action |
| PUT | Yes | No | Yes | Full replace (create or overwrite) |
| PATCH | No | No | Yes | Partial update |
| DELETE | Yes | No | No | Delete resource |
| HEAD | Yes | Yes | No | Get headers only (check if modified) |
| OPTIONS | Yes | Yes | No | CORS preflight, list allowed methods |
Idempotent = calling it N times has same effect as calling it once. DELETE is idempotent (deleting already-deleted resource returns 404, not an error state). POST is not — calling POST twice creates two resources.
Is DELETE idempotent? What happens if you call DELETE on the same resource twice in a row?
Important HTTP Headers
Request headers
Host: api.example.com # required in HTTP/1.1+, which virtual host
Authorization: Bearer <token> # auth credentials
Content-Type: application/json # body format
Accept: application/json # what formats client accepts
Accept-Encoding: gzip, br # compression algorithms client supports
User-Agent: Mozilla/5.0... # client identification
X-Request-ID: uuid # for distributed tracing
Cookie: session=abc123 # client sends stored cookies
Response headers
Content-Type: application/json # body format
Content-Encoding: gzip # body is compressed
Cache-Control: max-age=3600, public # cache for 1 hour
ETag: "abc123" # content hash for conditional requests
Last-Modified: Wed, 21 Oct 2024 # when resource last changed
Set-Cookie: session=abc; Secure; HttpOnly; SameSite=Strict
X-RateLimit-Limit: 100 # rate limit max
X-RateLimit-Remaining: 87 # requests left
Strict-Transport-Security: max-age=31536000; includeSubDomains # HSTS
Access-Control-Allow-Origin: * # CORS
Caching with ETag
sequenceDiagram
participant C as Client
participant S as Server
rect rgb(220, 235, 255)
Note over C,S: Initial request
C->>S: GET /api/users
S->>C: 200 OK + ETag: "v42" + body
end
rect rgb(230, 255, 230)
Note over C,S: Later — client re-checks whether its cached copy is stale
C->>S: GET /api/users<br/>If-None-Match: "v42"
alt resource unchanged
S->>C: 304 Not Modified (no body — saves bandwidth)
Note over C: Use cached response
else resource changed
S->>C: 200 OK + new ETag + new body
Note over C: Replace cached response
end
end
The client sends If-None-Match: "v42" and the server replies 304 Not Modified with no body. What did that save, and why does the server still need to compute the ETag to answer?
If-None-Match value; if they match, nothing has changed since the client last fetched it and the cached copy is safe to reuse.
CORS — Cross-Origin Resource Sharing
sequenceDiagram
participant BROWSER as Browser (app.example.com)
participant API as API (api.other.com)
rect rgb(255, 240, 200)
Note over BROWSER,API: Preflight — for non-simple requests (POST + JSON)
BROWSER->>API: OPTIONS /api/data<br/>Origin: https://app.example.com<br/>Access-Control-Request-Method: POST<br/>Access-Control-Request-Headers: Authorization
API->>BROWSER: 200 OK<br/>Access-Control-Allow-Origin: https://app.example.com<br/>Access-Control-Allow-Methods: GET, POST, PUT<br/>Access-Control-Allow-Headers: Authorization<br/>Access-Control-Max-Age: 86400
Note over BROWSER: Browser checks the actual request against these allow-lists before sending it
end
rect rgb(220, 255, 220)
Note over BROWSER,API: Actual request — only sent if the preflight checks passed
BROWSER->>API: POST /api/data<br/>Origin: https://app.example.com
API->>BROWSER: 200 OK + Access-Control-Allow-Origin
end
fetch() with a POST + JSON body to a different origin (api.other.com) — this isn't a "simple request," so the browser won't send it directly.
OPTIONS with Origin, Access-Control-Request-Method, and Access-Control-Request-Headers, asking the server "would you allow this?"
Access-Control-Allow-Origin, -Methods, -Headers, and an optional Access-Control-Max-Age that tells the browser how long it can cache this answer.
POST /api/data with the Origin header attached.
Access-Control-Allow-Origin is present and matches, the browser hands the response to JS.
Access-Control-Max-Age: 86400 cached, the next matching request within 24 hours skips steps 2–4 entirely and goes straight to step 5.
A page on app.example.com sends a GET request to api.other.com with a custom Authorization header. Does the browser need to send a CORS preflight first?
Authorization header takes the request out of the simple-request category — any request carrying an Authorization header, a Content-Type: application/json body, or a non-simple method (PUT/DELETE/PATCH) triggers a preflight OPTIONS check first.
Simple requests (no preflight): GET/HEAD/POST with Content-Type: text/plain or application/x-www-form-urlencoded or multipart/form-data.
Non-simple (needs preflight): Any request with Authorization header, Content-Type: application/json, or methods like PUT/DELETE/PATCH.