Is gRPC actually faster than JSON over HTTP?

I measured the two halves of the claim separately — binary serialisation against JSON, and HTTP/2 against HTTP/1.1. Binary decoding is about twice as fast, which is worth 0.03% of a request. The transport is where the real difference lives.

Many callers, one meter, and the service behind it

Not for the reason people give. The speed argument for gRPC is usually that protobuf is binary, and that part is real but tiny: below, binary encoding and decoding saved 0.83 microseconds per request — 0.034% of a round trip. What actually moves the needle is HTTP/2, and you can have HTTP/2 with JSON.

I could not install gRPC — the machine had no network — so I did not benchmark gRPC. I benchmarked its two ingredients separately, because they are almost always argued as one thing. There is a section below on exactly what that leaves out.

The short answer

  • Binary encoding is at parity with JSON.stringify and about twice as fast to decode. Measured: 0.81 µs vs 0.89 µs to encode a small message, 0.57 µs vs 1.32 µs to decode it. Microseconds; a typical handler is milliseconds.
  • A hand-rolled binary encoder written the obvious way is slower than JSON. Allocating a buffer per nested message took 1.33 µs against JSON.stringify's 0.89 µs. JSON.stringify is C++ inside V8 and it is good.
  • Gzipped JSON beat raw protobuf-format binary on size, by a lot. For the same 63,130-byte payload: 3,610 bytes gzipped, 22,823 bytes binary. That is 6.3x smaller for the format people call bigger.
  • The transport is where the time is. Over TLS, 400 requests took 181 ms on HTTP/1.1 with six connections and 31 ms on HTTP/2 with one — a 5.8x gap, none of it caused by the payload format.
  • HTTP/2's win is connection count, not raw throughput. Given 50 TCP connections, plain HTTP/1.1 ran within 26% of HTTP/2. The gap opens when connections are scarce or expensive, which over TLS they always are.

What is actually being compared when people say "gRPC vs REST"?

Three separate things, bundled:

  1. A serialisation format — protobuf's binary encoding instead of JSON text.
  2. A transport — HTTP/2 with multiplexed streams instead of HTTP/1.1.
  3. A contract and a code generator.proto files, generated stubs, typed clients in every language.

Only the first two are performance claims, and they are independent. You can serve protobuf over HTTP/1.1, and you can serve JSON over HTTP/2 by changing one import. Bundling them makes the transport's advantage look like the format's, so I measured them apart.

Gzipped JSON is smaller than an uncompressed binary encoding of the same data

Hardware and versions. Apple M3, 16 GB RAM, macOS 26.4.1, Node 23.5.0. Loopback only, ports 55626-55629; nothing crosses a network card. Each serialisation figure is the median of 7 timed runs after a warm-up pass; each HTTP figure is the median of 3 runs of 400 requests. I ran the whole file three times and quote the third run throughout.

How much faster is binary serialisation than JSON?

I wrote a protobuf-format encoder by hand — base-128 varints, length-delimited strings, (field << 3 | wiretype) tags — for an order message with a repeated nested item type. It round-trips 200 orders byte-identically, which the script asserts before timing anything.

I wrote it twice. The first version allocates a Writer per nested message, the obvious way. The second computes every length up front and writes into one exact-sized buffer, what a generated encoder does. The difference between those two is larger than the difference between either and JSON.

Microseconds per operation, median of 7 runs:

Payload JSON.stringify binary, naive binary, two-pass best binary vs JSON
1 order, 3 items 0.89 1.33 0.81 1.10x
200 orders, ~800 items 138.26 245.55 114.79 1.20x
Payload JSON.parse binary decode ratio
1 order, 3 items 1.32 0.57 2.31x
200 orders, ~800 items 221.35 118.14 1.87x

Decoding is where binary genuinely wins, by about 2x, consistently across all three runs (2.21x, 2.29x, 2.31x for the small message). The cause is real: JSON.parse scans for delimiters, handles escapes and builds a string for every key. The binary reader jumps by lengths it was told.

Encoding is close to a wash. JSON.stringify is not a JavaScript function; it is optimised C++ inside V8, and beating it by 10-20% took a deliberately careful implementation. Every naive attempt lost to it.

Now the proportion. Encode plus decode for one small message: 2.22 µs with JSON, 1.38 µs with binary — a saving of 0.83 µs. A round trip against a server doing 2 ms of work measured 2,489 µs, so the saving is 0.034% of the request. If your service talks to a database, the format is a rounding error.

Is protobuf smaller than JSON?

Yes — and it does not matter as much as it sounds, because nobody sends uncompressed JSON.

Bytes, for the same data:

Payload JSON JSON + gzip JSON + brotli binary binary + gzip
1 order, 3 items 316 212 169 113 128
200 orders, ~800 items 63,130 3,610 2,108 22,823 3,037

The binary encoding is consistently 0.36x the size of raw JSON — 0.358x small, 0.362x large. That is where "protobuf is 3x smaller" comes from, and it is correct.

But look at the second row. Gzipped JSON is 3,610 bytes against 22,823 bytes of raw binary. Gzipped JSON is 6.3x smaller than uncompressed protobuf-format binary, and brotli makes it 10.8x smaller. Repeated JSON keys are the most compressible bytes in computing: "customer": appearing 200 times costs almost nothing once LZ77 finds it. Protobuf has no keys to repeat and so less redundancy to give away — gzipping the binary got it to 3,037 bytes, 16% better than gzipped JSON, not 6x.

The small-message row has its own lesson: gzipping 113 bytes of binary produced 128 bytes. The gzip header and trailer cost more than the compression saved — the same threshold question as gzip or brotli for APIs.

Compression is not free in CPU either: gzipping the large JSON payload took 377.63 µs against 138.26 µs to stringify it, 2.7x the serialisation cost. A genuine trade — just not the one the gRPC pitch describes.

Does HTTP/2 multiplexing make requests faster?

This is the half that pays. Node has http2 built in, so I ran 400 requests against two servers doing 2 ms of work each, first over plaintext loopback.

Transport median ms min-max TCP conns req/s
HTTP/1.1 keep-alive, 1 socket, serial 995.6 983.1-998.4 1 402
HTTP/2, 1 connection, serial 1060.1 1027.3-1116.7 1 377
HTTP/1.1 keep-alive, 6 sockets 174.6 171.9-179.5 6 2,292
HTTP/2, 1 connection, 6 in flight 172.5 171.1-175.0 1 2,319
HTTP/1.1 keep-alive, 50 sockets 38.4 35.4-41.5 50 10,416
HTTP/2, 1 connection, 50 in flight 30.4 30.1-32.5 1 13,143
HTTP/1.1 no keep-alive, 50 sockets 31.8 30.0-34.8 108 12,587

Read the first two rows first. One request at a time, HTTP/2 is slower — 1060 ms against 996 ms. Framing, HPACK and stream bookkeeping are not free, and with no concurrency there is nothing to multiplex.

Rows three and four are the browser's world, where HTTP/1.1 is capped at six connections per origin. HTTP/2 on one connection matches six HTTP/1.1 connections exactly — 172.5 ms against 174.6 ms — on a sixth of the sockets.

Rows five and six are the honest disappointment. Give an HTTP/1.1 client 50 sockets and it lands within 26% of HTTP/2: multiplexing 50 streams over one connection is the same concurrency, spelled differently. On loopback a TCP connection is nearly free, so the last row — no keep-alive, 108 connections for 400 requests — costs almost nothing. That is where the plaintext test lies to you, so I ran it again over TLS.

Transport (HTTPS) median ms min-max TLS handshakes req/s
HTTP/1.1 keep-alive, 6 sockets 181.1 174.9-181.9 0 2,209
HTTP/1.1 keep-alive, 50 sockets 63.7 54.3-78.6 42 6,279
HTTP/1.1 no keep-alive, 50 sockets 119.3 116.8-146.8 105 3,354
HTTP/2 over TLS, 1 conn, 50 streams 31.0 29.2-33.6 0 12,887

One TLS 1.3 handshake against an RSA-2048 certificate measured 1.18 ms on loopback, and that is the floor — over a real network you pay one or two round trips on top. The counter resets after a warm-up pass, so 0 means no new connections during the measured window: HTTP/2 opened one and kept it.

Dropping keep-alive cost 105 handshakes and took the run from 63.7 ms to 119.3 ms — a 1.9x penalty with nothing to do with the payload format. HTTP/2 finished in 31 ms on a single connection: twice as fast as the best HTTP/1.1 configuration, 5.8x faster than the six-connection one.

That 5.8x is the number quoted as "gRPC is faster". It is HTTP/2, and a JSON API served over HTTP/2 gets the same 5.8x.

What I could not measure

This is the section that decides whether the rest is worth anything.

I did not benchmark gRPC. No @grpc/grpc-js, no protobufjs, no network to install them from. I implemented protobuf's wire format and measured that. My encoder is simpler than a real one — no unknown-field retention, no oneof, no maps, no true 64-bit integers (JS numbers are safe to 2^53, real int64 ids are not). A generated encoder is probably faster than mine. It is not going to turn 0.83 µs into a millisecond.

gRPC's own framing is not in these numbers. It adds a five-byte length prefix per message, trailers, status codes, deadline propagation and per-call metadata on top of HTTP/2 — overhead I did not pay or measure.

Code generation — reasoning, not measurement. The strongest argument for gRPC, and not a performance one. A .proto file is a contract a compiler checks: rename a field and every consumer fails to build instead of silently reading undefined. No benchmark shows that. It shows up in the bugs you do not have six months later.

Streaming — reasoning, not measurement. gRPC gives you server, client and bidirectional streaming as ordinary function signatures. Over JSON and HTTP you build that from SSE, WebSockets or long-polling, and each is a project. A persistent bidirectional channel is a real reason to switch, and I did not benchmark it.

Cross-language contracts — reasoning, not measurement. One .proto compiled to Go, Python, Java and TypeScript gives four clients that agree by construction. The alternative is OpenAPI, four generators and the drift between them. An organisational property, not a millisecond.

Real network conditions. Everything here is loopback: no packet loss, no bandwidth limit, sub-millisecond RTT. HTTP/2's advantage grows when RTT is real, because connection reuse avoids handshakes, and shrinks when loss is real, because TCP head-of-line blocking stalls every stream on the connection at once — the problem QUIC exists to solve. I could test neither.

So when should you reach for gRPC?

When you want the contract and the streaming. Not when you want the speed.

If someone proposes gRPC to make an API faster, the cheap experiment is to serve the existing JSON API over HTTP/2 first. That is where the 5.8x lives, it costs one import in Node, and it does not mean regenerating clients in four languages. Add gzip above a few hundred bytes and you have beaten raw protobuf on size too.

Keep gRPC on the list for internal service-to-service traffic where a schema compiler catches breaking changes at build time, and for anything genuinely bidirectional. Those are good reasons. "It's binary so it's faster" is worth 0.83 microseconds.

Neither choice changes the hard parts: which errors to retry is the same question over gRPC status codes as over HTTP ones, and rate limits, theirs and yours does not care what your bytes look like.

Check it yourself

One file, no dependencies, loopback only, ports 55626-55629. It asserts the encoder round-trips before timing anything, then runs every measurement above in about 12 seconds. It needs a self-signed certificate for the TLS section:

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
  -days 2 -nodes -subj "/CN=localhost"
node wire-lab.mjs
// wire-lab.mjs — Node 23. Loopback only, ports 55626-55629. No dependencies.
import http from 'node:http'
import https from 'node:https'
import http2 from 'node:http2'
import tls from 'node:tls'
import fs from 'node:fs'
import zlib from 'node:zlib'

// ============================================================ 1. the encoder
// Protobuf wire format, by hand. Fields are (number << 3 | wiretype) varints.
// wiretype 0 = varint, 2 = length-delimited.

class Writer {
  constructor () { this.b = Buffer.allocUnsafe(256); this.n = 0 }
  _room (k) {
    if (this.n + k <= this.b.length) return
    const b = Buffer.allocUnsafe(Math.max(this.b.length * 2, this.n + k))
    this.b.copy(b, 0, 0, this.n); this.b = b
  }
  varint (v) {                                   // base-128, low bits first
    this._room(10)
    while (v > 127) { this.b[this.n++] = (v & 127) | 128; v = Math.floor(v / 128) }
    this.b[this.n++] = v
  }
  tag (f, w) { this.varint(f * 8 + w) }
  uint (f, v) { if (!v) return; this.tag(f, 0); this.varint(v) }
  str (f, s) {
    if (!s) return
    const k = Buffer.byteLength(s)
    this.tag(f, 2); this.varint(k); this._room(k)
    this.b.write(s, this.n, 'utf8'); this.n += k
  }
  sub (f, buf) { this.tag(f, 2); this.varint(buf.length); this._room(buf.length); buf.copy(this.b, this.n); this.n += buf.length }
  out () { return this.b.subarray(0, this.n) }
}

class Reader {
  constructor (b, p = 0, end = b.length) { this.b = b; this.p = p; this.end = end }
  varint () {                                    // multiply, not shift: ids exceed 32 bits
    let r = 0, s = 1
    for (;;) { const c = this.b[this.p++]; r += (c & 127) * s; if (c < 128) return r; s *= 128 }
  }
  str () { const k = this.varint(); const s = this.b.toString('utf8', this.p, this.p + k); this.p += k; return s }
  skip (w) {
    if (w === 0) this.varint()
    else if (w === 2) this.p += this.varint()
    else if (w === 5) this.p += 4
    else if (w === 1) this.p += 8
  }
}

// message Item { 1 string sku; 2 uint32 qty; 3 uint32 unit_cents }
function encItem (it) {
  const w = new Writer()
  w.str(1, it.sku); w.uint(2, it.qty); w.uint(3, it.unit_cents)
  return w.out()
}
function decItem (r) {
  const k = r.varint(); const end = r.p + k; const it = {}
  while (r.p < end) {
    const t = r.varint(), f = t >>> 3, wt = t & 7
    if (f === 1) it.sku = r.str()
    else if (f === 2) it.qty = r.varint()
    else if (f === 3) it.unit_cents = r.varint()
    else r.skip(wt)
  }
  return it
}

// message Order { 1 uint64 id; 2 string customer; 3 string currency;
//                 4 uint64 total_cents; 5 uint64 created_at; 6 Status status;
//                 7 repeated Item items }
function encOrder (o) {
  const w = new Writer()
  w.uint(1, o.id); w.str(2, o.customer); w.str(3, o.currency)
  w.uint(4, o.total_cents); w.uint(5, o.created_at); w.uint(6, o.status)
  for (const it of o.items) w.sub(7, encItem(it))
  return w.out()
}
function decOrder (r) {
  const k = r.varint(); const end = r.p + k; const o = { items: [] }
  while (r.p < end) {
    const t = r.varint(), f = t >>> 3, wt = t & 7
    if (f === 1) o.id = r.varint()
    else if (f === 2) o.customer = r.str()
    else if (f === 3) o.currency = r.str()
    else if (f === 4) o.total_cents = r.varint()
    else if (f === 5) o.created_at = r.varint()
    else if (f === 6) o.status = r.varint()
    else if (f === 7) o.items.push(decItem(r))
    else r.skip(wt)
  }
  return o
}

// message Batch { 1 repeated Order orders }
function encBatch (list) {
  const w = new Writer()
  for (const o of list) w.sub(1, encOrder(o))
  return w.out()
}
function decBatch (buf) {
  const r = new Reader(buf); const out = []
  while (r.p < r.end) {
    const t = r.varint(), wt = t & 7
    if ((t >>> 3) === 1) out.push(decOrder(r)); else r.skip(wt)
  }
  return out
}

// --- the same format again, written the way a generated encoder writes it ---
// One allocation, exact size computed up front, no nested Writer objects.
function vlen (v) { let n = 1; while (v > 127) { v = Math.floor(v / 128); n++ } return n }
function slen (s) { const k = Buffer.byteLength(s); return 1 + vlen(k) + k }

function encBatch2 (list) {
  const isz = [], osz = []
  let total = 0
  for (const o of list) {
    let n = 0
    if (o.id) n += 1 + vlen(o.id)
    if (o.customer) n += slen(o.customer)
    if (o.currency) n += slen(o.currency)
    if (o.total_cents) n += 1 + vlen(o.total_cents)
    if (o.created_at) n += 1 + vlen(o.created_at)
    if (o.status) n += 1 + vlen(o.status)
    for (const it of o.items) {
      let m = 0
      if (it.sku) m += slen(it.sku)
      if (it.qty) m += 1 + vlen(it.qty)
      if (it.unit_cents) m += 1 + vlen(it.unit_cents)
      isz.push(m); n += 1 + vlen(m) + m
    }
    osz.push(n); total += 1 + vlen(n) + n
  }
  const b = Buffer.allocUnsafe(total)
  let p = 0, oi = 0, ii = 0
  const wv = v => { while (v > 127) { b[p++] = (v & 127) | 128; v = Math.floor(v / 128) } b[p++] = v }
  const ws = s => { const k = Buffer.byteLength(s); wv(k); b.write(s, p, 'utf8'); p += k }
  for (const o of list) {
    b[p++] = 10; wv(osz[oi++])                            // field 1, wiretype 2
    if (o.id) { b[p++] = 8; wv(o.id) }
    if (o.customer) { b[p++] = 18; ws(o.customer) }
    if (o.currency) { b[p++] = 26; ws(o.currency) }
    if (o.total_cents) { b[p++] = 32; wv(o.total_cents) }
    if (o.created_at) { b[p++] = 40; wv(o.created_at) }
    if (o.status) { b[p++] = 48; wv(o.status) }
    for (const it of o.items) {
      b[p++] = 58; wv(isz[ii++])
      if (it.sku) { b[p++] = 10; ws(it.sku) }
      if (it.qty) { b[p++] = 16; wv(it.qty) }
      if (it.unit_cents) { b[p++] = 24; wv(it.unit_cents) }
    }
  }
  return b
}

// ============================================================= 2. the data
const SKUS = ['WID-4471', 'BRK-0092', 'CBL-1180', 'MNT-7734', 'PSU-0451', 'FAN-2210']
function order (i) {
  return {
    id: 918273645000 + i,
    customer: 'acme-industrial-' + (i % 97),
    currency: 'USD',
    total_cents: 1499500 + i * 37,
    created_at: 1756598400 + i,
    status: (i % 4) + 1,
    items: Array.from({ length: 3 + (i % 3) }, (_, j) => ({
      sku: SKUS[(i + j) % SKUS.length], qty: 1 + ((i + j) % 9), unit_cents: 4999 + j * 250
    }))
  }
}
const ONE = [order(1)]
const MANY = Array.from({ length: 200 }, (_, i) => order(i))

// correctness gate — an encoder that loses data is not a benchmark
const canon = v => JSON.stringify(v, (k, x) =>
  (x && typeof x === 'object' && !Array.isArray(x))
    ? Object.fromEntries(Object.keys(x).sort().map(k2 => [k2, x[k2]]))
    : x)
const rt = decBatch(encBatch(MANY))
if (canon(rt) !== canon(MANY)) { console.error('ROUND TRIP MISMATCH'); process.exit(1) }
if (!encBatch2(MANY).equals(encBatch(MANY))) { console.error('TWO-PASS ENCODER DISAGREES'); process.exit(1) }
if (canon(decBatch(encBatch2(MANY))) !== canon(MANY)) { console.error('TWO-PASS ROUND TRIP MISMATCH'); process.exit(1) }
console.log('round-trip check: binary decode === original for all ' + MANY.length + ' orders')
console.log('two-pass encoder produces byte-identical output to the naive one\n')

// =========================================================== 3. payload size
const sizes = []
for (const [label, data] of [['1 order, 3 items', ONE], ['200 orders, ~800 items', MANY]]) {
  const json = Buffer.from(JSON.stringify(data))
  const bin = encBatch(data)
  sizes.push({
    label,
    json: json.length,
    jsonGz: zlib.gzipSync(json, { level: 6 }).length,
    jsonBr: zlib.brotliCompressSync(json).length,
    bin: bin.length,
    binGz: zlib.gzipSync(bin, { level: 6 }).length
  })
}
console.log('=== payload size, bytes ===')
console.log('  payload                  JSON  JSON+gzip  JSON+br   binary  binary+gzip')
for (const s of sizes) {
  console.log('  ' + s.label.padEnd(23) +
    String(s.json).padStart(6) + String(s.jsonGz).padStart(11) + String(s.jsonBr).padStart(9) +
    String(s.bin).padStart(9) + String(s.binGz).padStart(13))
}
console.log('')
console.log('=== size, as a ratio to raw JSON ===')
for (const s of sizes) {
  const r = v => (v / s.json).toFixed(3)
  console.log('  ' + s.label.padEnd(23) + 'gzip ' + r(s.jsonGz) + '   brotli ' + r(s.jsonBr) +
    '   binary ' + r(s.bin) + '   binary+gzip ' + r(s.binGz))
}
console.log('')

// ======================================================== 4. serialisation
const RUNS = 7
function bench (fn, iters) {
  for (let i = 0; i < Math.max(200, iters); i++) fn()      // warm the JIT
  const ms = []
  for (let r = 0; r < RUNS; r++) {
    const t = process.hrtime.bigint()
    for (let i = 0; i < iters; i++) fn()
    ms.push(Number(process.hrtime.bigint() - t) / 1e6)
  }
  ms.sort((a, b) => a - b)
  const med = ms[(RUNS - 1) >> 1]
  return { usPerOp: (med * 1000) / iters, spread: ((ms[RUNS - 1] - ms[0]) / med) * 100, med }
}

const cases = []
for (const [label, data, iters] of [['1 order', ONE, 20000], ['200 orders', MANY, 300]]) {
  const jsonBuf = Buffer.from(JSON.stringify(data))
  const binBuf = encBatch(data)
  const gzBuf = zlib.gzipSync(jsonBuf, { level: 6 })
  cases.push({
    label,
    enc: {
      json: bench(() => Buffer.from(JSON.stringify(data)), iters),
      bin: bench(() => encBatch(data), iters),
      bin2: bench(() => encBatch2(data), iters),
      jsonGz: bench(() => zlib.gzipSync(Buffer.from(JSON.stringify(data)), { level: 6 }), Math.max(50, iters / 20 | 0))
    },
    dec: {
      json: bench(() => JSON.parse(jsonBuf.toString('utf8')), iters),
      bin: bench(() => decBatch(binBuf), iters),
      jsonGz: bench(() => JSON.parse(zlib.gunzipSync(gzBuf).toString('utf8')), Math.max(50, iters / 20 | 0))
    }
  })
}
console.log('=== encode: object -> bytes, microseconds per op (median of ' + RUNS + ' runs) ===')
console.log('  payload      JSON.stringify   binary naive   binary 2-pass   best binary vs JSON   JSON+gzip')
for (const c of cases) {
  const best = Math.min(c.enc.bin.usPerOp, c.enc.bin2.usPerOp)
  console.log('  ' + c.label.padEnd(12) +
    c.enc.json.usPerOp.toFixed(2).padStart(12) + c.enc.bin.usPerOp.toFixed(2).padStart(15) +
    c.enc.bin2.usPerOp.toFixed(2).padStart(16) +
    ((c.enc.json.usPerOp / best).toFixed(2) + 'x').padStart(22) +
    c.enc.jsonGz.usPerOp.toFixed(2).padStart(12))
}
console.log('')
console.log('=== decode: bytes -> object, microseconds per op (median of ' + RUNS + ' runs) ===')
console.log('  payload      JSON.parse       hand-written binary   ratio   gunzip+parse')
for (const c of cases) {
  console.log('  ' + c.label.padEnd(12) +
    c.dec.json.usPerOp.toFixed(2).padStart(12) + c.dec.bin.usPerOp.toFixed(2).padStart(20) +
    ('  ' + (c.dec.json.usPerOp / c.dec.bin.usPerOp).toFixed(2) + 'x').padStart(9) +
    c.dec.jsonGz.usPerOp.toFixed(2).padStart(12))
}
console.log('')
console.log('=== run-to-run spread, worst minus best as % of median ===')
for (const c of cases) {
  console.log('  ' + c.label.padEnd(12) +
    'json-enc ' + c.enc.json.spread.toFixed(1) + '%  bin2-enc ' + c.enc.bin2.spread.toFixed(1) +
    '%  json-dec ' + c.dec.json.spread.toFixed(1) + '%  bin-dec ' + c.dec.bin.spread.toFixed(1) + '%')
}
console.log('')

// ============================================ 5. HTTP/1.1 keep-alive vs HTTP/2
const BODY = encBatch(ONE)
let h1conns = 0, h2conns = 0
const THINK = 2                                   // ms of server-side work per request

const h1 = http.createServer((req, res) => {
  req.resume()
  setTimeout(() => { res.writeHead(200, { 'content-type': 'application/octet-stream' }); res.end(BODY) }, THINK)
})
h1.on('connection', () => h1conns++)

const h2s = http2.createServer((req, res) => {
  req.resume()
  setTimeout(() => { res.writeHead(200, { 'content-type': 'application/octet-stream' }); res.end(BODY) }, THINK)
})
h2s.on('connection', () => h2conns++)

await new Promise(r => h1.listen(55626, '127.0.0.1', r))
await new Promise(r => h2s.listen(55627, '127.0.0.1', r))

function h1Get (agent) {
  return new Promise((res, rej) => {
    const q = http.request({ host: '127.0.0.1', port: 55626, path: '/orders', agent }, r => {
      r.resume(); r.on('end', res)
    })
    q.on('error', rej); q.end()
  })
}
function h2Get (session) {
  return new Promise((res, rej) => {
    const s = session.request({ ':path': '/orders' })
    s.resume(); s.on('end', res); s.on('error', rej)
  })
}

async function pool (n, inflight, make) {
  let i = 0
  await Promise.all(Array.from({ length: inflight }, async () => {
    while (i < n) { i++; await make() }
  }))
}

const N = 400
async function timeH1 (sockets, inflight) {
  const agent = new http.Agent({ keepAlive: true, maxSockets: sockets })
  h1conns = 0
  await pool(40, Math.min(inflight, 8), () => h1Get(agent))       // warm
  const t = process.hrtime.bigint()
  await pool(N, inflight, () => h1Get(agent))
  const ms = Number(process.hrtime.bigint() - t) / 1e6
  agent.destroy()
  return { ms, conns: h1conns }
}
async function timeH2 (inflight) {
  const session = http2.connect('http://127.0.0.1:55627')
  await new Promise(r => session.on('connect', r))
  h2conns = 0
  await pool(40, Math.min(inflight, 8), () => h2Get(session))     // warm
  const t = process.hrtime.bigint()
  await pool(N, inflight, () => h2Get(session))
  const ms = Number(process.hrtime.bigint() - t) / 1e6
  session.close()
  return { ms, conns: 1 }
}

async function best3 (fn) {
  const rs = []
  for (let i = 0; i < 3; i++) rs.push(await fn())
  rs.sort((a, b) => a.ms - b.ms)
  return { med: rs[1].ms, lo: rs[0].ms, hi: rs[2].ms, conns: rs[1].conns }
}

console.log('=== ' + N + ' requests, ' + THINK + ' ms server work each (median of 3 runs) ===')
console.log('  transport                        median ms   min-max ms      TCP conns   req/s')
const rows = [
  ['HTTP/1.1 keep-alive, 1 socket, serial', () => timeH1(1, 1)],
  ['HTTP/2, 1 connection, serial', () => timeH2(1)],
  ['HTTP/1.1 keep-alive, 6 sockets', () => timeH1(6, 6)],
  ['HTTP/2, 1 connection, 6 in flight', () => timeH2(6)],
  ['HTTP/1.1 keep-alive, 50 sockets', () => timeH1(50, 50)],
  ['HTTP/2, 1 connection, 50 in flight', () => timeH2(50)],
  ['HTTP/1.1 NO keep-alive, 50 sockets', async () => {
    const agent = new http.Agent({ keepAlive: false, maxSockets: 50 })
    h1conns = 0
    await pool(40, 8, () => h1Get(agent))
    const t = process.hrtime.bigint()
    await pool(N, 50, () => h1Get(agent))
    const ms = Number(process.hrtime.bigint() - t) / 1e6
    agent.destroy()
    return { ms, conns: h1conns }
  }]
]
let serialMs = 0
for (const [label, fn] of rows) {
  const r = await best3(fn)
  if (label.startsWith('HTTP/1.1 keep-alive, 1 socket')) serialMs = r.med
  console.log('  ' + label.padEnd(36) + r.med.toFixed(1).padStart(9) +
    (r.lo.toFixed(1) + '-' + r.hi.toFixed(1)).padStart(15) +
    String(r.conns).padStart(12) + (N / (r.med / 1000)).toFixed(0).padStart(9))
}
console.log('')

// ================================ 6. the same race over TLS, where connections cost
const KEY = fs.readFileSync(new URL('./key.pem', import.meta.url))
const CERT = fs.readFileSync(new URL('./cert.pem', import.meta.url))
let tlsConns = 0

const h1t = https.createServer({ key: KEY, cert: CERT }, (req, res) => {
  req.resume()
  setTimeout(() => { res.writeHead(200, { 'content-type': 'application/octet-stream' }); res.end(BODY) }, THINK)
})
h1t.on('secureConnection', () => tlsConns++)
const h2t = http2.createSecureServer({ key: KEY, cert: CERT }, (req, res) => {
  req.resume()
  setTimeout(() => { res.writeHead(200, { 'content-type': 'application/octet-stream' }); res.end(BODY) }, THINK)
})
h2t.on('secureConnection', () => tlsConns++)
await new Promise(r => h1t.listen(55628, '127.0.0.1', r))
await new Promise(r => h2t.listen(55629, '127.0.0.1', r))

const TLSOPT = { rejectUnauthorized: false, servername: 'localhost' }
function h1tGet (agent) {
  return new Promise((res, rej) => {
    const q = https.request({ host: '127.0.0.1', port: 55628, path: '/orders', agent, ...TLSOPT },
      r => { r.resume(); r.on('end', res) })
    q.on('error', rej); q.end()
  })
}

// bare handshake cost, nothing else in the way
{
  const one = async () => {
    const t = process.hrtime.bigint()
    for (let i = 0; i < 20; i++) {
      await new Promise((res, rej) => {
        const s = tls.connect({ host: '127.0.0.1', port: 55628, ...TLSOPT }, () => { s.destroy(); res() })
        s.on('error', rej)
      })
    }
    return Number(process.hrtime.bigint() - t) / 1e6 / 20
  }
  const r = []; for (let i = 0; i < 3; i++) r.push(await one())
  r.sort((a, b) => a - b)
  console.log('=== TLS 1.3 handshake, RSA-2048 self-signed, over loopback ===')
  console.log('  cost of ONE new connection: ' + r[1].toFixed(2) + ' ms  (min ' + r[0].toFixed(2) + ', max ' + r[2].toFixed(2) + ')')
  console.log('')
}

console.log('=== ' + N + ' HTTPS requests, ' + THINK + ' ms server work each (median of 3 runs) ===')
console.log('  transport                        median ms   min-max ms   TLS handshakes   req/s')
const tlsRows = [
  ['HTTPS/1.1 keep-alive, 6 sockets', async () => {
    const agent = new https.Agent({ keepAlive: true, maxSockets: 6 })
    await pool(40, 6, () => h1tGet(agent)); tlsConns = 0
    const t = process.hrtime.bigint(); await pool(N, 6, () => h1tGet(agent))
    const ms = Number(process.hrtime.bigint() - t) / 1e6; agent.destroy(); return { ms, conns: tlsConns }
  }],
  ['HTTPS/1.1 keep-alive, 50 sockets', async () => {
    const agent = new https.Agent({ keepAlive: true, maxSockets: 50 })
    await pool(40, 8, () => h1tGet(agent)); tlsConns = 0
    const t = process.hrtime.bigint(); await pool(N, 50, () => h1tGet(agent))
    const ms = Number(process.hrtime.bigint() - t) / 1e6; agent.destroy(); return { ms, conns: tlsConns }
  }],
  ['HTTPS/1.1 NO keep-alive, 50 sockets', async () => {
    const agent = new https.Agent({ keepAlive: false, maxSockets: 50 })
    await pool(40, 8, () => h1tGet(agent)); tlsConns = 0
    const t = process.hrtime.bigint(); await pool(N, 50, () => h1tGet(agent))
    const ms = Number(process.hrtime.bigint() - t) / 1e6; agent.destroy(); return { ms, conns: tlsConns }
  }],
  ['HTTP/2 over TLS, 1 conn, 50 streams', async () => {
    const session = http2.connect('https://127.0.0.1:55629', TLSOPT)
    await new Promise(r => session.on('connect', r))
    await pool(40, 8, () => h2Get(session)); tlsConns = 0
    const t = process.hrtime.bigint(); await pool(N, 50, () => h2Get(session))
    const ms = Number(process.hrtime.bigint() - t) / 1e6; session.close(); return { ms, conns: tlsConns }
  }]
]
for (const [label, fn] of tlsRows) {
  const r = await best3(fn)
  console.log('  ' + label.padEnd(36) + r.med.toFixed(1).padStart(9) +
    (r.lo.toFixed(1) + '-' + r.hi.toFixed(1)).padStart(15) +
    String(r.conns).padStart(15) + (N / (r.med / 1000)).toFixed(0).padStart(9))
}
console.log('')
h1t.close(); h2t.close()

// ============================================== 7. bytes on the wire per request
const jsonOne = Buffer.from(JSON.stringify(ONE))
console.log('=== one small response, bytes on the wire ===')
console.log('  JSON body                  ' + jsonOne.length)
console.log('  binary body                ' + BODY.length)
console.log('  JSON body gzipped          ' + zlib.gzipSync(jsonOne, { level: 6 }).length)
console.log('  typical HTTP/1.1 headers   ~' + Buffer.byteLength(
  'HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nDate: Sun, 31 Aug 2026 00:00:00 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n'))
console.log('')

// ================================================= 8. put the two in proportion
{
  const c = cases[0]
  const serial = Math.min(c.enc.bin2.usPerOp, c.enc.bin.usPerOp) + c.dec.bin.usPerOp
  const jserial = c.enc.json.usPerOp + c.dec.json.usPerOp
  const rtus = serialMs / N * 1000                  // measured serial round trip, us
  console.log('=== serialisation as a share of one round trip (1-order message) ===')
  console.log('  JSON   encode + decode: ' + jserial.toFixed(2) + ' us')
  console.log('  binary encode + decode: ' + serial.toFixed(2) + ' us')
  console.log('  saved per request:      ' + (jserial - serial).toFixed(2) + ' us')
  console.log('  measured serial round trip (HTTP/1.1, 2 ms handler): ~' + rtus.toFixed(0) + ' us')
  console.log('  the saving is ' + ((jserial - serial) / rtus * 100).toFixed(3) + '% of the round trip')
}

h1.close(); h2s.close()
process.exit(0)

The whole run, on Node 23.5.0 / macOS 26.4.1 / Apple M3:

round-trip check: binary decode === original for all 200 orders
two-pass encoder produces byte-identical output to the naive one

=== payload size, bytes ===
  payload                  JSON  JSON+gzip  JSON+br   binary  binary+gzip
  1 order, 3 items          316        212      169      113          128
  200 orders, ~800 items  63130       3610     2108    22823         3037

=== size, as a ratio to raw JSON ===
  1 order, 3 items       gzip 0.671   brotli 0.535   binary 0.358   binary+gzip 0.405
  200 orders, ~800 items gzip 0.057   brotli 0.033   binary 0.362   binary+gzip 0.048

=== encode: object -> bytes, microseconds per op (median of 7 runs) ===
  payload      JSON.stringify   binary naive   binary 2-pass   best binary vs JSON   JSON+gzip
  1 order             0.89           1.33            0.81                 1.10x       12.12
  200 orders        138.26         245.55          114.79                 1.20x      377.63

=== decode: bytes -> object, microseconds per op (median of 7 runs) ===
  payload      JSON.parse       hand-written binary   ratio   gunzip+parse
  1 order             1.32                0.57    2.31x        5.51
  200 orders        221.35              118.14    1.87x      248.29

=== run-to-run spread, worst minus best as % of median ===
  1 order     json-enc 26.8%  bin2-enc 47.0%  json-dec 11.3%  bin-dec 33.5%
  200 orders  json-enc 11.7%  bin2-enc 15.9%  json-dec 33.1%  bin-dec 41.5%

=== 400 requests, 2 ms server work each (median of 3 runs) ===
  transport                        median ms   min-max ms      TCP conns   req/s
  HTTP/1.1 keep-alive, 1 socket, serial    995.6    983.1-998.4           1      402
  HTTP/2, 1 connection, serial           1060.1  1027.3-1116.7           1      377
  HTTP/1.1 keep-alive, 6 sockets          174.6    171.9-179.5           6     2292
  HTTP/2, 1 connection, 6 in flight       172.5    171.1-175.0           1     2319
  HTTP/1.1 keep-alive, 50 sockets          38.4      35.4-41.5          50    10416
  HTTP/2, 1 connection, 50 in flight       30.4      30.1-32.5           1    13143
  HTTP/1.1 NO keep-alive, 50 sockets       31.8      30.0-34.8         108    12587

=== TLS 1.3 handshake, RSA-2048 self-signed, over loopback ===
  cost of ONE new connection: 1.18 ms  (min 1.12, max 1.42)

=== 400 HTTPS requests, 2 ms server work each (median of 3 runs) ===
  transport                        median ms   min-max ms   TLS handshakes   req/s
  HTTPS/1.1 keep-alive, 6 sockets         181.1    174.9-181.9              0     2209
  HTTPS/1.1 keep-alive, 50 sockets         63.7      54.3-78.6             42     6279
  HTTPS/1.1 NO keep-alive, 50 sockets     119.3    116.8-146.8            105     3354
  HTTP/2 over TLS, 1 conn, 50 streams      31.0      29.2-33.6              0    12887

=== one small response, bytes on the wire ===
  JSON body                  316
  binary body                113
  JSON body gzipped          212
  typical HTTP/1.1 headers   ~171

=== serialisation as a share of one round trip (1-order message) ===
  JSON   encode + decode: 2.22 us
  binary encode + decode: 1.38 us
  saved per request:      0.83 us
  measured serial round trip (HTTP/1.1, 2 ms handler): ~2489 us
  the saving is 0.034% of the round trip

Note the spread line. The microsecond measurements swing up to 47% between runs on a laptop with other things happening, which is why the ratios matter and the absolute figures do not. Across three full runs the decode ratio held at 2.21x, 2.29x and 2.31x; the encode ratio wandered between 1.10x and 1.39x.

The two lines to compare are saved per request: 0.83 us and the 150 ms between the first and last row of the HTTPS table. One is an argument for changing your serialisation format. The other is an argument for turning on HTTP/2, which you can do without changing anything else.

Related: gzip or brotli for a JSON API, which measures the compression this article leans on.