Something went wrong. Try again.
Demo comparing html-over-the-wire w/ WS and SSE.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213// Test 5 — the compression asymmetry, isolated from the network.//// The claim being tested: for HTML-over-the-wire, WebSocket cannot reach the// compression ratios an SSE response gets for free, and closing the gap means// doing in userland what the browser already does natively.//// Three structural reasons, each measured here://// 1. ALGORITHM. permessage-deflate (RFC 7692) negotiates exactly one codec:// DEFLATE. There is no brotli extension for WebSocket. An SSE response is// just an HTTP response, so `Content-Encoding: br` applies and every// browser decodes it in the network stack.//// 2. WINDOW. DEFLATE's window maxes out at 32 KB (windowBits 15). Our board// is ~88 KB, so a repeated board cannot even reference its own previous// copy — the match falls outside the window. Brotli allows up to 16 MB.//// 3. CONTEXT TAKEOVER. permessage-deflate's ratio depends on keeping the// zlib context alive across messages, which costs ~256 KB of server memory// per connection. Proxies and servers routinely force no_context_takeover// to bound that, which resets the dictionary on every message.
import zlib from 'node:zlib'import { makeBoard, apply, renderPatch } from '../src/domain.js'
const PATCHES = Number(process.env.PATCHES ?? 100)
function makeStream(mode) { const board = makeBoard() const out = [] for (let i = 0; i < PATCHES; i++) { const row = apply(board, { id: i, nonce: i + 1 }) out.push(Buffer.from(renderPatch(board, row, mode), 'utf8')) } return out}
// A codec that keeps one context for the whole session and flushes per patch,// i.e. what a real streaming connection does.function streaming(make, flushCode) { return async (patches) => { const z = make() let acc = 0 z.on('data', (c) => (acc += c.length)) const per = [] let prev = 0 // CPU time, not wall time: we await each flush, so wall time would be // mostly event-loop scheduling. const cpu0 = process.cpuUsage() for (const p of patches) { z.write(p) // The flush callback fires after the compressor has emitted this // patch's bytes, so `acc` is exact at this point. await new Promise((res) => z.flush(flushCode, res)) per.push(acc - prev) prev = acc } const cpu = process.cpuUsage(cpu0) await new Promise((res) => z.end(res)) return { total: acc, per, cpuMs: (cpu.user + cpu.system) / 1000 } }}
// Per-message, fresh context every time — permessage-deflate with// no_context_takeover, and also what any "just gzip each message" scheme does.function perMessage(fn) { return async (patches) => { let total = 0 const per = [] const cpu0 = process.cpuUsage() for (const p of patches) { const out = fn(p) total += out.length per.push(out.length) } const cpu = process.cpuUsage(cpu0) return { total, per, cpuMs: (cpu.user + cpu.system) / 1000 } }}
const CODECS = [ { name: 'none', avail: 'both', run: async (patches) => ({ total: patches.reduce((a, b) => a + b.length, 0), per: patches.map((p) => p.length), cpuMs: 0, }), }, { name: 'deflate (ctx takeover)', avail: 'WS permessage-deflate, default', run: streaming(() => zlib.createDeflateRaw({ level: 6, windowBits: 15 }), zlib.constants.Z_SYNC_FLUSH), }, { name: 'deflate (NO ctx takeover)', avail: 'WS when proxy forces it', run: perMessage((p) => zlib.deflateRawSync(p, { level: 6, windowBits: 15 })), }, { name: 'gzip stream', avail: 'SSE Content-Encoding: gzip', run: streaming(() => zlib.createGzip({ level: 6 }), zlib.constants.Z_SYNC_FLUSH), }, { name: 'brotli q4 (w22)', avail: 'SSE Content-Encoding: br', run: streaming( () => zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4, [zlib.constants.BROTLI_PARAM_LGWIN]: 22, [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT, }, }), zlib.constants.BROTLI_OPERATION_FLUSH, ), }, { name: 'brotli q5 (w24)', avail: 'SSE Content-Encoding: br', run: streaming( () => zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5, [zlib.constants.BROTLI_PARAM_LGWIN]: 24, [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT, }, }), zlib.constants.BROTLI_OPERATION_FLUSH, ), },]
const MODES = [ { mode: 'narrow', label: 'narrow (one ~500 B row per patch)' }, { mode: 'fat', label: 'fat (whole ~88 KB board per patch)' },]
const all = []
for (const m of MODES) { const patches = makeStream(m.mode) const raw = patches.reduce((a, b) => a + b.length, 0) const rows = [] for (const c of CODECS) { const r = await c.run(patches) rows.push({ mode: m.mode, codec: c.name, avail: c.avail, total: r.total, perPatch: r.total / PATCHES, ratio: raw / r.total, // The steady-state number: the last patch, once the dictionary is warm. lastPatch: r.per[r.per.length - 1], firstPatch: r.per[0], cpuMs: +r.cpuMs.toFixed(1), }) } all.push({ ...m, raw, rows })}
function pad(s, n) { return String(s).padEnd(n)}
console.log(`\n=== Test 5: compression of an HTML patch stream (${PATCHES} patches) ===`)for (const m of all) { console.log(`\n-- ${m.label} — ${(m.raw / 1024).toFixed(1)} KB raw total\n`) console.log( pad('codec', 26) + pad('available to', 28) + pad('total', 11) + pad('avg/patch', 11) + pad('1st patch', 11) + pad('warm patch', 12) + pad('ratio', 9) + 'encode cpu', ) console.log('─'.repeat(120)) for (const r of m.rows) { console.log( pad(r.codec, 26) + pad(r.avail, 28) + pad(`${(r.total / 1024).toFixed(1)} KB`, 11) + pad(`${r.perPatch.toFixed(0)} B`, 11) + pad(`${r.firstPatch} B`, 11) + pad(`${r.lastPatch} B`, 12) + pad(`${r.ratio.toFixed(1)}x`, 9) + `${r.cpuMs} ms`, ) }}
// ---- the userland-parity cost, measured rather than asserted -------------console.log('\n-- decode cost: what the client pays, and who pays it\n')const fat = makeStream('fat')const brStream = await CODECS.find((c) => c.name === 'brotli q4 (w22)').run(fat)const dfStream = await CODECS.find((c) => c.name === 'deflate (ctx takeover)').run(fat)console.log( `SSE + Content-Encoding: br -> ${(brStream.total / 1024).toFixed(1)} KB, decoded by the browser's\n` + ` network stack. Zero JS, off the main thread.\n`,)console.log( `WS + permessage-deflate -> ${(dfStream.total / 1024).toFixed(1)} KB, decoded natively too —\n` + ` but that is the ceiling. ${(dfStream.total / brStream.total).toFixed(1)}x more bytes than br.\n`,)console.log( `WS + brotli to match -> you ship a brotli decoder to the client and run it\n` + ` per message in JS/WASM, on top of a codec the browser\n` + ` already has. Nothing in the platform will do it for you.\n`,)
console.log(JSON.stringify({ test: 'compression', patches: PATCHES, modes: all }))