HTTP — Client-Server Communication, Versions, Methods, Headers

0/0 checks

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:

1. TLS 1.2 — ClientHello. Client sends its supported cipher suites and a random value. No key material yet.
2. TLS 1.2 — ServerHello + Certificate + ServerKeyExchange + Done. The server picks a cipher suite and sends its certificate in the clear — visible to anyone sniffing the connection.
3. TLS 1.2 — Client verifies, then replies. The client checks the certificate, generates a pre-master secret, and sends ClientKeyExchange + ChangeCipherSpec + Finished. That's round trip 1 spent.
4. TLS 1.2 — Server confirms. The server replies with its own ChangeCipherSpec + Finished. Only now, after 2 full round trips, can the first HTTP byte go out.
5. TLS 1.3 — ClientHello + key_share. The client sends its ECDHE public key in the same first message as its ClientHello — it doesn't wait to be asked.
6. TLS 1.3 — Server derives keys immediately. With the client's key_share already in hand, the server computes the shared secret right away and replies with ServerHello + its own key_share + Certificate (now encrypted, unlike TLS 1.2) + Finished — all in one flight.
7. TLS 1.3 — Client finishes and sends HTTP in the same flight. The client derives the same keys, verifies the certificate, and sends Finished together with the actual GET /page request — encrypted, in a single round trip.
8. Net result. TLS 1.2 needs 2 round trips before any HTTP data moves; TLS 1.3 needs 1. At 50ms RTT that's the difference between 100ms and 50ms before the first byte of every fresh HTTPS connection.

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
Text-based, one TCP connection per in-flight request. 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.
Binary framing over a single TCP connection. Requests and responses are split into 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.
QUIC over UDP instead of TCP. Each stream gets independent, in-order delivery inside QUIC, so a lost packet only stalls the one stream it belongs to — the TCP-level head-of-line blocking that HTTP/2 still has is gone. QUIC also folds the transport and TLS handshakes together for 0-RTT resumption on reconnect, and supports connection migration — a client can switch networks (Wi-Fi → cellular) mid-connection without dropping it, since the connection is identified by a connection ID rather than an IP/port tuple. Don't read "QUIC over UDP" as "HTTP/2 with a different transport bolted on," though — QUIC is a genuinely separate transport protocol with its own congestion control and loss recovery, standardized independently of HTTP itself. See the wire-level deep dive below for why that distinction matters.

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.

1. Page load starts. The browser needs 5 resources from the same host and opens up to 6 TCP connections to fetch them in parallel.
2. Fast resources finish quickly. /index.html, /style.css, and /image.png each get their own connection and complete normally.
3. One resource is slow. /app.js takes 100ms and holds Conn 3 the whole time — nothing else is wrong with the network, that connection is just busy.
4. The 6-connection limit bites. A 6th resource has nowhere to go — every connection is either in use or already used, so it queues behind Conn 3 even though Conn 1, 2, and 5 already finished and now sit idle.
5. HTTP/2 avoids this specific problem. All 5 resources become streams multiplexed on one TCP connection. The slow /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.
6. But HTTP/2's fix has a limit. If a packet is lost anywhere on that single TCP connection, TCP won't deliver any of the bytes behind it — including bytes for streams that have nothing to do with the lost packet — until the retransmit arrives. That's TCP-level HOL blocking, and it's the reason HTTP/3 exists.

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.

normal slow lost / dropped packet waiting (blocked)

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:

Two handshakes run back to back on two different layers. TCP's SYN/SYN-ACK/ACK (1 RTT) carries zero cryptographic material — it just establishes an ordered byte stream. Only once that's done can TLS 1.3's ClientHello/ServerHello+Finished (1 RTT) run on top of it. 2 RTTs total before the first HTTP byte.
There's no "connection first, then encrypt" step to pay for twice. The client's very first UDP packet carries both QUIC's transport parameters and a TLS 1.3 ClientHello with key_share together; the server's reply carries both the transport acknowledgment and ServerHello/Finished together. 1 RTT total, because it's one handshake doing both jobs at once, not two stacked ones.
Reconnecting to a server the client already holds a valid session ticket for, the client sends transport parameters and actual application data in that first UDP packet — the server doesn't have to reply before real bytes start moving. 0 RTT, at the same replay-risk tradeoff TLS 1.3's 0-RTT resumption already carries elsewhere in this file.

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.

ALB/nginx got an invalid response from the upstream — the app crashed mid-response or spoke the wrong protocol. The upstream said something, just not something usable.
The service is down or overloaded — the upstream refused the connection outright. Nothing answered at all.
The upstream timed out — it's alive and accepted the connection, it's just too slow to respond in time.

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?


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
1. Browser wants to make a non-simple request. JS calls 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.
2. Browser auto-sends a preflight OPTIONS request. No application code triggers this — the browser itself sends OPTIONS with Origin, Access-Control-Request-Method, and Access-Control-Request-Headers, asking the server "would you allow this?"
3. Server answers with its allow-lists. The response carries Access-Control-Allow-Origin, -Methods, -Headers, and an optional Access-Control-Max-Age that tells the browser how long it can cache this answer.
4. Browser checks the answer — locally, before sending anything real. Is the actual origin, method, and header set covered by what the server just allowed? If not, the real request is never sent and JS sees a CORS error.
5. Checks pass — the actual request goes out. Browser sends the real POST /api/data with the Origin header attached.
6. Server responds normally. As long as Access-Control-Allow-Origin is present and matches, the browser hands the response to JS.
7. Max-Age skips the repeat. With 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?

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.