Web Engineering ·

How HTTP/2 and HTTP/3 Work: Multiplexing, QUIC, and the Protocol Upgrades Behind Modern Web Performance

A deep dive into the protocol evolution from HTTP/1.1 to HTTP/2 to HTTP/3. Covers binary framing, multiplexed streams, HPACK compression, QUIC over UDP, 0-RTT handshakes, connection migration, and production deployment patterns for CDN and edge delivery.

How HTTP/2 and HTTP/3 Work: Multiplexing, QUIC, and the Protocol Upgrades Behind Modern Web Performance

HTTP/1.1 was designed in 1997 for a web where a page was mostly text with a handful of images. A modern page loads hundreds of assets. The gap between what HTTP/1.1 was built for and what the modern web demands explains every design decision in HTTP/2 and HTTP/3.

This article walks through each protocol in mechanical detail: what problem it solves, how the wire format works, where it still falls short, and what that means for how you deploy and debug real applications.

The HTTP/1.1 Problem: Head-of-Line Blocking and the Six-Connection Workaround

HTTP/1.1 is a text-based, request-response protocol over TCP. Each request occupies the connection until the response is fully received. The server cannot send a response out of order.

This creates head-of-line blocking at the application layer: if a slow response is in flight, every subsequent request on that connection waits. The standard workaround was to open multiple parallel TCP connections per origin, with browsers capping at six concurrent connections per host. Six connections means six TCP handshakes, six TLS handshakes (1-2 RTT each in TLS 1.2), and six streams of congestion control state, all competing for the same network path.

Developers responded with workarounds that became standard practice: domain sharding (splitting assets across static1.example.com, static2.example.com to get 12 or 18 parallel connections), CSS and JavaScript concatenation to reduce request count, image sprites to pack many images into one HTTP request, and inlining critical CSS to cut render-blocking requests.

All of these work around the protocol rather than fixing it. HTTP/2 fixes the protocol.

HTTP/2: Binary Framing and Multiplexed Streams

HTTP/2 replaces the text-based wire format with a binary framing layer. Every message is split into frames. Each frame carries a stream identifier, a type field, flags, and a payload.

The fundamental unit is a stream: an independent, bidirectional sequence of frames within a single TCP connection. Multiple streams coexist on the same connection simultaneously. The client and server interleave frames from different streams on the wire; the receiver reassembles each stream independently.

This is the core of HTTP/2 multiplexing. One TCP connection carries all requests to an origin concurrently. The six-connection limit is irrelevant. Domain sharding actively hurts HTTP/2 performance because it splits one multiplexed connection into multiple independent ones, removing the protocol’s ability to share bandwidth intelligently.

Frame Types

The key frame types to understand:

  • HEADERS: carries request or response headers (compressed with HPACK)
  • DATA: carries request or response body chunks
  • SETTINGS: negotiates connection parameters (initial window size, max concurrent streams)
  • WINDOW_UPDATE: implements per-stream and connection-level flow control
  • PRIORITY: declares stream priority (deprecated in HTTP/3, rarely effective in HTTP/2 implementations)
  • RST_STREAM: terminates a single stream without closing the connection
  • GOAWAY: initiates connection shutdown

HPACK Header Compression

HTTP headers are verbose and repetitive. A typical request carries Accept, Accept-Encoding, Accept-Language, Cookie, User-Agent, and dozens of custom headers, most of which are identical on every request in a session.

HTTP/2 compresses headers with HPACK (RFC 7541). HPACK maintains two tables on both endpoints: a static table of 61 common header name-value pairs (:method GET, :status 200, content-type text/html, etc.) and a dynamic table that grows as new headers are seen during the session.

When a header matches a dynamic table entry, HPACK encodes it as a single integer index. Headers not in the table use Huffman coding. The result: a header block that might be 800 bytes over HTTP/1.1 compresses to 50-100 bytes over HTTP/2 for a warm session.

Flow Control

HTTP/2 implements flow control at two levels: per-stream and per-connection. Each side advertises a window size (defaulting to 65,535 bytes). The sender may not send more DATA bytes than the current window allows. The receiver increments the window by sending WINDOW_UPDATE frames as it consumes data.

This prevents a fast sender from overwhelming a slow receiver, and lets the receiver apply backpressure independently on each stream. A slow upload on one stream does not reduce the window for other streams.

Server Push (and Why It Mostly Failed)

HTTP/2 server push lets the server send resources to the client before the client requests them. The server sends a PUSH_PROMISE frame on an existing stream, then opens a new server-initiated stream to deliver the resource.

The intent: the server knows that index.html always needs main.css and app.js, so it pushes them before the browser parses the HTML and discovers the references. In theory, this eliminates one RTT.

In practice, server push underdelivers for several reasons. The server cannot know what is already in the browser’s cache; pushing a cached resource wastes bandwidth and can delay the primary response. The browser can send RST_STREAM to cancel a pushed stream, but by that point the server may have already buffered the data. Conditional push logic is complex and usually wrong at the edges. Most CDNs implemented push poorly or not at all.

Chrome removed server push support in Chrome 106 (2022). HTTP/3 retains the mechanism in the spec but real-world usage is negligible.

Negotiating HTTP/2

HTTP/2 over TLS uses ALPN (Application-Layer Protocol Negotiation), a TLS extension. The client lists supported protocols in the ClientHello (h2, http/1.1). The server selects one and confirms it in the ServerHello. No extra round trips.

import * as https from "https";
import * as http2 from "http2";
import * as fs from "fs";

// Check negotiated protocol after connection
const client = http2.connect("https://example.com");

client.on("connect", (session) => {
  // session.socket.alpnProtocol is 'h2' if HTTP/2 was negotiated
  console.log("Protocol negotiated:", session.socket.alpnProtocol);
});

const req = client.request({ ":path": "/" });

req.on("response", (headers) => {
  const status = headers[":status"];
  console.log("Status:", status);
});

req.setEncoding("utf8");
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
  console.log("Response length:", body.length);
  client.close();
});

req.end();

For cleartext HTTP/2 (h2c), negotiation uses the Upgrade header, but browser support is absent and production deployments uniformly use TLS.

The Remaining Problem: TCP Head-of-Line Blocking

HTTP/2 eliminates application-layer head-of-line blocking. But it still runs over TCP, and TCP has its own head-of-line blocking problem.

TCP is a reliable, ordered byte stream. Every byte must be delivered in sequence. If a packet is lost, TCP holds back all subsequent data in its receive buffer until the lost packet is retransmitted and acknowledged. This happens at the transport layer, invisible to HTTP/2.

The consequence: a single lost packet stalls all streams on the HTTP/2 connection, because they all share one TCP byte stream. At 1% packet loss, HTTP/2 can perform worse than three independent HTTP/1.1 connections because HTTP/1.1 connections are affected independently; with HTTP/2, one loss event blocks everything.

This is the problem HTTP/3 solves.

HTTP/3 and QUIC: Moving to UDP

HTTP/3 runs over QUIC (RFC 9000) instead of TCP. QUIC is a transport protocol implemented in userspace over UDP. It provides the reliability and ordering guarantees of TCP, but applies them per stream rather than across the entire connection.

When a QUIC packet is lost, only the streams whose data was in that packet are held back waiting for retransmission. Other streams continue delivering data uninterrupted. Head-of-line blocking is solved at the transport layer.

QUIC Wire Format

QUIC packets carry one or more frames. Unlike TCP, QUIC frames are aware of streams. A STREAM frame carries a stream ID, an offset within that stream, and data. A lost STREAM frame blocks only its stream.

QUIC also integrates TLS 1.3 directly into the handshake. There is no separate TLS negotiation phase after the transport handshake: the TLS handshake messages are carried inside QUIC packets from the first exchange.

0-RTT Connection Establishment

A fresh QUIC connection completes the cryptographic handshake in 1 RTT: the client sends an Initial packet containing the TLS ClientHello, the server responds with its cryptographic material, and the client can send HTTP requests in its next flight. The server can respond before finishing validation of the client’s certificate (if any).

For repeat connections, QUIC supports 0-RTT resumption using a pre-shared key from the previous session (a session ticket, analogous to TLS 1.3 early data). The client can send application data in the very first packet, before the handshake completes.

The tradeoff: 0-RTT data is not forward secret and is replay-vulnerable. The server cannot distinguish a replayed 0-RTT request from a legitimate one. Safe use of 0-RTT restricts early data to idempotent operations. For GET requests this is generally acceptable; for POST requests that mutate state, it is not.

HTTP servers signal replay protection via the 425 Too Early status code if they receive early data they are not willing to process.

// Detecting whether a response came from 0-RTT (QUIC early data)
// In a Node.js server context using a QUIC library
import { createQuicSocket } from "net"; // conceptual — actual QUIC support varies by runtime

// Check for Early-Data header when proxying through a CDN
// Cloudflare sets CF-Cache-Status and other headers; the edge terminates QUIC
// Your origin sees a standard HTTP/2 or HTTP/1.1 connection from the CDN

function isEarlyData(req: Request): boolean {
  // Cloudflare forwards this header when the request arrived via 0-RTT
  return req.headers.get("early-data") === "1";
}

function handleRequest(req: Request): Response {
  if (req.method !== "GET" && isEarlyData(req)) {
    // Reject non-idempotent early data
    return new Response("Too Early", { status: 425 });
  }
  // Process normally
  return new Response("OK");
}

Connection Migration

QUIC connections are identified by a Connection ID chosen by each endpoint, not by the 4-tuple of source IP, source port, destination IP, destination port that identifies a TCP connection.

When a mobile client switches from Wi-Fi to cellular, its IP address changes. A TCP connection tied to the old IP is dead; the app must reconnect, re-negotiate TLS, and re-issue all in-flight requests. For HTTP/2 this typically means a second or two of interruption.

A QUIC connection survives the IP change. The client sends packets from the new IP using the same Connection ID. The server detects the address change and issues a NEW_CONNECTION_ID frame to update the mapping. Application-layer requests in flight continue without interruption.

This matters most for long-lived connections (streaming, real-time data) and mobile clients on poor networks.

HTTP/3 Framing

HTTP/3 maps HTTP semantics onto QUIC streams. Each request-response pair uses a pair of QUIC streams (one in each direction). The HTTP/3 framing layer is similar to HTTP/2 but adapted for QUIC: HEADERS and DATA frames exist, but flow control is delegated to QUIC’s stream-level flow control rather than HTTP-level WINDOW_UPDATE frames.

Header compression uses QPACK (RFC 9204) instead of HPACK. HPACK’s dynamic table required in-order delivery (a constraint QUIC streams don’t provide), so QPACK uses two separate QUIC streams for encoder and decoder instructions. The result is equivalent compression with QUIC’s out-of-order delivery model.

Tradeoffs Table

DimensionHTTP/1.1HTTP/2HTTP/3
TransportTCPTCPQUIC (UDP)
HOL blockingApplication + TCPTCP onlyNone
Connections per origin6 (browsers)11
Header compressionNoneHPACK (dynamic table)QPACK
Handshake cost (cold)2+ RTT (TLS 1.2)2+ RTT (TLS 1.2)1 RTT
Handshake cost (warm)1 RTT (TLS 1.3 session resumption)1 RTT0 RTT
Connection migrationNoNoYes (Connection ID)
Middlebox compatibilityExcellentGood (ALPN required)Variable (UDP blocked in some networks)
Server pushNoYes (deprecated in practice)Yes (effectively unused)
Congestion controlKernel TCP stackKernel TCP stackUserspace (per-implementation)
Debugging toolingExcellentGoodMaturing (curl, Chrome DevTools)
UDP firewall riskNoneNoneReal (UDP port 443 sometimes blocked)

Production Considerations

Enabling HTTP/3 on Cloudflare and CDNs

Cloudflare enables HTTP/3 with a toggle in the Speed settings panel. Behind that toggle: Cloudflare’s edge terminates QUIC connections and proxies to your origin over HTTP/2 or HTTP/1.1. Your origin does not need to speak QUIC.

The edge serves the Alt-Svc response header to advertise QUIC support:

Alt-Svc: h3=":443"; ma=86400

h3 is the ALPN token for HTTP/3 over QUIC. ma=86400 is the max-age in seconds. On the next request, a compatible client will attempt QUIC on port 443. If it fails (UDP blocked, packet loss too high), the client falls back to HTTP/2 on TCP.

For self-hosted deployments, nginx and caddy both support HTTP/3 (QUIC). Caddy enables it by default when TLS is configured. For nginx, you need a build compiled with --with-http_v3_module (available since nginx 1.25.0) and the appropriate listen directive:

server {
    listen 443 quic reuseport;
    listen 443 ssl;

    ssl_certificate     /etc/ssl/cert.pem;
    ssl_certificate_key /etc/ssl/key.pem;
    ssl_protocols       TLSv1.3;

    add_header Alt-Svc 'h3=":443"; ma=86400';

    # rest of config
}

Connection Coalescing

HTTP/2 and HTTP/3 both support connection coalescing: if two origins share the same IP address and the TLS certificate covers both hostnames, a client may reuse the existing connection rather than opening a new one.

This matters for multi-domain CDN setups. If api.example.com and static.example.com both resolve to the same Cloudflare anycast IP and use a wildcard certificate for *.example.com, requests to both domains travel over a single HTTP/2 connection. No second handshake, no second connection.

Coalescing is automatic in compliant clients, but it requires that DNS resolution produces the same IP for both origins at the time of the check. With anycast CDNs, this holds reliably. With origin IPs that vary by region or change during deployments, coalescing is unreliable.

Debugging HTTP/3 with curl

# Force HTTP/3 (requires curl built with QUIC support -- check with curl --version | grep HTTP3)
curl --http3 -v https://example.com

# Try HTTP/3 but fall back to HTTP/2 if it fails
curl --http3-prior-knowledge -v https://example.com

# Observe the negotiated protocol in the verbose output
# Look for "Using HTTP/3" in the output
curl -v --http3 https://cloudflare.com 2>&1 | grep -E "(Using HTTP|< HTTP)"

The --http3 flag sends an HTTP/2 request first and looks for the Alt-Svc header, then upgrades to QUIC on a subsequent request. --http3-prior-knowledge skips the discovery step and attempts QUIC immediately, useful when you know the server supports it.

Chrome DevTools

In Chrome DevTools, the Network panel’s Protocol column shows h3 for HTTP/3 requests and h2 for HTTP/2. Enable it by right-clicking any column header in the Network tab and selecting Protocol.

The chrome://net-export/ page (or chrome://net-internals/#quic) shows active QUIC sessions, packet statistics, and connection IDs. This is the primary debugging surface for diagnosing QUIC fallback issues, 0-RTT behavior, and connection migration events.

For a quick CLI check of what protocol a site is advertising:

// Check Alt-Svc header to discover HTTP/3 support
async function checkProtocolSupport(url: string): Promise<void> {
  const response = await fetch(url);

  const altSvc = response.headers.get("alt-svc");
  const via = response.headers.get("via");

  console.log("Alt-Svc:", altSvc ?? "(not present)");
  console.log("Via:", via ?? "(not present)");

  if (altSvc?.includes("h3")) {
    console.log("Server advertises HTTP/3 support");
    // Parse the max-age value
    const maMatch = altSvc.match(/ma=(\d+)/);
    if (maMatch) {
      console.log("Alt-Svc max-age:", maMatch[1], "seconds");
    }
  } else {
    console.log("No HTTP/3 advertisement found");
  }
}

checkProtocolSupport("https://cloudflare.com");

UDP Firewall Risks

QUIC uses UDP port 443. Some corporate networks, ISPs, and captive portals block non-TCP traffic on port 443 or rate-limit UDP at levels that break QUIC’s performance characteristics. Estimates vary, but 5-10% of real-world clients encounter environments where QUIC either fails outright or performs worse than TCP.

All major QUIC implementations handle this gracefully: after a timeout or explicit failure, the client falls back to TCP with HTTP/2. The penalty is one failed QUIC connection attempt (typically one RTT) before falling back. For most applications, this is acceptable.

If your user base is concentrated in enterprise or education networks, measure QUIC fallback rates before assuming HTTP/3 is delivering the expected gains. Cloudflare’s analytics panel reports the protocol breakdown per request; your own RUM (Real User Monitoring) data is the ground truth.

When Protocol Choice Actually Matters

The gains from HTTP/2 and HTTP/3 are not uniform. The protocols deliver the most value when:

  • A page or API response triggers many parallel sub-requests (asset-heavy pages, GraphQL with many field resolvers, microservice fan-out)
  • Clients are on high-latency or lossy networks (mobile, satellite, regions far from your CDN PoP)
  • Sessions are long-lived and warm (0-RTT and dynamic table compression pay off over time)

For a JSON API that serves one request per connection, the protocol difference is nearly zero. The connection setup cost is amortized over the lifetime of the connection; if the connection handles one request, the protocol overhead is the entire comparison.

The right framing: HTTP/2 and HTTP/3 remove protocol-level bottlenecks. If your application has other bottlenecks (slow database queries, large uncompressed payloads, cold-start latency), fixing those first will move the needle more than the protocol upgrade.

Closing

HTTP/1.1, HTTP/2, and HTTP/3 are not competing options: they represent successive generations of removing artificial constraints from the protocol layer. HTTP/1.1’s six-connection workaround and domain sharding are symptoms of HOL blocking the protocol never solved. HTTP/2 fixed the application layer but inherited TCP’s constraints. HTTP/3 fixes the transport by moving to QUIC, at the cost of UDP compatibility risk and a less mature debugging ecosystem.

For most production deployments today, HTTP/2 is the baseline and HTTP/3 is an incremental improvement delivered transparently by your CDN. The real leverage is understanding the tradeoffs well enough to configure both correctly, measure the actual protocol distribution in your traffic, and avoid cargo-culting optimizations designed for HTTP/1.1 into an HTTP/2 or HTTP/3 deployment.

More in Web Engineering

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.

How Next.js Works Internally: The Compilation Pipeline, RSC Protocol, and Caching Architecture From Request to Render
Web Engineering ·

How Next.js Works Internally: The Compilation Pipeline, RSC Protocol, and Caching Architecture From Request to Render

A deep dive into the internal architecture of Next.js App Router. Covers the SWC/Turbopack compilation pipeline, the RSC wire protocol, the four-layer caching architecture, rendering strategies, middleware execution, Server Actions, and the self-hosted vs managed deployment tradeoffs.

How the TypeScript Compiler Works: Parsing, Type Checking, and the Emit Pipeline From Source to JavaScript
Web Engineering ·

How the TypeScript Compiler Works: Parsing, Type Checking, and the Emit Pipeline From Source to JavaScript

A deep dive into TypeScript compiler internals covering the five compilation phases (scanner, parser, binder, type checker, emitter), how the Program object manages tsconfig resolution, declaration file generation, incremental compilation with .tsbuildinfo, and how this knowledge helps you debug confusing type errors and optimize build performance.

From Vibe Code to Production: Rescuing AI-Generated Codebases
Web Engineering ·

From Vibe Code to Production: Rescuing AI-Generated Codebases

A systematic methodology for taking an AI-generated codebase through triage, stabilization, and hardening. Covers the specific failure patterns that appear at scale, a security and architecture audit checklist, the stabilize-refactor-harden rescue sequence, and a decision framework for rewrite vs. refactor.