Deep dive
How it works
In: bytes from a TCP connection that finished TLS with ALPN h2 (or cleartext with prior knowledge) · Out: header lists and DATA per stream, and the frames to send back · State: SETTINGS of both sides, per-stream state, connection window, one window per stream, one HPACK dynamic table per direction · Cost: O(1) per frame header, about 1 ns (measured); HPACK, TLS and syscalls dominate
- Preface: the client sends the 24 bytes
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\nthen a SETTINGS frame (may be empty); the server's first frame is SETTINGS. Every SETTINGS frame is answered with SETTINGS+ACK (RFC 9113 §3.4, §6.5.3). - Read 9 bytes: length (24 bits), type, flags, reserved bit, stream id (31 bits). A length above
SETTINGS_MAX_FRAME_SIZE(default 16,384) is FRAME_SIZE_ERROR; unknown types are ignored; id 0 means the whole connection. - Types: DATA 0, HEADERS 1, PRIORITY 2 (deprecated), RST_STREAM 3, SETTINGS 4, PUSH_PROMISE 5, PING 6, GOAWAY 7, WINDOW_UPDATE 8, CONTINUATION 9. A header block is HEADERS plus CONTINUATION frames up to END_HEADERS, nothing between.
- Clients open streams with odd ids, servers with even; ids rise and are never reused. HEADERS opens, END_STREAM half-closes, RST_STREAM kills. Open and half-closed streams count toward
SETTINGS_MAX_CONCURRENT_STREAMS(unlimited until set; at least 100 recommended). - Sending n DATA bytes needs n at most the stream window and the connection window, and charges both (the header is free). At 0, wait for WINDOW_UPDATE (1 to 2^31-1; id 0 = connection). A window above 2^31-1 is FLOW_CONTROL_ERROR. A new
SETTINGS_INITIAL_WINDOW_SIZEshifts all stream windows by the difference, even below 0; the connection window is unchanged. - Header blocks are HPACK. A static-table hit is one byte (61 entries; index 2 is
:method: GET). A literal may be added to the dynamic table (FIFO, oldest evicted when over the size limit), sent without indexing, or sent never-indexed for secrets such asauthorization. Strings may be Huffman coded; integers use an N-bit prefix (§5.1). - PING needs an ACK. GOAWAY names the last stream id that may be processed; later streams need a new connection. Violations are stream errors (RST_STREAM) or connection errors (GOAWAY, close).
Code
use std::collections::HashMap;
const MAX_WINDOW: i64 = (1 << 31) - 1; // 2^31-1: no window may exceed it
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Err { FrameSize, Protocol, FlowControl }
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct Header { pub len: u32, pub ty: u8, pub flags: u8, pub stream: u32 }
impl Header {
/// 9 octets: length(24) type(8) flags(8) R(1) stream id(31), big endian (RFC 9113 §4.1).
pub fn encode(&self) -> [u8; 9] {
let (l, s) = (self.len.to_be_bytes(), (self.stream & 0x7fff_ffff).to_be_bytes()); // R = 0 on send
[l[1], l[2], l[3], self.ty, self.flags, s[0], s[1], s[2], s[3]]
}
/// `max` = the SETTINGS_MAX_FRAME_SIZE we advertised (default 16_384). Subset of the §6 rules.
pub fn decode(b: &[u8; 9], max: u32) -> Result<Header, Err> {
let len = u32::from_be_bytes([0, b[0], b[1], b[2]]);
let stream = u32::from_be_bytes([b[5], b[6], b[7], b[8]]) & 0x7fff_ffff; // R is ignored on receive
let h = Header { len, ty: b[3], flags: b[4], stream };
if len > max { return Err(Err::FrameSize); }
match h.ty {
0x0 | 0x1 | 0x3 | 0x9 if stream == 0 => Err(Err::Protocol), // DATA HEADERS RST CONTINUATION
0x4 | 0x6 if stream != 0 => Err(Err::Protocol), // SETTINGS PING: stream 0 only
0x4 if len % 6 != 0 || (h.flags & 1 == 1 && len != 0) => Err(Err::FrameSize), // ACK is empty
0x6 if len != 8 => Err(Err::FrameSize),
0x8 if len != 4 => Err(Err::FrameSize), // WINDOW_UPDATE
_ => Ok(h),
}
}
}
/// SETTINGS payload: (id u16, value u32) pairs; callers ignore unknown ids.
pub fn parse_settings(p: &[u8]) -> Result<Vec<(u16, u32)>, Err> {
if p.len() % 6 != 0 { return Err(Err::FrameSize); }
p.chunks(6).map(|c| {
let (id, v) = (u16::from_be_bytes([c[0], c[1]]), u32::from_be_bytes([c[2], c[3], c[4], c[5]]));
match id {
0x2 if v > 1 => Err(Err::Protocol), // ENABLE_PUSH
0x4 if v as i64 > MAX_WINDOW => Err(Err::FlowControl), // INITIAL_WINDOW_SIZE
0x5 if !(16_384..=16_777_215).contains(&v) => Err(Err::Protocol), // MAX_FRAME_SIZE
_ => Ok((id, v)),
}
}).collect()
}
/// Send side of flow control: a connection window plus one per stream (signed: may go negative).
pub struct Flow { conn: i64, initial: i64, streams: HashMap<u32, i64> }
impl Flow {
pub fn new() -> Self { Flow { conn: 65_535, initial: 65_535, streams: HashMap::new() } }
pub fn open(&mut self, id: u32) { self.streams.insert(id, self.initial); }
/// DATA bytes that may go out now (the frame header is not counted).
pub fn available(&self, id: u32) -> usize { self.conn.min(self.streams[&id]).max(0) as usize }
/// Sending DATA charges both windows. Err = blocked until WINDOW_UPDATE.
pub fn send(&mut self, id: u32, n: usize) -> Result<(), ()> {
if n > self.available(id) { return Err(()); }
self.conn -= n as i64;
*self.streams.get_mut(&id).unwrap() -= n as i64;
Ok(())
}
/// id 0 = connection window. Increment 1..=2^31-1; the sum may not exceed 2^31-1.
pub fn window_update(&mut self, id: u32, inc: u32) -> Result<(), Err> {
if inc == 0 || inc as i64 > MAX_WINDOW { return Err(Err::Protocol); }
let w = if id == 0 { &mut self.conn } else { self.streams.get_mut(&id).ok_or(Err::Protocol)? };
if *w + inc as i64 > MAX_WINDOW { return Err(Err::FlowControl); }
*w += inc as i64;
Ok(())
}
/// SETTINGS_INITIAL_WINDOW_SIZE: every stream window moves by the difference; the connection's does not.
pub fn set_initial(&mut self, new: i64) -> Result<(), Err> {
let d = new - self.initial;
if self.streams.values().any(|w| *w + d > MAX_WINDOW) { return Err(Err::FlowControl); }
self.streams.values_mut().for_each(|w| *w += d);
self.initial = new;
Ok(())
}
}
/// HPACK integer (RFC 7541 §5.1), `n`-bit prefix. (value, octets used); None = incomplete or overlong.
pub fn hpack_int(buf: &[u8], n: u32) -> Option<(u64, usize)> {
let max = (1u64 << n) - 1;
let mut v = *buf.first()? as u64 & max;
if v < max { return Some((v, 1)); }
for (i, m) in (1..).zip((0..=28).step_by(7)) { // at most 5 continuation octets
let b = *buf.get(i)? as u64;
v += (b & 127) << m;
if b & 128 == 0 { return Some((v, i + 1)); }
}
None
}
#[cfg(test)]
mod tests { use super::*;
#[test] fn frames() {
let h = Header { len: 4, ty: 8, flags: 0, stream: 0x7fff_ffff };
assert_eq!(h.encode(), [0, 0, 4, 8, 0, 0x7f, 0xff, 0xff, 0xff]);
let mut b = h.encode(); b[5] |= 0x80; // reserved bit set: ignored
assert_eq!(Header::decode(&b, 16_384), Ok(h));
let big = Header { len: 16_385, ty: 0, flags: 0, stream: 1 }.encode();
assert_eq!((Header::decode(&big, 16_384), Header::decode(&big, 1 << 20).is_ok()), (Err(Err::FrameSize), true));
assert_eq!(Header::decode(&Header { len: 0, ty: 0, flags: 0, stream: 0 }.encode(), 16_384), Err(Err::Protocol));
assert_eq!(parse_settings(&[0, 3, 0, 0, 0, 100, 0, 4, 0, 0, 0xff, 0xff]), Ok(vec![(3, 100), (4, 65_535)]));
assert_eq!(parse_settings(&[0, 4, 0x80, 0, 0, 0]), Err(Err::FlowControl));
}
#[test] fn flow() { // RFC 9113 §6.9.2: 60 KB sent, then initial window 16 KB: -44 KB
let mut f = Flow::new(); f.open(1); f.open(3);
f.send(1, 60 * 1024).unwrap();
f.set_initial(16 * 1024).unwrap();
f.window_update(0, 60 * 1024).unwrap(); // connection credit alone does not help stream 1
assert_eq!((f.available(1), f.streams[&1]), (0, -44 * 1024));
assert_eq!(f.available(3), 16 * 1024); // other streams are unaffected
f.window_update(1, 44 * 1024 + 1000).unwrap();
assert_eq!(f.available(1), 1000);
assert_eq!(f.window_update(0, 0x7fff_ffff), Err(Err::FlowControl));
}
#[test] fn hpack() { // RFC 7541 Appendix C.1
assert_eq!(hpack_int(&[0b1110_1010], 5), Some((10, 1)));
assert_eq!(hpack_int(&[0x1f, 154, 10], 5), Some((1337, 3)));
assert_eq!(hpack_int(&[42], 8), Some((42, 1)));
assert_eq!(hpack_int(&[0x1f, 154], 5), None);
}
}
Complexity and constants
- Measured on an Apple M4 Pro, rustc 1.99.1
--release, hot loop, no I/O:Header::decodeabout 1.0 ns,Flow::sendabout 6.5 ns (HashMapwith SipHash, plus refills),hpack_inton 1337 about 1.3 ns. - Memory per connection is bounded by settings: HPACK table, window counters, accepted field-block size.
- Cost drivers: HPACK decoding and allocation per header, TLS crypto, one
writeper frame unless coalesced.
Efficiency
- Cost: parsing a frame is nanoseconds (above). RFC 7541 Appendix C.3 encodes four request fields in 20 bytes, the next request (one more field) in 14.
- Levers:
- One connection, many streams — fewer connections, less competition with other flows (RFC 9113 §1) — many small requests.
- HPACK dynamic table and Huffman — repeated fields shrink to an index (C.3.2) — similar requests on one connection.
- Window above the bandwidth-delay product — otherwise flow control caps throughput (RFC 9113 §5.2.3) — long fast paths.
- No tiny WINDOW_UPDATEs, coalesced writes — fewer frames and syscalls (§6.9.1) — high request rates.
- Never-indexed literals for secrets — a few bytes more per request, stops table probing (RFC 7541 §7.1) — cookies, tokens.
- Naive → standard → state of the art:
- HTTP/1.1, a connection per request, text headers · handshake and full headers each time.
- Multiplexed streams, HPACK, tuned windows · one connection; a TCP loss still stalls all streams.
- HTTP/3 over QUIC · no shared stall.
Implementation notes
- Read frames promptly: a missed WINDOW_UPDATE can deadlock (§5.2.2).
- Charge every received DATA frame to the connection window, even a rejected one (§6.9); decode every field block to keep HPACK in sync (§10.5.1).
- Send WINDOW_UPDATE when the application consumes data; window 2^31-1 turns off the memory bound (§5.2.2).
- Queue control frames ahead of large DATA, or they wait (§4.2).
Pitfalls
- Rapid Reset (CVE-2023-44487): open a stream and reset it at once: the slot is free again but the server already started work. Google saw a peak of 398 million requests per second. Fix: count resets per connection; close the connection on abuse (Google).
- CONTINUATION flood (2024): HEADERS without END_HEADERS, then endless CONTINUATION frames; implementations without a cap buffered or decoded them (CERT/CC VU#421644; Envoy CVE-2024-30255). Cap block size and frame count.
- HPACK bomb: one table-sized entry referenced repeatedly; Python
hpack1.0.0 to 2.2.0 let 16 kB expand to 64 MB (CVE-2016-6581). Cap the decoded header-list size, not the wire size. - Cheap-frame floods: PING, SETTINGS and tiny WINDOW_UPDATE each force a reply (§10.5); rate-limit them.
- Server push: PUSH_PROMISE remains in the spec, but Chrome disabled push by default in Chrome 106; clients send
SETTINGS_ENABLE_PUSH0. PRIORITY is deprecated; APNs ignores it. - Windows are per hop: proxies do not forward WINDOW_UPDATE (§6.9).
Variants and alternatives
In production
- Browsers, CDNs and major servers negotiate
h2through TLS ALPN (TLS 1.3). - Apple's APNs provider API requires HTTP/2 and HPACK, asks senders to reuse a connection for hours to days and allows several streams per connection without promising a number (mobile push delivery).
- Proxies and service meshes run separate HTTP/2 connections upstream and downstream, with separate windows; the window is a credit scheme (credit-based flow control).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| h2 | Rust | hyperium/h2 | cargo add h2 | — | — | — | — | client and server, flow control and HPACK; underneath hyper |
| hyper | Rust | hyperium/hyper | cargo add hyper --features http2,server | — | — | — | — | HTTP library on top of h2 |
| nghttp2 | C | nghttp2/nghttp2 | brew install nghttp2 | — | — | — | — | C library with HPACK; nghttp, nghttpd tools; Node.js uses it |
| x/net/http2 | Go | golang/net | go get golang.org/x/net/http2 | — | — | — | — | what net/http bundles; hpack subpackage |
| netty-codec-http2 | Java | netty/netty | Maven io.netty:netty-codec-http2 | — | — | — | — | frame codec, flow controller, HPACK |
Sources
- Thomson, Benfield (eds.), RFC 9113: HTTP/2, 2022. rfc
- Peon, Ruellan, RFC 7541: HPACK: Header Compression for HTTP/2, 2015. rfc
- Google Cloud, How it works: the novel HTTP/2 Rapid Reset DDoS attack, 2023. article · CVE-2023-44487
- Nowotarski, HTTP/2 CONTINUATION Flood: Technical Details, 2024. article · CERT/CC VU#421644
- Pollard, Remove HTTP/2 Server Push from Chrome, Chrome for Developers. article
- Apple, Sending notification requests to APNs. docs