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.
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.stringifyand 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.stringifyis 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:
- A serialisation format — protobuf's binary encoding instead of JSON text.
- A transport — HTTP/2 with multiplexed streams instead of HTTP/1.1.
- A contract and a code generator —
.protofiles, 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.
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.