Backend Systems · Request Handling
HTTP/2 Stream Multiplexing and Flow Control on the Server
How independent streams share one TCP connection, why flow control is owed at two levels, and what per-connection compression state costs a server.
HTTP/2 replaces the line-oriented message with a binary frame stream in which requests and responses are interleaved. The connection is no longer the unit of concurrency: it is a container for streams, each carrying a request in one direction and a response in the other, and many can be open at once.
The framing change is the visible part. The consequences that matter for a server are less visible: flow control becomes an obligation the receiver discharges explicitly, header compression becomes state shared by every stream on the connection, and the connection remains a single ordered byte stream underneath, so the independence the protocol advertises is bounded by TCP.
Nine octets of frame header
Every frame begins with a fixed header: a 24-bit length, an 8-bit type, an 8-bit flags field, and a 31-bit stream identifier whose high bit is reserved. The length counts the payload, not the header, and must not exceed the maximum frame size the peer advertised, which defaults to 16 KiB and can be raised in steps up to 16 MiB minus one by a setting.
The frame types that matter for ordinary traffic are HEADERS, DATA, SETTINGS, WINDOW_UPDATE, RST_STREAM, PING, GOAWAY, and CONTINUATION. Control frames such as SETTINGS and PING carry stream identifier zero; anything else on stream zero is a protocol error. HEADERS opens a stream, DATA carries payload, and the END_STREAM flag on either marks the end of that direction.
Because a header block may exceed one frame, HEADERS without END_HEADERS must be followed by CONTINUATION frames on the same stream, and no other frame may appear between them. A server that interleaves other work into that gap produces a connection error, and one that cannot buffer an arbitrarily long header block has to enforce a limit on the assembled size rather than on each frame.
import struct
def recv_exact(sock, n):
buf = b""
while len(buf) < n:
part = sock.recv(n - len(buf))
if not part:
raise ConnectionError("truncated frame")
buf += part
return buf
def read_frame(sock, max_frame=16384):
hdr = recv_exact(sock, 9)
length = (hdr[0] << 16) | (hdr[1] << 8) | hdr[2]
if length > max_frame:
raise ConnectionError("frame exceeds SETTINGS_MAX_FRAME_SIZE")
ftype, flags = hdr[3], hdr[4]
stream_id = struct.unpack("!I", hdr[5:9])[0] & 0x7FFFFFFF
return ftype, flags, stream_id, recv_exact(sock, length)
Stream identity and lifetime
Streams opened by the client use odd identifiers, allocated in increasing order, and an identifier is never reused on the connection. The practical consequence is that a long-lived connection has a finite supply: after 2^31 - 1 client-initiated streams the client must open a new connection. That limit is far away in ordinary traffic and reachable in a service that makes millions of small requests over one connection.
The lifetime is a state machine with half-closed states. A stream becomes half-closed when one side sends END_STREAM, and fully closed when both have, or when either side sends RST_STREAM. A server that responds and then continues reading is in the half-closed state that permits it; a server that responds before reading the request body must tolerate the client continuing to send until it sees the reset.
The peer declares how many streams it will accept concurrently through SETTINGS_MAX_CONCURRENT_STREAMS, and a server applies its own limit in the other direction. This setting is the natural place to bound request concurrency per connection, and it is more useful than a connection cap for a client that would otherwise multiplex everything onto one connection: a limit of 100 streams means at most 100 requests in flight against that connection regardless of how many the client would like.
Two windows, both discharged by the receiver
Flow control in HTTP/2 is credit-based and applies to DATA frames only. Each stream has a window, and the connection as a whole has a window. A sender may transmit DATA only while both windows have room, and it decrements both. The receiver returns credit with WINDOW_UPDATE, and until it does, the sender must stop.
The initial window is 65,535 octets for both levels, and the receiver advertises a different per-stream initial size through SETTINGS_INITIAL_WINDOW_SIZE. The connection window cannot be changed by a setting; it is raised only with WINDOW_UPDATE, which is why a server that wants large bodies over many streams has to top up the connection window explicitly rather than once per stream.
Two failure modes follow directly. A handler that does not read a request body leaves that stream’s window exhausted at zero, which stalls only that stream. But if enough unread bodies accumulate, the connection window is exhausted too, and every other stream on the connection stalls behind one slow handler. The multiplexing guarantee is therefore conditional on the application consuming each stream’s data, and a server that hands every stream to a worker without a per-stream read deadline can be stalled by a single request that is never read.
The second failure is window-size accounting under settings changes. When a SETTINGS frame lowers SETTINGS_INITIAL_WINDOW_SIZE, every existing stream’s window shrinks by the difference, and a window that would go negative is an error condition the sender must handle by pausing rather than by resetting the connection. Implementations that recompute windows from scratch after a settings change lose the outstanding credit they had already been granted.
Header compression is per-connection state
HPACK, defined in RFC 7541, compresses header fields using a static table of common names and values, a dynamic table built from previously seen fields, and Huffman coding of the remaining literals. The dynamic table is shared by all streams on the connection and is indexed by position, so the decoder’s table must match the encoder’s exactly and in the same order.
This is what makes header compression effective: the second request on a connection repeats almost nothing. It is also what makes the connection stateful in a way HTTP/1.1 never was. A single lost or misparsed header block desynchronises the table, and every subsequent block on that connection decodes to the wrong values. The failure mode is not a corrupted request but a stream of requests with the wrong headers, including the wrong authorization header, which is why a decoding error is required to reset the connection rather than the stream.
The dynamic table is also unbounded unless the receiver says otherwise. The size limit is advertised in a setting and defaults to 4,096 octets, and every open connection carries its own table. A server holding a hundred thousand connections with the default table has allocated hundreds of megabytes for compression state before any application object exists.
There is a security consequence as well. Compression that depends on secret-bearing and attacker-influenced content in the same context leaks information through the compressed length, which is the basis of the BREACH family of attacks against compressed responses. HTTP/2 header compression is less exposed because headers are largely under the server’s control, but the same reasoning applies whenever a response body mixes secrets with reflected input.
TCP ordering survives multiplexing
Every stream on a connection shares one congestion window and one ordered byte stream. A lost packet blocks delivery of all subsequent bytes until it is retransmitted, so frames for unrelated streams wait behind the gap. The protocol removes application-level head-of-line blocking, in which a slow response delays later requests on the same connection, but not transport-level blocking, in which a lost segment delays every stream.
QUIC addresses the transport-level case by running streams over independent ordered byte streams in UDP, which is why HTTP/3 is defined over it. The practical difference appears on lossy paths, and on clean paths the two behave similarly.
Stream prioritisation was specified in the original protocol as a dependency tree and is deprecated in RFC 9113; RFC 9218 defines a simpler scheme in which each request carries an urgency and an incremental flag in a header. Server support for either is optional, and a server that ignores priority simply serves streams in arrival order, which is what many deployments do.
What a server has to keep per connection
The per-connection state is the design constraint. A connection holds HPACK encoder and decoder tables, a connection-level flow-control window, a stream table with per-stream windows and buffers, and the settings it has agreed to. It holds one socket, one TLS session, and one concurrency slot in the accept budget.
That is more state than an HTTP/1.1 connection by an order of magnitude, which inverts the usual sizing: fewer connections each carrying far more traffic. It also makes shutdown more delicate, because a connection with active streams cannot simply be closed; the mechanism for retiring one gracefully is GOAWAY, and the sequence it belongs to is covered in graceful shutdown and connection draining. The remaining cost, the one no protocol change removes, is what each stream does while it is open, which streaming request bodies bounds.