Home › Networking › HTTP & RPC
algorithm low

HTTP/2

Problem Clients and servers that exchange many small requests over one connection need them to proceed independently, without one slow response blocking the rest or repeated headers wasting bytes, while one peer cannot exhaust the other's memory.

Binary multiplexed framing for HTTP over one TCP connection: streams, per-stream and per-connection credit windows, HPACK header compression and the limits that contain abuse.

O(1) per frame header; HPACK O(header bytes); window bookkeeping O(1) per DATA frame and per WINDOW_UPDATE
httpmultiplexingframinghpackflow-controlrapid-reset
Overview

HTTP/2 puts many request/response exchanges on one TCP connection by cutting everything into frames whose 9-byte header names the stream. Each stream has its own credit window, as does the connection, so a slow reader stops only its stream. Headers go through HPACK, a table both ends keep in sync, so a repeated header costs a byte or two.

Key facts

Use when

  • Browsers and CDNs loading many resources from one origin; gRPC, which runs on HTTP/2; mobile apps sending many API calls over one connection; server-to-server APIs and push gateways such as APNs; service meshes and reverse proxies.

Avoid when

  • Lossy mobile paths dominate: HTTP/3 avoids the shared TCP stall.
  • A one-off request or a tiny device: HTTP/1.1 needs less state.

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

  1. Preface: the client sends the 24 bytes PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n then 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).
  2. 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.
  3. 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.
  4. 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).
  5. 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_SIZE shifts all stream windows by the difference, even below 0; the connection window is unchanged.
  6. 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 as authorization. Strings may be Huffman coded; integers use an N-bit prefix (§5.1).
  7. 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::decode about 1.0 ns, Flow::send about 6.5 ns (HashMap with SipHash, plus refills), hpack_int on 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 write per 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 hpack 1.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_PUSH 0. 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 h2 through 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

namelangrepoinstallverdictlicencehealthas_ofnotes
h2Rusthyperium/h2cargo add h2————client and server, flow control and HPACK; underneath hyper
hyperRusthyperium/hypercargo add hyper --features http2,server————HTTP library on top of h2
nghttp2Cnghttp2/nghttp2brew install nghttp2————C library with HPACK; nghttp, nghttpd tools; Node.js uses it
x/net/http2Gogolang/netgo get golang.org/x/net/http2————what net/http bundles; hpack subpackage
netty-codec-http2Javanetty/nettyMaven 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

Used by

Systems (17):

Alternatives and related

HTTP/1.1 message parsingA program reads HTTP/1.1 from a TCP connection (server, proxy or crawler); bytes arrive in arbitrary pieces, and it must find where each message ends in exactly the way every other hop does.QUICApplications on slow, lossy or mobile networks need a connection that starts quickly, survives a change of network, and does not let one lost packet stall unrelated data, all while staying encrypted.
grpc protocol · plannedGRPC framing over HTTP/2: unary and streaming RPCs, deadlines, status
credit based flow control · plannedReceiver-granted credits/windows bound in-flight data (HTTP/2 windows, AMQP prefetch, backpressure)
Length-prefix framingTwo programs exchange discrete messages over a connection that delivers an unstructured stream of bytes. The receiver must find where each message starts and ends, even when data arrives split or merged.Huffman codingAfter a compressor has split data into symbols, frequent symbols should take fewer bits than rare ones, with very fast decoding. This is the classic way to assign such variable-length codes.WebSocketsWeb apps need the server to push updates to a browser the moment they happen, and the browser to answer on the same channel, without a new request for every update.TLS 1.3Any network service must keep traffic between clients, or between internal services, private, tamper-proof and authenticated to the right server, without hand-building a secure channel.Server-sent eventsA service must push a steady flow of small updates to many clients that only listen, over ordinary HTTP, and resume without gaps or duplicates after the connection drops, at each reconnect.