HTTP/2

32 min read
On this page

HTTP/2 preserves the semantics of methods, status codes, caching, etc., from HTTP, expressing multiple exchanges within a single connection using frames and streams. It allows responses for different requests to be interleaved, overcoming the limitation of HTTP/1.1 where responses had to be ordered by request; the underlying TCP still provides a single ordered byte stream.

The current base specification is RFC 9113, which obsoletes RFC 7540. Do not describe it as a transport protocol that rewrites TCP, nor interpret "single connection, multiple streams" as meaning only one permanent connection is open for the entire site. RFC 9113

Problem to DiagnoseCheck First
h2 is negotiated, but why no GET text seen?Request headers use header blocks and HPACK
One response is slow; do other streams have to wait for it?Check stream scheduling, connection window, and TCP gaps separately
Does END_HEADERS mean the request is finished?End of header block is different from end of sending direction
Window is large, why is sending stopped?Check both stream window and connection window simultaneously
Why does the compression table occasionally fail to decode?Both directions have state; packet captures may lack history
Which requests can be retried after GOAWAY?Last-Stream-ID and business execution uncertainty

Connection Establishment and Field Representation

HTTPS commonly uses TLS ALPN to negotiate h2, after which the client sends the defined connection preface and SETTINGS; the server also starts its connection preface with SETTINGS. Although the client's 24-byte fixed preface appears to contain HTTP text, it is not sending a normal HTTP/1.1 request to the application.

HTTP/2 without TLS can be established based on prior knowledge. The historical h2c Upgrade mechanism should not be considered the mandatory entry point for all HTTP/2; the latest base specification has deprecated this upgrade mechanism.

Normal requests express the target using pseudo-fields such as :method, :scheme, :authority, and :path; responses use :status. Pseudo-fields should appear before regular fields, and field names must be lowercase; specific methods like CONNECT have corresponding rules. Do not cram a complete HTTP/1.1 start line into HEADERS.

Connection-specific fields such as Connection, Keep-Alive, and Transfer-Encoding cannot be copied directly; TE has a special case allowing only trailers. Content-Length can still express the expected content length, but streams are delimited by HTTP/2 frames and END_STREAM, so CL cannot be used to replace stream state judgment.

Frame Header 9 B: Length is not the full frame length

FieldLengthMeaning
Length24 bitNumber of payload bytes only
Type8 bitDATA, HEADERS, SETTINGS, etc.
Flags8 bitInterpreted per frame type
Reserved + Stream ID1 + 31 bitReserved bit and associated stream; 0 is used for applicable connection control

The receiver's default maximum frame payload is 16,384 B, which can be adjusted within a specified range via SETTINGS_MAX_FRAME_SIZE, up to a maximum of 16,777,215 B. This upper limit is not the total length of a response or a field list.

Below is a DATA frame carrying 5 B of content and ending the sending direction on stream 1; the complete frame is 14 B. The code only handles the frame header and length, not the connection state machine.

def frame(kind, flags, stream, payload):
    assert 0 <= stream < 2**31 and len(payload) < 2**24
    return (len(payload).to_bytes(3, 'big') + bytes([kind, flags])
            + stream.to_bytes(4, 'big') + payload)

wire = frame(0, 1, 1, b'hello')
assert wire.hex() == '00000500010000000168656c6c6f'
assert len(wire) == 14
assert int.from_bytes(wire[:3], 'big') == len(wire) - 9
assert int.from_bytes(wire[5:9], 'big') & 0x7fffffff == 1
print('DATA payload / Complete frame:', len(wire) - 9, len(wire))

Stream States and Two END Flags

Request streams created by the client use odd IDs; push streams initiated by the server use even IDs. You cannot arbitrarily open an even-numbered request stream for arbitrary bidirectional RPC. IDs are used monotonically within the connection and are not recycled or reused after completion; stream 0 is not a business request stream.

END_HEADERS indicates that the current compressed header block is complete; END_STREAM indicates that the sending direction is finished. Only a GET request header can set both flags simultaneously on HEADERS, but the server can still send responses on the same stream. A POST with content might first send HEADERS, then multiple DATA frames, and finally end with DATA or trailer HEADERS.

If a header block is split into CONTINUATION, the corresponding CONTINUATION frames for that stream must be sent consecutively until END_HEADERS; no DATA from other streams can be interleaved. Thus, multiplexing also has protocol-level contiguous regions and cannot be simply understood as any frame being able to jump the queue arbitrarily.

Multiplexing: Separate Application Queuing from TCP Head-of-Line Blocking

Frames can be interleaved, but bytes after a TCP gap still need to wait S1 HEADERS Dispatched by Stream ID S3 HEADERS Dispatched by Stream ID S3 DATA Dispatched by Stream ID S1 DATA Dispatched by Stream ID Missing Bytes Subsequent frames, even if belonging to other streams, cannot skip the TCP gap The stream order in the diagram is an example; unfinished header blocks in HEADERS have contiguous CONTINUATION requirements.

When stream 1's slow query is not yet ready to respond, stream 3 can send its own response first; but all sent frames eventually fall into the same TCP sequence number space. After a segment of TCP bytes is lost, the receiver cannot deliver the bytes after the gap as a continuous stream to HTTP/2 in advance, even if they come from another HTTP stream.

This does not mean that losing one packet will revoke data already delivered by all streams, nor does it mean that frames naturally correspond to specific TCP packets. Connection-level bandwidth, congestion control, server scheduling, application lock contention, and HPACK state can also form cross-stream impacts.

Two-Layer Flow Control: Only DATA is Handled, Not Congestion Control

DATA consumes both the connection window and its own stream window Connection Send Remaining: 10,000 B All streams share this direction's quota Stream 1: Remaining 8,000 B First send 6,000 B DATA Stream 3: Remaining 8,000 B Subsequently send at most 4,000 B DATA Example has no padding, frame length limit is sufficient; stream window still has plenty left, but may be blocked by the connection window.

For a sending direction, DATA is limited by both connection and stream quotas. The default initial quota is 65,535 B; SETTINGS_INITIAL_WINDOW_SIZE modifies the stream window and cannot be used to increase the connection window. The connection quota is adjusted via stream 0 WINDOW_UPDATE. The reverse sending direction has its own quota.

The entire payload of DATA counts towards flow control, including padding and its length field, but excluding the 9 B frame header; HEADERS, PING, SETTINGS, etc., do not consume the DATA window. Therefore, limiting only DATA is insufficient to restrict the peer's field decoding or control frame resource consumption.

connection = 10_000
stream = {1: 8_000, 3: 8_000}

def send(sid, size):
    global connection
    assert 0 <= size <= min(connection, stream[sid])
    connection -= size
    stream[sid] -= size

send(1, 6_000)
assert connection == 4_000 and stream[1] == 2_000
send(3, 4_000)
assert connection == 0 and stream[3] == 4_000
# Only increasing the stream window still cannot break through the connection remaining amount.
stream[3] += 10_000
assert min(connection, stream[3]) == 0
connection += 2_000
assert min(connection, stream[3]) == 2_000
print('After connection window update, stream 3 can send:', min(connection, stream[3]))

WINDOW_UPDATE increments cannot be zero, and windows also have an upper limit. Lowering SETTINGS_INITIAL_WINDOW_SIZE may also cause existing stream windows to temporarily become negative; in this case, you must wait for the quota to recover rather than forcibly converting the negative number into an unsigned large number. Applications must consume and release receive budgets in a timely manner to avoid flow control stalls.

Why can't I send even after replenishing the quota for only one stream?

After exhausting the connection quota, only clicking WINDOW_UPDATE for stream 3 causes the gold bar to grow, but the send button remains unavailable. Only after replenishing the connection quota does sending resume. This state demonstrates how the constraint of "taking the minimum of two remaining amounts" forms through historical operations.

Preparing the visual
Why can't I send even after replenishing the quota for only one stream?

Each DATA deducts from two ledgers. Stream 3 still has a balance, but sending pauses if the connection is zero; HEADERS are not in these two DATA ledgers, but are still subject to other resource constraints.

HPACK: Each Direction Maintains Compression State Independently

HPACK uses a 61-item static table, a dynamic table, and optional fixed Huffman encoding. The encoder in the request direction and the decoder on the server share one corresponding state; the response direction is another. It is not that the server stores response fields into it, and the client can reference them in the next request.

Dynamic table entries are inserted according to encoding instructions; later-inserted items are placed at the front, and old items are evicted based on capacity. It is not that "all fields are automatically stored after each HEADERS ends," and accessing an old item does not promote it to LRU. Entry billing is name byte count + value byte count + 32, which is different from the compressed wire length.

For example, :authority: www.example.com occupies 10+15+32=57 B of table capacity; the first dynamic index is 62. The next time a new item is inserted, the old item's index becomes 63. Static index 2 corresponds to :method: GET, expressed as 0x82 in single-byte indexed form; index 62 corresponds to 0xbe, but you only know what it represents when the direction and historical state match.

Never Indexed is used for encoding constraints on sensitive fields; it does not encrypt fields, does not hide the total length, and does not eliminate all compression side channels. The field table capacity limit and the decoded field list size limit are different resource controls. RFC 7541: HPACK

Why does index 62 point to a different field next time?

After inserting two items consecutively, 62 points to the later-inserted one, and the previous field becomes 63. Referencing 62 again and observing the table order shows no change. After shrinking the capacity and calling back the slider, evicted items do not reappear because capacity limits and actual historical state are two separate variables.

Preparing the visual
Why does index 62 point to a different field next time?

Index is the position in the current direction's compression state, not a permanent field number. Insertion shifts old indices back, capacity is billed by name+value+32, and reading does not perform LRU promotion.

SETTINGS, Priority, and Resource Boundaries

SETTINGS describe the constraints the sender is willing to receive, allowing the peer to adjust its own sending accordingly. For example, the client declares the maximum frame length, limiting the frames the server sends to the client; this cannot be interpreted in reverse as permission for the client's own sending. SETTINGS with ACK no longer carry setting items.

MAX_CONCURRENT_STREAMS limits the streams that can be concurrently active in the corresponding direction, not the cumulative number of requests in the connection history. Even if only instantaneous concurrency is controlled, it may still bear the workload caused by rapid stream creation/cancellation. You should also pay attention to request cost, queuing, field size, and cancellation propagation.

The old priority dependency tree has been deprecated in RFC 9113; RFC 9218 defines extensible priority, where urgency is 0 to 7 (smaller is more urgent), and incremental indicates whether progressive delivery is suitable. These are scheduling hints, do not guarantee fixed throughput shares, and cannot break through flow control windows. RFC 9218

Cancellation and Graceful Exit: Do Not Treat RST as Transaction Rollback

RST_STREAM terminates a stream, but it does not prove that the application did not execute the request, nor does it automatically rollback database transactions. Implementations must still maintain compression and flow control state involving the connection; you cannot see that a stream was cancelled and arbitrarily skip all related frames.

GOAWAY uses Last-Stream-ID to inform the peer which streams may have already been processed. Assuming the last client stream ID accepted by the server was 5, stream 7 can be retried on a new connection; for streams 1, 3, and 5, those that did not receive a complete response still have execution uncertainty and must be handled with deduplication based on method and business logic. Graceful exit can first stop new streams, then wait for existing streams to complete; you cannot simply receive GOAWAY and retransmit all requests. RFC 9113: Connection Exit

Server Push: Protocol Capability vs. Browser Support

PUSH_PROMISE once allowed the server to commit in advance and send resources the client might need; unknown cache hits, wasted transmission, and scheduling complexity would weaken the benefits. Chrome removed HTTP/2 Server Push support starting from version 106, but you cannot write that the HTTP/2 specification has deleted the push frame. Chrome Removal Notice

103 Early Hints or preload hints let the client decide whether to initiate the request themselves; this is not the same mechanism as the server directly pushing content. Deployment choices should be based on real client support and measurement results; you cannot bind "enabling HTTP/2" together with "should enable push."

Continue Reading

  • HTTP/1.1: Message boundaries, caching, and method semantics.
  • HTTP/3: QUIC streams and QPACK dependency trade-offs.
  • TCP: Byte sequence numbers, windows, and congestion control.