From HTTP/1.1 to HTTP/3: Head-of-Line Blocking, Multiplexing, QUIC, WebSocket and SSE
Key takeaways
HTTP evolved from a simple text protocol to a binary, multiplexed, encrypted-by-default transport. This guide explains what changed between HTTP versions, why the changes matter for performance, and when to use WebSocket or SSE.
HTTP Version Timeline
HTTP started as a minimal protocol for fetching HTML documents and has evolved into the backbone of modern real-time applications. Each version was driven by concrete performance problems with the previous one.
HTTP/0.9 (1991): GET only, no headers, HTML responses only
HTTP/1.0 (1996): Headers, status codes, POST — new connection per request
HTTP/1.1 (1997): Persistent connections, chunked transfer, Host header
HTTP/2 (2015): Binary, multiplexed, header compression, server push
HTTP/3 (2022): QUIC transport (UDP), eliminates TCP head-of-line blocking
HTTP/1.1 Limitations
HTTP/1.1’s core problem is head-of-line blocking: only one request can be in-flight per connection at a time. While one response is waiting, everything else queues behind it. Browsers work around this by opening 6–8 parallel TCP connections per domain, but each connection requires its own TCP and TLS handshake — expensive on high-latency networks.
The headers situation is also bad. Every HTTP/1.1 request repeats the full set of headers (User-Agent, Accept, Cookies, etc.) — often 800 bytes or more — even when nothing has changed. On a page with 50 requests, that’s 40KB of redundant header data.
HTTP/1.1 connection lifecycle:
1. TCP handshake (1 RTT before the client can send)
2. TLS handshake (1 RTT with TLS 1.3, 2 RTT with TLS 1.2)
3. Request → Response
4. Next request waits (head-of-line blocking)
Workarounds browsers use:
- 6-8 parallel TCP connections per domain
- Domain sharding (cdn1.example.com, cdn2.example.com)
- Resource bundling (all JS in one file to reduce requests)
- Sprite sheets (combine images)
HTTP/1.1 request:
GET /index.html HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0
Accept: text/html
[~800 bytes of headers, uncompressed, repeated every request]
To put the handshake cost in numbers: on a mobile connection with a 100 ms round-trip time, a fresh HTTPS connection over TCP + TLS 1.2 spends about 300 ms before the first request byte is even sent. Opening six such connections in parallel does not multiply that delay, but it does multiply the server’s TLS work and, more importantly, each new connection starts in TCP slow start, sending only a small window of data until it has proven the path can take more. Many short-lived connections never get out of slow start, which is why HTTP/1.1 pages with many small resources felt slow even on fast links.
The workarounds in the list above were rational at the time and became anti-patterns later. Domain sharding forces extra DNS lookups and TLS handshakes and defeats HTTP/2’s single-connection model; very large bundles mean a one-line change invalidates the whole cached file. When a site moves to HTTP/2, undoing sharding is usually the first quick win.
HTTP/2: Multiplexing
HTTP/2 was designed to fix HTTP/1.1’s fundamental problems at the protocol level rather than relying on browser workarounds. The key innovation is multiplexing: multiple requests and responses flow simultaneously over a single TCP connection as independent streams. There’s no waiting — stream 3 doesn’t block because stream 1 is still loading.
HTTP/2 also switches from text to binary encoding, which is more compact and faster to parse. Headers are compressed with HPACK, which remembers which headers were sent before and only transmits the differences. On subsequent requests in a session, headers shrink from ~800 bytes to a handful of bytes.
HTTP/2 connection model:
One TCP connection
Stream 1: GET /api/user → streaming response
Stream 3: GET /api/posts → streaming response (parallel!)
Stream 5: POST /api/log → streaming response (parallel!)
All streams share one connection
No head-of-line blocking at HTTP layer (TCP still can block)
HTTP/2 features:
- Binary framing: requests/responses as binary frames (not text)
- Multiplexing: multiple streams on one connection
- Header compression (HPACK): common headers compressed, not repeated
- Server push: server sends assets before client requests them (since abandoned by browsers)
- Stream prioritization: important resources first (the original tree-based scheme was replaced by the simpler RFC 9218 priorities)
HPACK is a deliberate design choice, not just “gzip for headers”. Compressing headers with a general-purpose compressor, as the earlier SPDY protocol did, turned out to leak secrets through the CRIME attack: an attacker who can inject text into requests can guess a cookie byte by byte by watching the compressed size. HPACK instead uses a static table of common headers, a per-connection dynamic table of previously sent headers, and Huffman coding for literals, which avoids that class of leak. The dynamic table is also why HTTP/2 connections are stateful in a way HTTP/1.1 connections are not — a proxy cannot simply forward frames from one connection to another.
Multiplexing also changes what “a connection” means operationally. One connection now carries all of a user’s traffic to that host, so connection-level limits matter: SETTINGS_MAX_CONCURRENT_STREAMS (often 100–128) caps parallel requests, and a load balancer that balances connections rather than requests can pin a busy client to one backend. That is a common surprise with gRPC, which runs on HTTP/2: a few long-lived client connections stay on the same few servers regardless of how many backends you add.
Checking HTTP/2
# curl shows protocol version
curl -I --http2 https://example.com
# HTTP/2 200
# content-type: text/html
# Chrome DevTools → Network → Protocol column
# h2 = HTTP/2, h3 = HTTP/3, http/1.1 = HTTP/1.1
# Browser console
performance.getEntriesByType('navigation')[0].nextHopProtocol
# "h2" or "h3"
Server Push (deprecated in practice)
# Nginx: push assets with the main HTML
location = /index.html {
http2_push /styles.css;
http2_push /app.js;
}
Note: Server push was removed from Chrome and most browsers have deprecated it — it caused over-pushing cached resources. Use <link rel="preload"> instead.
HTTP/2 with Nginx
server {
listen 443 ssl;
http2 on; # nginx 1.25.1+; older versions: listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; # HTTP/2 works with TLS 1.2+
location / {
proxy_pass http://backend;
}
}
On nginx 1.25.1 and later, the http2 parameter on listen still works but logs a deprecation warning (“the “listen … http2” directive is deprecated, use the “http2” directive instead”). Note also that this configuration speaks HTTP/2 only to clients; proxy_pass talks HTTP/1.x to the backend (HTTP/1.0 by default, unless you set proxy_http_version 1.1). That is usually fine — the latency benefits of HTTP/2 matter on the long, lossy client link, not on a data-center link with sub-millisecond RTT. Browsers negotiate HTTP/2 during the TLS handshake via ALPN, so a server whose TLS library lacks ALPN support silently falls back to HTTP/1.1.
HTTP/3 and QUIC
The TCP head-of-line blocking problem:
HTTP/2 over TCP:
Packet 1 (Stream 1 data) — lost!
Packet 2 (Stream 3 data) — waiting for packet 1
Packet 3 (Stream 5 data) — waiting for packet 1
All streams blocked until TCP retransmits packet 1
HTTP/3 over QUIC:
Packet 1 (Stream 1 data) — lost, QUIC retransmits
Packet 2 (Stream 3 data) — delivered! Stream 3 continues
Packet 3 (Stream 5 data) — delivered! Stream 5 continues
Only Stream 1 waits for retransmission
QUIC advantages:
- 1-RTT handshake for new connections (transport and TLS 1.3 combined), 0-RTT on repeat connections
- Connection migration — connection survives IP change (WiFi → mobile)
- Independent streams — packet loss only blocks affected stream
- Built on UDP — implemented in user space, so it can evolve without OS kernel upgrades
The head-of-line problem in HTTP/2 is subtle: TCP guarantees in-order delivery of a single byte stream, and it has no idea that bytes belong to different HTTP streams. A single lost packet stalls everything behind it until the retransmission arrives. On a clean network this is rare; on a lossy mobile link with 1–2% packet loss, HTTP/2’s single connection can actually perform worse than HTTP/1.1’s six, because one loss stalls all requests instead of one sixth of them. QUIC fixes this by moving streams into the transport layer, so ordering is per stream.
The trade-offs are practical rather than theoretical. QUIC is typically more CPU-expensive per byte than TCP, because TCP benefits from decades of kernel and NIC offload optimizations while QUIC encrypts every packet in user space. Some corporate networks and firewalls block or rate-limit UDP 443, which is why every client keeps HTTP/2 as a fallback. And 0-RTT data can be replayed by an attacker who captures it, so servers must accept only idempotent requests (such as GET) in 0-RTT; a POST that places an order must never be processed from early data.
HTTP/3 Support
# Cloudflare, Fastly, Google, Akamai all support HTTP/3
# Check: curl --http3 https://cloudflare.com -I
# Nginx: HTTP/3 support in nginx 1.25+
listen 443 quic reuseport;
listen 443 ssl;
ssl_protocols TLSv1.3; # QUIC requires TLS 1.3
add_header Alt-Svc 'h3=":443"; ma=86400'; # Advertise HTTP/3
WebSocket — Bidirectional Real-Time
WebSocket upgrades an HTTP connection to a persistent, bidirectional channel.
Client → Server:
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Server → Client:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
[Connection upgraded — now both sides can send at any time]
Sec-WebSocket-Accept is the base64 SHA-1 of the client’s key concatenated with a fixed GUID. It is not security; it only proves the server understood the WebSocket handshake, so a plain HTTP cache or server cannot accidentally “accept” the upgrade. Authentication has to happen separately — usually with a cookie or a token sent during the handshake — and the server should check the Origin header, because browsers do not apply CORS to WebSocket connections. A page on any site can open a WebSocket to your server with the user’s cookies attached (cross-site WebSocket hijacking) unless you reject unexpected origins.
WebSocket Server (Node.js)
import { WebSocketServer, WebSocket } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set<WebSocket>();
wss.on('connection', (ws, req) => {
clients.add(ws);
console.log(`Client connected. Total: ${clients.size}`);
ws.on('message', (data) => {
const message = data.toString();
console.log('Received:', message);
// Broadcast to all connected clients
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
});
ws.on('close', () => {
clients.delete(ws);
console.log(`Client disconnected. Total: ${clients.size}`);
});
ws.on('error', (err) => {
console.error('WebSocket error:', err);
clients.delete(ws);
});
// Send welcome message
ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
});
The clients set duplicates something ws already tracks (wss.clients), but keeping it explicit makes the lifecycle visible. Two things this server does not handle yet are the ones that bite in production. Dead connections: when a phone loses signal, no close event arrives for a long time, because TCP has nothing to send and so never notices. The ws documentation’s answer is a heartbeat — call ws.ping() every 30 seconds, mark the client alive on pong, and terminate() those that did not answer. Backpressure: client.send() queues data in memory if the client reads slowly, and ws.bufferedAmount grows without limit; a broadcast loop to thousands of clients should skip or disconnect clients whose buffer exceeds a threshold. Finally, this state lives in one process, so with several server instances behind a load balancer a broadcast reaches only the clients on the same instance — the usual fix is a pub/sub layer such as Redis between instances.
WebSocket Client (Browser)
let ws: WebSocket;
let retries = 0;
function connect() {
ws = new WebSocket('wss://example.com/ws'); // wss = secure WebSocket
ws.onopen = () => {
console.log('Connected');
retries = 0;
ws.send(JSON.stringify({ type: 'join', room: 'main' }));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
};
ws.onclose = (event) => {
console.log('Disconnected:', event.code, event.reason);
if (event.code === 1000) return; // closed on purpose — don't reconnect
// Auto-reconnect with capped backoff
setTimeout(connect, 1000 * Math.min(++retries, 30));
};
ws.onerror = (error) => {
console.error('WebSocket error:', error); // onclose follows
};
}
connect();
// Send only when the socket is open
function send(payload: unknown) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
// Close gracefully
ws.close(1000, 'User logged out');
Calling send() right after new WebSocket(...) is a classic mistake: the connection is still in the CONNECTING state, and the browser throws InvalidStateError: Failed to execute 'send' on 'WebSocket': Still in CONNECTING state. Sending in onopen or checking readyState avoids it. The browser’s WebSocket has no built-in reconnect, so the reconnection loop is your responsibility. In production, add random jitter to the delay: when a server restarts, every client disconnects at the same moment, and without jitter they all reconnect at the same moment too, which can knock the server over again.
WebSocket with Nginx
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s; # Long timeout for persistent connections
proxy_send_timeout 86400s;
}
Upgrade and Connection are hop-by-hop headers, which proxies strip by default; without the two proxy_set_header lines the backend never sees the upgrade request and answers with a normal HTTP response, and the browser reports “WebSocket connection failed: Error during WebSocket handshake: Unexpected response code: 200” (or 400). proxy_http_version 1.1 is required because the upgrade mechanism does not exist in HTTP/1.0, nginx’s default for upstream connections. The long proxy_read_timeout is a workaround; the more robust approach is a shorter timeout plus the ping/pong heartbeat described above, so idle connections are kept alive by traffic and truly dead ones are cleaned up.
Server-Sent Events (SSE)
SSE streams events from server to client over a regular HTTP connection. Simpler than WebSocket when you only need server → client:
// Express SSE endpoint
app.get('/events', (req, res) => {
// Required headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Send event helper
const sendEvent = (event: string, data: any, id?: string) => {
if (id) res.write(`id: ${id}\n`);
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Send initial data
sendEvent('connected', { timestamp: Date.now() });
// Send periodic updates
const interval = setInterval(() => {
sendEvent('update', { price: getStockPrice(), time: Date.now() });
}, 1000);
// Cleanup when client disconnects
req.on('close', () => {
clearInterval(interval);
res.end();
});
});
// Browser client — EventSource API
const eventSource = new EventSource('/events');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Update:', data);
};
eventSource.addEventListener('update', (event) => {
updatePriceDisplay(JSON.parse(event.data));
});
// EventSource auto-reconnects on disconnect
// Last-Event-ID header resumes from last received event
eventSource.close(); // Stop listening
The wire format is plain text: each event is a block of field: value lines ended by a blank line, which is why sendEvent writes \n\n at the end. onmessage only receives events without an event: field (or with event: message); named events like update must be subscribed to with addEventListener, and forgetting that is the usual reason an SSE client “receives nothing” while the network tab shows data arriving. The id: field matters for reconnection: after a drop, EventSource reconnects on its own and sends the last received id in the Last-Event-ID header, and the server should replay events after it. The server can also set the retry delay with a retry: 5000 line.
Three deployment issues come up repeatedly. Over HTTP/1.1, each EventSource holds one of the browser’s roughly six connections per host, so a user with several tabs open can exhaust them and every other request to your site hangs; serving SSE over HTTP/2 avoids this because streams share one connection. Proxy buffering: nginx buffers upstream responses by default, so events arrive in bursts or only when the buffer fills — send X-Accel-Buffering: no from the app or set proxy_buffering off for that location. And compression middleware in Express can hold back small writes until it has enough data to compress; exclude text/event-stream from compression or call res.flush() after each event.
Comparison
| Feature | HTTP/1.1 | HTTP/2 | HTTP/3 | WebSocket | SSE |
|---|---|---|---|---|---|
| Protocol | TCP | TCP | QUIC/UDP | TCP | TCP |
| Direction | Req/Res | Req/Res | Req/Res | Bidirectional | Server→Client |
| Multiplexing | ❌ | ✅ | ✅ | N/A | N/A |
| Head-of-line blocking | HTTP+TCP | HTTP only | ❌ None | N/A | N/A |
| TLS required | No | Effectively | Yes | No (ws://) | No |
| Auto-reconnect | N/A | N/A | N/A | Manual | ✅ Built-in |
| Browser support | All | All | All current browsers | All | All |
For choosing between them in practice: most applications do not choose an HTTP version at all — they enable HTTP/2 and HTTP/3 at the CDN or load balancer and gain the benefits without code changes. The real design decision is between plain requests, SSE, and WebSocket for real-time features. My default is to start with SSE for anything server-to-client (notifications, progress, live dashboards), because it is ordinary HTTP: it passes through proxies, authentication middleware, and logging unchanged, and reconnection comes for free. WebSocket earns its extra operational cost when the client sends frequent messages too — chat with typing indicators, collaborative editing, games — where issuing a separate HTTP request for every client message would add latency and overhead.
Related Articles
Frequently Asked Questions (FAQ)
Q. I enabled HTTP/3 in Nginx, but DevTools still shows h2. Is it broken?
A. Not necessarily. Browsers usually discover HTTP/3 through the Alt-Svc header on an earlier HTTP/1.1 or HTTP/2 response, so the first request often goes over TCP and only later connections switch to h3. If it never switches, check that UDP port 443 is open in your firewall or cloud security group, because QUIC runs over UDP and browsers silently fall back to HTTP/2 over TCP when the UDP packets are blocked. curl --http3 (with a curl build that supports HTTP/3) is a quick way to test the QUIC path directly.