An asynchronous HTTP/1.1 server can handle many connections concurrently while still serializing work within each connection. Send a slow request followed by a fast request over the same persistent connection, and the fast response remains trapped behind the slow one. Send those requests over separate connections, and the fast response can arrive immediately.
That behavior is head-of-line blocking: one unit of work at the front of an ordered sequence delays independent work behind it.
HTTP/2 addresses this problem at the application layer by dividing one connection into independently identified streams. HTTP/3 goes further by running over QUIC, whose transport-level streams avoid the cross-stream blocking imposed by TCP's single ordered byte stream.
The differences are easier to understand when reduced to their scheduling and framing mechanisms. The examples below are not implementations of HTTP/2 or QUIC. They isolate the ordering constraint each protocol changes.
Reproducing the HTTP/1.1 Limitation
HTTP/1.1 supports persistent connections and request pipelining. A client may send another request without waiting for the previous response, but the server must return responses in request order. Responses do not carry request or stream identifiers, so the client associates the first response with the first request, the second response with the second request, and so on.
The following client pipelines a slow request and a fast request on one connection:
writer.write(b"GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n")
writer.write(b"GET /fast HTTP/1.1\r\nHost: localhost\r\n\r\n")
await writer.drain()
The observed timing is:
== one connection, /slow then /fast pipelined ==
/slow response arrived at t=1.00s -> HTTP/1.1 200 OK
/fast response arrived at t=1.00s -> HTTP/1.1 200 OK
== two separate connections, same two requests ==
/fast response arrived at t=0.00s
/slow response arrived at t=1.00s
On the persistent connection, /fast cannot produce a visible response before /slow. On two connections, each connection has its own ordering sequence, so the fast request is unaffected.
There are two related effects in a minimal server. A simple connection loop reads one request, awaits its handler, writes the response, and only then reads the next request. In that implementation, /fast is not dispatched until /slow completes. A more sophisticated HTTP/1.1 server could parse and execute pipelined requests concurrently, but it would still have to buffer completed responses and transmit them in request order. Either design exposes the same protocol constraint to the client.
This is why HTTP/1.1 clients historically opened several parallel TCP connections per origin. Browsers commonly used limits around six connections, although the exact policy varied by browser and changed over time. Multiple connections created several independent ordering lanes, reducing the chance that one slow response would delay every other resource. The workaround added connection setup, congestion-control state, memory use, and competition between parallel TCP flows.
HTTP/2 Adds Logical Streams
HTTP/2 replaces HTTP/1.1's textual message stream with binary frames. Every request and response belongs to a logical stream, and each frame includes a stream identifier. Frames from several streams can therefore be interleaved on one TCP connection and reassembled independently by the receiver.
The stream identifier removes the ambiguity that forces HTTP/1.1 responses into request order. If stream 1 is producing a large or slow response while stream 3 has a small response ready, the server may transmit stream 3's frames first or interleave them with stream 1's frames.
The core mechanism can be illustrated with a queue standing in for a shared connection:
class MiniConnection:
def __init__(self):
self.wire = asyncio.Queue()
async def send_frame(self, stream_id, chunk, end_stream=False):
await self.wire.put((stream_id, chunk, end_stream))
await asyncio.sleep(0) # let other stream producers interleave their frames too
Two producers place tagged chunks on the same queue. One response is generated slowly; the other completes in two frames:
wire: frame for stream 2: b'HTTP/1.1 200'
wire: frame for stream 2: b' DONE'
wire: frame for stream 1: b'HTTP/1.1 200'
wire: frame for stream 1: b' slow-part-1'
wire: frame for stream 1: b' slow-part-2'
wire: frame for stream 1: b' DONE'
reassembled:
stream 2: b'HTTP/1.1 200 DONE'
stream 1: b'HTTP/1.1 200 slow-part-1 slow-part-2 DONE'
The payload strings are illustrative rather than valid HTTP/2 messages; real HTTP/2 uses binary HEADERS and DATA frames. The relevant property is the stream ID. Stream 2 can complete while stream 1 is still active because the receiver no longer depends on global response order to interpret the bytes.
Multiplexing made one connection capable of carrying many concurrent exchanges and removed much of the motivation for a pool of HTTP/1.1 connections. It also enabled stream prioritization and flow control, although real-world prioritization behavior has evolved across clients and servers.
HTTP/2 introduced HPACK alongside multiplexing. HPACK compresses repeated header fields using static and dynamic tables, substantially reducing redundant metadata such as cookies and user-agent values. Header compression improves efficiency, but it is separate from the stream framing that removes HTTP/1.1 application-layer head-of-line blocking.
The Ordering Constraint Moves Down to TCP
HTTP/2 streams are independent within the HTTP layer, but every frame is still encoded into one TCP byte stream.
TCP guarantees reliable, ordered delivery. If a TCP segment is lost, later bytes may already be present in the receiver's network stack, but TCP cannot expose them to the application until the missing range has been retransmitted. HTTP/2 never gets a chance to inspect those later frames, even when they belong to an unrelated stream.
This is transport-level head-of-line blocking. The HTTP/2 multiplexer knows that its streams are independent; TCP does not. From TCP's perspective, the entire connection is one ordered sequence of bytes.
The distinction matters:
- HTTP/1.1 head-of-line blocking comes from ordered responses within the application protocol.
- HTTP/2 removes that constraint by identifying and interleaving streams.
- TCP loss can still prevent HTTP/2 from receiving available data for every stream on the connection.
HTTP/2 still provides significant benefits. On stable networks, multiplexing, header compression, and connection reuse can materially improve page loading and API behavior. Its remaining limitation appears when packet loss creates a gap in the underlying TCP stream.
HTTP/3 Moves Streams into the Transport
HTTP/3 runs over QUIC rather than TCP. QUIC uses UDP datagrams as its substrate but implements reliable delivery, congestion control, encryption, connection management, and multiplexed streams in user space.
Because QUIC understands streams, an ordering gap in one stream does not prevent the application from receiving contiguous data from another stream. If data for stream A is lost, stream A waits for the missing bytes; already available data for stream B can continue to the HTTP/3 layer.
This does not eliminate every form of interference. Ordering still applies within an individual stream, and streams commonly share connection-level congestion control and network capacity. Packet loss may reduce the sending rate for the connection as a whole. QUIC specifically removes TCP's cross-stream delivery-order dependency: missing bytes in one stream do not mechanically withhold complete bytes from another.
QUIC also changes connection management in several useful ways:
- TLS 1.3 is integrated into the protocol rather than layered over a completed TCP handshake.
- Connection IDs allow a connection to survive some network-path changes, such as a device moving between Wi-Fi and cellular networks.
- A previously established connection may use 0-RTT data, subject to server policy and replay-safety restrictions.
- Transport behavior can evolve without waiting for operating-system TCP stacks to implement a new protocol version.
0-RTT is not a universal zero-latency mode. It applies only to resumed connections, can be rejected by the server, and carries replay risk. Applications should send only replay-safe requests until the handshake is confirmed.
Comparing the Protocols
| Property | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| Transport | TCP | TCP | QUIC over UDP |
| Multiplexing | No independent streams; pipelined responses remain ordered | Stream-ID-tagged frames | Streams implemented by QUIC and used by HTTP/3 |
| Application-layer response blocking | Present within a connection | Removed across streams | Removed across streams |
| Cross-stream transport ordering | TCP can block the entire connection after loss | TCP can block the entire connection after loss | Loss in one stream does not block delivery on another stream |
| Header compression | Repeated textual headers | HPACK | QPACK |
| Traditional concurrency strategy | Several parallel connections | Primarily one multiplexed connection per origin | Primarily one multiplexed connection per origin |
QPACK is not simply HPACK renamed for HTTP/3. HPACK relies on the ordered HTTP/2 connection when synchronizing dynamic header state. QUIC streams may be delivered independently, so QPACK separates encoder and decoder instructions in a way designed to limit header-decoding blockage while preserving compression.
In the Real World
Head-of-line blocking is most visible when many independent resources share limited connection capacity or when a multiplexed connection experiences loss.
Under HTTP/1.1, a browser can distribute requests across several connections, but each connection remains an ordered lane. Slow responses occupy those lanes, and additional connections bring their own setup and transport costs.
Under HTTP/2, streams share one connection and application responses can complete independently. A slow API response no longer has to delay an unrelated small asset merely because its request arrived first. Packet loss can still pause delivery across all streams until TCP fills the missing byte range.
Under HTTP/3, QUIC confines that ordering dependency to the affected stream. This can improve responsiveness on lossy or frequently changing networks, particularly mobile paths. The result is workload- and network-dependent: QUIC does not make loss free, and HTTP/3 is not automatically faster for every connection.
The progression from HTTP/1.1 to HTTP/3 is therefore not a sequence of failed attempts at the same fix. Each version changes the layer that still imposes unnecessary ordering:
- HTTP/1.1 binds responses to connection-wide order.
- HTTP/2 introduces application-layer streams but carries them through one ordered TCP byte stream.
- HTTP/3 uses a transport that preserves ordering within each stream without imposing that order across unrelated streams.
The governing design principle is simple: preserve ordering where correctness requires it, and avoid extending that dependency to work that could progress independently.
Top comments (0)