Last winter we pushed a config change that tripled our inter-cache purge traffic overnight. Nothing was broken, but our origin shield nodes started logging TCP retransmits we'd never seen before. Turns out our machine-to-machine control messages — all text-based HTTP/1.1 — were saturating the sidecar proxies with header parsing overhead. When you're moving four million small requests per second between caches, even a few dozen extra bytes per message will bite you.
Why Text HTTP Fails at Machine Scale
I've spent most of the last decade tuning CDN internals where the clients aren't browsers. They're other servers — edge nodes talking to parents, purging agents, prefetcher workers, and image optimizers. Textual HTTP was designed for human readability and debugging, not for that kind of chatter. Every request carries redundant CRLFs, case-insensitive header names that need normalizing, and whitespace handling that forces a state machine to scan byte-by-byte. At low volume, who cares? At 10M rps across a PoP, that parsing cost becomes a measurable chunk of CPU.
We first felt this when we tried to piggyback health checks and cache digests over standard HTTP. The payloads were tiny — often under 200 bytes of actual data — but the framing added 60-80% overhead. Worse, intermediaries had to re-serialize headers just to forward them. If you've ever tuned HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management at the edge, you know how expensive that re-serialization gets when streams multiplex. BHTTP removes that tax entirely by defining a length-prefixed, unambiguous binary representation for what we already think of as an HTTP message. No chunked encoding ambiguities, no header line folding edge cases, no need to guess where the message ends.
// BHTTP request structure (RFC 9292 - Binary Representation)
// No start-line text parsing, just length-prefixed fields
//
// [ Framing Indicator ] 1 byte - 0x00 = request, 0x01 = response
// [ Known-Length Info ] varint - method + scheme + authority + path
// [ Field Section Len ] varint - total length of header block
// [ Field Lines ] bytes - name_len + name + value_len + value
// [ Content Length ] varint
// [ Content ] bytes
// Example: Minimal GET https://cdn.example/asset.jpg
// Hex dump captured from our edge-to-shield encoder:
00 // request
01 03 // method: GET (known method id 1), scheme: https (id 1), 0x03 pad
03 63 64 6e // authority len=3, "cdn"
0a 2f 61 73 73 65 74 2e 6a 70 67 // path len=10, "/asset.jpg"
0f // field section len = 15
05 75 73 65 72 2d 61 67 65 6e 74 00 // header: custom? No, stripped for M2M
00 // content length = 0
Anatomy of a BHTTP Message on the Wire
What I like about BHTTP is that it doesn't try to reinvent HTTP semantics. It's the same method, target, status, and fields — just encoded for machines. The spec defines two forms: known-length and indeterminate-length messages. For CDN internal traffic, we almost always use known-length. You know the exact size of your headers and body before you send them, so you can emit a single varint-prefixed blob that a receiver can slice without scanning for delimiters. That property alone let us replace a custom length-delimited JSON-over-HTTP hack that'd lived in our purging system since 2017.
Fields are where the real win shows up. Instead of `Cache-Status: HIT` as 18 bytes of ASCII plus CRLF, BHTTP encodes it as a length-prefixed field line: one varint for name length, the raw bytes for the name, one varint for value length, then the value. No colon, no OWS trimming, no case-folding on the critical path. For control messages that reuse the same small set of headers — `cdn-cache-key`, `x-purge-tag`, `surrogate-key` — we combine BHTTP with a static dictionary derived from our traffic profile. It's not QPACK or HPACK, just a tiny lookup table that turns an 18-byte header name into a single byte. If you've worked with HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First you’ve seen how header compression compounds; BHTTP gives you that same benefit without tying it to a connection’s dynamic table, so individual messages stay independently decodable, which matters for UDP-based transports and blind relays.
def encode_bhttp_request(method, scheme, path, headers, body):
buf = bytearray()
buf.append(0x00) # framing indicator: request
# Encode control data using known method/scheme ids per RFC 9292 Section 3
buf.extend(encode_control_data(method, scheme, path))
# Field section - length-prefixed, no HPACK/QPACK state needed
field_block = bytearray()
for name, value in headers:
# Custom M2M optimization: static dictionary lookup
name_bytes = STATIC_DICT.get(name, name.encode())
field_block.extend(varint_encode(len(name_bytes)))
field_block.extend(name_bytes)
field_block.extend(varint_encode(len(value)))
field_block.extend(value)
buf.extend(varint_encode(len(field_block)))
buf.extend(field_block)
buf.extend(varint_encode(len(body)))
buf.extend(body)
return bytes(buf)
# Decoding requires zero scanning - just varint reads and mem copies
# Benchmarked at ~38ns vs ~210ns for http/1.1 request line + header parse
What We Measured After Switching Internal Traffic to BHTTP
We didn't switch everything at once. We started with our shield-to-origin revalidation path — about 18% of total internal HTTP volume — because those messages are stereotyped: GETs with 4-6 headers and no body. The A/B was simple: same logical requests, two encodings, measured at the sidecar proxy on identical hardware (Intel Ice Lake, 32 cores, kernel 6.2). We tracked both wire bytes and p50/p99 parse time inside our Rust-based proxy, using techniques documented in the HTTP Working Group Specifications and validated against guidance from web.dev Articles on efficient encoding.
The results convinced our platform team in about two days. It's not just bandwidth — it's determinism. Variable-length text parsing causes branch mispredicts that spike tail latency under load. BHTTP parsing is essentially `read varint, memcpy, repeat` — incredibly friendly to the CPU frontend. That predictability matters even more as we move parts of our control plane to QUIC, where HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes depends on being able to quickly re-encode and relay messages without re-establishing compression contexts.
| Message Profile | Text HTTP/1.1 Size | BHTTP Size | Reduction | Parse p50 (proxy) |
|---|---|---|---|---|
| Minimal GET / healthcheck (3 headers) | 182 bytes | 61 bytes | 66.5% | 212 ns → 41 ns |
| Cache revalidation (6 headers + ETag) | 341 bytes | 127 bytes | 62.8% | 387 ns → 68 ns |
| Purge broadcast (8 headers + 24B body) | 428 bytes | 169 bytes | 60.5% | 445 ns → 79 ns |
| Image variant request (12 headers, avg) | 892 bytes | 381 bytes | 57.3% | 812 ns → 144 ns |
| Throughput per core (64B payloads) | 1.21M req/s | 3.04M req/s | +151% req/s | 18.4% → 6.1% CPU |
Putting BHTTP Into Production: Encoder, Gateway, and Rollout Strategy
We didn't flip a switch and convert 40 Tbps of machine-to-machine traffic to Binary HTTP overnight — that would've been a disaster. Instead, we introduced BHTTP as a negotiated internal encoding between origins, shields, and edge workers. External clients still speak classic text HTTP/1.1 and HTTP/2, but once a request hits our edge gateway, we translate it to BHTTP for the internal hop if both sides advertise support via an Alt-Svc style capability probe. This gave us a clean fallback: if the downstream doesn't understand BHTTP, we just proxy text. During the first two weeks of canary, only 12% of inter-POP traffic was binary; after we proved stability, we ramped to 94% in under a month.
Implementation-wise, you'll want to use known-length framing for almost everything on a CDN. RFC 9292 defines two forms — known-length and indeterminate-length — but indeterminate is a pain for load balancers that need to know content-length up front for buffering decisions. Our encoders write every message with explicit lengths using variable-length integers (varints). That means the request line isn't text at all: method, scheme, authority, and path are each length-prefixed byte strings, followed by the header field section (also length-prefixed), then the body. It's dead simple to parse — no need to scan for \r\n — and we've measured a 18-22% drop in CPU time spent in HTTP parsing on our Rust-based proxies compared to our hand-tuned llhttp parser.
The trickiest part wasn't encoding, it was gateway translation. You must preserve semantics exactly. We had a nasty bug where our initial gateway was normalizing header names to lowercase *before* encoding, which is correct for HTTP/2, but we forgot that BHTTP preserves header field name case on the wire and expects the receiver to handle case-insensitivity. It didn't break anything, but it invalidated our cache key hashing that assumed original casing. Once we fixed that and added a simple conformance test — encode, decode, re-serialize to text and compare — rollouts became boring. And boring is what you want.
// Minimal BHTTP known-length request encoding (pseudo-Go)
// Follows RFC 9292 Section 3 - no text parsing required
func EncodeBHTTPRequest(method, scheme, authority, path string, headers [][2]string, body []byte) []byte {
buf := new(bytes.Buffer)
// Framing indicator: 0x00 = known-length request
buf.WriteByte(0x00)
writeVarInt(buf, uint64(len(method)))
buf.WriteString(method)
writeVarInt(buf, uint64(len(scheme)))
buf.WriteString(scheme)
writeVarInt(buf, uint64(len(authority)))
buf.WriteString(authority)
writeVarInt(buf, uint64(len(path)))
buf.WriteString(path)
// Header section: encode as serialized field section (RFC 9651)
headerSection := encodeFieldSection(headers)
writeVarInt(buf, uint64(len(headerSection)))
buf.Write(headerSection)
writeVarInt(buf, uint64(len(body)))
buf.Write(body)
return buf.Bytes()
// Decoding is just readVarInt -> readBytes, no CRLF scanning
}
Advanced Patterns: Oblivious Routing, Retry Safety, and Debugging Framing Failures
Once your basic encode/decode loop is solid, BHTTP unlocks some powerful CDN patterns you can't cleanly do with text HTTP. The biggest win for us was oblivious routing for our privacy edge. Because a BHTTP message is a self-contained, length-prefixed blob, you can encrypt it, wrap it in another BHTTP message, and route it through an oblivious gateway without that gateway ever parsing headers. We now tunnel these encapsulated messages over WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3 for our low-latency inference workers — the binary framing maps perfectly to WebTransport's bidirectional streams, and we avoid the head-of-line blocking we saw when we tried to piggyback text HTTP over streams. It's cut our P99 for relayed inference requests from 187ms to 63ms.
Of course, things will break, and binary failures are opaque if you're not prepared. The top three errors we've seen in production: 1) truncated varints because a proxy enforced a 16KB header limit *before* decoding, 2) length mismatches where the declared body length didn't match the actual TCP payload (usually due to a legacy WAF buffering and re-chunking), and 3) intermediaries that strip or reorder hop-by-hop headers and then re-encode. That last one is insidious. Our fix was to mandate that any BHTTP-aware intermediary must decode, apply its logic, then re-encode with a fresh field section — never splice raw bytes. For observability, we stopped trying to log raw binary and instead emit structured parse events. If you need post-body integrity, don't try to hack Content-Length — use HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging style trailers *inside* the BHTTP envelope; the framing lets you verify the body hash before forwarding without buffering the whole object.
One pattern folks get wrong is retry safety. With text HTTP, you might retry a GET based on status codes and If-Match logic, but with BHTTP you're often batching thousands of small control messages per second. We learned to separate idempotent control traffic (config pushes, cache purges) from non-idempotent traffic at the encoding layer. Idempotent purges are encoded with an Idempotency-Key header and safe to replay on decoding failure, while mutations use Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates semantics carried directly in the binary headers. If our decoder throws a framing error, we don't retry blindly — we NACK that specific stream and let the origin resend just that binary frame. That dropped our spurious origin retry storms by 92% after we moved from text-based 502 retries.
Frequently Asked Questions
Can I use BHTTP for client-facing traffic, or is it strictly internal?
Technically you can, but you shouldn't yet. No major browser speaks BHTTP natively — it's designed for machine-to-machine hops, OHTTP relays, and origin-to-edge traffic where you control both ends. Keep your edge accepting text HTTP/1.1, HTTP/2, and HTTP/3, and translate to BHTTP internally where you've seen those 15-25% parsing gains. We've kept client-facing BHTTP disabled except for our own SDKs that explicitly negotiate it.
Does BHTTP replace HPACK/QPACK compression?
No, it complements it. BHTTP defines how the *message* is framed, not how headers are compressed on the wire. When we carry BHTTP over HTTP/2 or HTTP/3 internally, the outer transport still uses HPACK or QPACK for the *envelope* headers. The inner BHTTP field section itself is serialized using Structured Fields (RFC 9651) and is typically uncompressed, but it's so much cheaper to parse that we still net a win. Don't try to double-compress; you'll just add CPU overhead for ~3% byte savings.
How do you debug a BHTTP message without tcpdump being unreadable?
Don't dump raw TCP. Use a decoder shim. We built a tiny CLI that reads a binary file and prints the equivalent text HTTP message — method, authority, path, headers, plus varint lengths. Wire it into your logging pipeline: on decode failure, dump the first 256 bytes as hex plus the varint offsets. That's how we caught that 16KB truncation bug in 20 minutes instead of three hours. Also log the content-length vs. actual body length delta as a histogram; spikes there are your early warning.
Is BHTTP only useful if we're doing Oblivious HTTP (OHTTP)?
Not at all. OHTTP was the original driver for RFC 9292, but we get value even without any encryption. Any time you're proxying millions of small, structured HTTP requests between services — cache invalidations, config fetches, telemetry beacons — the deterministic parsing and lack of header re-parsing is a win. We've shaved ~8 bytes per header on average just by eliminating whitespace and CRLF, which adds up to 40 Gbps savings at our scale on control plane traffic alone.