Something went wrong. Try again.
Demo comparing html-over-the-wire w/ WS and SSE.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934<title>Transport is not the bottleneck</title><style>/* ── tokens: light is the base, dark redefines only values ─────────────── */:root { --paper: #F7F8FA; --surface: #FFFFFF; --surface-2: #EEF1F5; --ink: #12161D; --ink-2: #414B59; --ink-3: #6B7684; --rule: #DCE1E8; --rule-2: #C3CBD6;
--ws: #C2571A; --sse: #0095AB; --h1: #6B4FA8;
--good: #17714F; --warn: #8A5A00;
--grid: #E4E8ED; --shadow: 0 1px 2px rgba(18,22,29,.06), 0 8px 24px -12px rgba(18,22,29,.18);
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;}@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --paper: #12161D; --surface: #191F28; --surface-2: #212934; --ink: #E7EBF1; --ink-2: #B3BDCA; --ink-3: #8B96A5; --rule: #2A323E; --rule-2: #3A4553; --ws: #D2692A; --sse: #0FA3BA; --h1: #7E5AC8; --good: #3FA37B; --warn: #C79445; --grid: #262E3A; --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.6); }}:root[data-theme="dark"] { --paper: #12161D; --surface: #191F28; --surface-2: #212934; --ink: #E7EBF1; --ink-2: #B3BDCA; --ink-3: #8B96A5; --rule: #2A323E; --rule-2: #3A4553; --ws: #D2692A; --sse: #0FA3BA; --h1: #7E5AC8; --good: #3FA37B; --warn: #C79445; --grid: #262E3A; --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.6);}
* { box-sizing: border-box; }
body { background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 17px; line-height: 1.65; margin: 0; -webkit-font-smoothing: antialiased;}
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 32px 120px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0;}.col { max-width: 68ch; }
/* ── masthead ──────────────────────────────────────────────────────────── */.masthead { border-bottom: 2px solid var(--ink); padding: 72px 0 28px; margin-bottom: 40px;}.eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: var(--sse); margin: 0 0 20px; font-weight: 600;}h1 { font-family: var(--mono); font-size: clamp(34px, 6.2vw, 62px); line-height: 1.02; letter-spacing: -.035em; font-weight: 600; margin: 0 0 22px; text-wrap: balance; max-width: 20ch;}.dek { font-size: clamp(17px, 2.1vw, 21px); line-height: 1.5; color: var(--ink-2); margin: 0; max-width: 60ch;}.dek strong { color: var(--ink); font-weight: 600; }.byline { font-family: var(--mono); font-size: 12.5px; color: var(--ink-3); margin-top: 26px; display: flex; flex-wrap: wrap; gap: 8px 22px;}
/* ── hero stats ────────────────────────────────────────────────────────── */.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 2px; background: var(--rule); border: 1px solid var(--rule); margin-bottom: 56px;}.stat { background: var(--surface); padding: 24px 26px 26px; }.stat .k { font-family: var(--mono); font-size: 11.5px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 14px;}.stat .v { font-family: var(--mono); font-size: 42px; line-height: 1; letter-spacing: -.04em; font-variant-numeric: tabular-nums; font-weight: 600; margin-bottom: 10px;}.stat .d { font-size: 14.5px; line-height: 1.45; color: var(--ink-2); }.stat.a .v { color: var(--sse); }.stat.b .v { color: var(--ink); }.stat.c .v { color: var(--ws); }
/* ── sections ──────────────────────────────────────────────────────────── */section { margin-bottom: 76px; scroll-margin-top: 24px; }.sechead { display: flex; align-items: baseline; gap: 16px; border-top: 1px solid var(--rule-2); padding-top: 16px; margin-bottom: 26px;}.secnum { font-family: var(--mono); font-size: 12px; letter-spacing: .1em; color: var(--ink-3); font-weight: 600; white-space: nowrap; padding-top: 6px;}h2 { font-family: var(--mono); font-size: clamp(22px, 3vw, 30px); line-height: 1.15; letter-spacing: -.025em; font-weight: 600; margin: 0; text-wrap: balance;}h3 { font-family: var(--mono); font-size: 16px; letter-spacing: -.01em; font-weight: 600; margin: 36px 0 12px;}p { margin: 0 0 18px; }p.lede { font-size: 19px; color: var(--ink-2); }a { color: var(--sse); text-decoration-thickness: 1px; text-underline-offset: 2px; }strong { font-weight: 600; }code { font-family: var(--mono); font-size: .88em; background: var(--surface-2); padding: .12em .38em; border-radius: 3px;}
/* ── verdict ───────────────────────────────────────────────────────────── */.verdicts { display: grid; gap: 14px; margin: 0 0 8px; }.verdict { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 16px; background: var(--surface); border: 1px solid var(--rule); border-left: 3px solid var(--mk, var(--sse)); padding: 18px 22px; box-shadow: var(--shadow);}.verdict .tag { font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--mk, var(--sse)); font-weight: 600; padding-top: 4px; white-space: nowrap;}.verdict p { margin: 0; font-size: 16px; line-height: 1.55; }
/* ── figures ───────────────────────────────────────────────────────────── */figure { margin: 32px 0 0; background: var(--surface); border: 1px solid var(--rule); box-shadow: var(--shadow);}.figtop { padding: 22px 26px 6px; }.figtitle { font-family: var(--mono); font-size: 15px; font-weight: 600; letter-spacing: -.01em; }.figsub { font-size: 14px; color: var(--ink-3); margin-top: 5px; line-height: 1.45; }.figbody { padding: 10px 18px 8px; overflow-x: auto; }figcaption { padding: 14px 26px 20px; font-size: 14px; color: var(--ink-2); line-height: 1.5; border-top: 1px solid var(--rule); margin-top: 6px;}.legend { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 6px 26px 0; font-family: var(--mono); font-size: 12.5px; color: var(--ink-2);}.legend span { display: inline-flex; align-items: center; gap: 7px; }.swatch { width: 11px; height: 11px; border-radius: 2px; flex: none; }
svg { display: block; max-width: 100%; height: auto; }.gridline { stroke: var(--grid); stroke-width: 1; }.axis { fill: var(--ink-3); font-family: var(--mono); font-size: 11.5px; }.axis-strong { fill: var(--ink-2); font-family: var(--mono); font-size: 12px; font-weight: 600; }.vlabel { fill: var(--ink); font-family: var(--mono); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }.baseline { stroke: var(--rule-2); stroke-width: 1; }.bar { transition: opacity .12s; }.bar:hover { opacity: .78; }
/* ── tables ────────────────────────────────────────────────────────────── */.tablewrap { overflow-x: auto; margin: 28px 0 0; border: 1px solid var(--rule); background: var(--surface); box-shadow: var(--shadow); }table { border-collapse: collapse; width: 100%; font-size: 14.5px; }th, td { padding: 11px 16px; text-align: right; white-space: nowrap; }th:first-child, td:first-child { text-align: left; }thead th { font-family: var(--mono); font-size: 11.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-3); font-weight: 600; border-bottom: 1px solid var(--rule-2); background: var(--surface-2);}tbody td { border-bottom: 1px solid var(--rule); font-variant-numeric: tabular-nums; font-family: var(--mono); font-size: 13.5px; }tbody td:first-child { font-family: var(--sans); font-size: 14.5px; }tbody tr:last-child td { border-bottom: none; }tbody tr.hi td { background: color-mix(in srgb, var(--sse) 8%, var(--surface)); }td .win { color: var(--sse); font-weight: 600; }td .lose { color: var(--ws); }.dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 8px; vertical-align: baseline; }
/* ── callout ───────────────────────────────────────────────────────────── */.callout { background: var(--surface-2); border: 1px solid var(--rule); padding: 20px 24px; margin: 28px 0 0; font-size: 15.5px; line-height: 1.55; color: var(--ink-2);}.callout .lab { font-family: var(--mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--warn); font-weight: 600; display: block; margin-bottom: 8px;}.callout.refuted .lab { color: var(--ws); }.callout p:last-child { margin-bottom: 0; }
pre { background: var(--surface-2); border: 1px solid var(--rule); padding: 18px 20px; overflow-x: auto; font-family: var(--mono); font-size: 13.5px; line-height: 1.6; margin: 24px 0 0;}pre .c { color: var(--ink-3); }pre .k { color: var(--h1); }pre .s { color: var(--sse); }
/* ── tooltip ───────────────────────────────────────────────────────────── */#tip { position: fixed; pointer-events: none; opacity: 0; transform: translate(-50%, -100%); background: var(--ink); color: var(--paper); font-family: var(--mono); font-size: 12px; line-height: 1.45; padding: 8px 11px; border-radius: 4px; white-space: nowrap; z-index: 50; transition: opacity .1s;}#tip b { font-weight: 600; }
ul.plain { margin: 0 0 18px; padding-left: 22px; }ul.plain li { margin-bottom: 9px; }
.footer { border-top: 2px solid var(--ink); padding-top: 24px; font-size: 14px; color: var(--ink-3); font-family: var(--mono); line-height: 1.6;}
:focus-visible { outline: 2px solid var(--sse); outline-offset: 2px; }@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }@media (max-width: 640px) { .wrap { padding: 0 20px 80px; } .sechead { flex-direction: column; gap: 6px; }}</style>
<div id="tip" role="status" aria-live="polite"></div>
<div class="wrap">
<header class="masthead"> <p class="eyebrow">Transport bake-off · SSE + CQRS vs WebSockets</p> <h1>The transport is not the bottleneck</h1> <p class="dek">Testing whether <strong>HTML over WebSockets</strong> actually earns its complexity, or whether reads down an SSE stream and writes up as ordinary HTTP POSTs is both simpler and just as fast. Same domain code, same bytes of HTML, one variable: the wire.</p> <div class="byline"> <span>Node 24.14 · TLS 1.3 · TCP-level delay proxy</span> <span>3,000 commands per cell</span> <span>Bytes counted below TLS</span> </div></header>
<div class="stats"> <div class="stat a"> <div class="k">Latency spread @ 100ms RTT</div> <div class="v"><3%</div> <div class="d">All three architectures land within 3ms of each other on a 103ms round trip.</div> </div> <div class="stat b"> <div class="k">New TCP conns · 3,000 POSTs</div> <div class="v">0</div> <div class="d">Over HTTP/2 the command POST is a stream, not a connection. No handshake.</div> </div> <div class="stat c"> <div class="k">Bytes penalty · WS vs SSE</div> <div class="v">48×</div> <div class="d">For repeated HTML, WebSocket cannot reach SSE's compression. Structurally.</div> </div></div>
<section class="col"> <div class="sechead"><span class="secnum">VERDICT</span><h2>Three findings</h2></div> <div class="verdicts"> <div class="verdict" style="--mk: var(--good)"> <span class="tag">Confirmed</span> <p>The theory holds. Steady-state latency is the network, not the transport — the wire contributes about 2ms and then stops mattering. Per-connection memory under TLS is a wash: <b>245 KB</b> for SSE/h2 against <b>248 KB</b> for WebSocket.</p> </div> <div class="verdict" style="--mk: var(--ws)"> <span class="tag">Corrected</span> <p>The claim that a persistent connection “avoids repeating the TCP handshake and the HTTP headers on every interaction” is an <b>HTTP/1.1</b> claim. On HTTP/2 it opens zero connections and costs 33 bytes of headers. The article generalizes a property of the older protocol.</p> </div> <div class="verdict" style="--mk: var(--sse)"> <span class="tag">Unexpected</span> <p>SSE's real advantage isn't parity, it's <b>compression</b>. For HTML — which repeats — SSE reaches ratios WebSocket cannot, at any effort, because of a limit written into the DEFLATE format in 1996.</p> </div> </div> <p style="margin-top:26px">Chat is not the dividing line. <strong>Payload shape is.</strong></p></section>
<section> <div class="col"> <div class="sechead"><span class="secnum">TEST 1</span><h2>Latency is the network</h2></div> <p class="lede">100 clients × 30 rounds, TLS throughout. Measured client-side: from <code>dispatch()</code> to the patch carrying that command's nonce arriving on the client's own stream.</p> <p>Each group below sits on a dashed line marking the network round trip itself — the floor no architecture can beat. Every bar is essentially that floor plus a couple of milliseconds.</p> </div>
<figure> <div class="figtop"> <div class="figtitle">Command → patch round trip, steady state (p50)</div> <div class="figsub">Dashed line = the network RTT itself. Bar height above it is what the transport costs you.</div> </div> <div class="legend" id="lg1"></div> <div class="figbody"><svg id="c1" viewBox="0 0 1000 360" role="img" aria-label="Grouped bar chart of median round trip latency at 0, 25 and 100 millisecond RTT. All three transports are within 3 milliseconds of each other at every tier."></svg></div> <figcaption>At 100ms RTT: WebSocket 103.49ms, SSE+POST/h2 105.42ms, SSE+POST/h1 102.51ms. The ordering isn't even stable — HTTP/1.1 is nominally fastest in steady state, because its POST rides a warm dedicated socket while h2 shares one connection with the event stream.</figcaption> </figure>
<div class="col"> <h3>The handshake claim, isolated</h3> <p>The interesting column is the <em>first</em> command after connect — the moment an HTTP/1.1 client has to open a fresh socket, and exactly the cost the article is pointing at.</p> </div> <div class="tablewrap col" style="max-width:none"> <table> <thead><tr><th>Transport</th><th>1st cmd @ 0ms</th><th>1st cmd @ 100ms</th><th>Steady @ 100ms</th><th>New TCP conns</th></tr></thead> <tbody> <tr><td><span class="dot" style="background:var(--ws)"></span>WebSocket</td><td>2.49 ms</td><td>107.8 ms</td><td>103.49 ms</td><td>0</td></tr> <tr class="hi"><td><span class="dot" style="background:var(--sse)"></span>SSE + POST · HTTP/2</td><td>5.04 ms</td><td>116.29 ms</td><td>105.42 ms</td><td><span class="win">0</span></td></tr> <tr><td><span class="dot" style="background:var(--h1)"></span>SSE + POST · HTTP/1.1</td><td><span class="lose">44.37 ms</span></td><td><span class="lose">259.1 ms</span></td><td>102.51 ms</td><td><span class="lose">100</span></td></tr> </tbody> </table> </div> <div class="col"> <div class="callout"> <span class="lab">The cost SSE actually pays</span> <p>Connection <em>setup</em>, not interaction. At 100ms RTT an h2 session takes <b>314ms</b> to establish against WebSocket's <b>208ms</b> — one extra round trip, because h2 exchanges SETTINGS before the first request goes out where a WebSocket upgrade rides it. You pay that once per page load, not once per click.</p> </div> </div></section>
<section> <div class="col"> <div class="sechead"><span class="secnum">TEST 3</span><h2>Where it inverts: bytes</h2></div> <p class="lede">Counted below TLS at the proxy, per command round trip, after connect — so this is marginal steady-state cost, not first paint.</p> <p>For a single 500-byte row, WebSocket wins by 92 bytes. Nobody will notice. But send a whole-board morph — the pattern that makes HTML-over-the-wire pleasant to write — and the result reverses hard.</p> </div>
<figure> <div class="figtop"> <div class="figtitle">Bytes per command, whole-board morph (~88 KB of HTML)</div> <div class="figsub">Log scale. Same application, same HTML, same request for compression.</div> </div> <div class="figbody"><svg id="c2" viewBox="0 0 1000 300" role="img" aria-label="Horizontal log-scale bar chart of bytes per command for a fat morph. WebSocket with brotli requested still sends 3657 bytes; SSE over HTTP/2 with brotli sends 177 bytes."></svg></div> <figcaption>The WebSocket <code>gzip</code> and <code>br</code> rows are identical because <strong>they are the same codec</strong>. Asking a WebSocket for brotli gets you deflate — quietly.</figcaption> </figure></section>
<section> <div class="col"> <div class="sechead"><span class="secnum">TEST 5 · 6</span><h2>Why WebSocket can't close the gap</h2></div> <p class="lede">Three structural reasons, none of them tunable on the WebSocket side.</p>
<h3>a. There is no brotli for WebSocket</h3> <p><code>permessage-deflate</code> (RFC 7692) is the only compression extension the protocol defines, and it negotiates exactly one codec: DEFLATE. An SSE stream is an ordinary HTTP response, so <code>Content-Encoding: br</code> simply applies — and every browser has decoded it in the network stack since 2016.</p>
<h3>b. DEFLATE's window is capped at 32 KB by the format</h3> <p>This is the mechanism. For patch <em>N</em> to compress against patch <em>N−1</em>, the previous copy must still be inside the sliding window. Sweep the window against an 86.3 KB board and the failure is a cliff, not a slope:</p> </div>
<figure> <div class="figtop"> <div class="figtitle">Compressed size of a warm patch vs. sliding-window size</div> <div class="figsub">Both axes log. The cliff sits exactly where the window crosses one board.</div> </div> <div class="legend" id="lg3"></div> <div class="figbody"><svg id="c3" viewBox="0 0 1000 400" role="img" aria-label="Log-log line chart showing compressed patch size against window size. Deflate flattens at 3966 bytes and stops at its 32KB format ceiling. Brotli drops from 2750 bytes to 39 bytes once its window exceeds the 86KB payload."></svg></div> <figcaption>Between a 64 KB and a 128 KB window, brotli goes from 2,750 bytes to <strong>39 bytes</strong>. DEFLATE cannot get there: 32 KB is the format's ceiling, so a fat morph re-compresses from scratch every time while brotli emits “same as last time, one row different.”</figcaption> </figure>
<div class="col"> <h3>The real threshold is half the window</h3> <p>A live version of this — two chat panes on one server, one over each transport — sharpens the number. To compress copy <em>N</em> against copy <em>N−1</em>, the window has to hold <strong>both</strong>. So DEFLATE's usable payload ceiling is about <strong>16 KB</strong>, not 32 KB. Growing a chat log and watching cost per patch shows exactly where it breaks:</p> </div>
<figure> <div class="figtop"> <div class="figtitle">Cost per patch as the document grows</div> <div class="figsub">Live demo, fat morph of the whole log on every message. Log scale.</div> </div> <div class="legend" id="lg4"></div> <div class="figbody"><svg id="c4" viewBox="0 0 1000 340" role="img" aria-label="Log chart of bytes per patch against document size. SSE stays flat near 67 bytes while WebSocket climbs steeply after the document passes about 16 kilobytes."></svg></div> <figcaption>SSE holds at ~67 bytes per patch regardless of document size. WebSocket tracks the document once two copies stop fitting in 32 KB — the break lands between 14.4 KB and 19.6 KB, and by 31 KB the gap is <strong>69×</strong>.</figcaption> </figure>
<div class="col"> <h3>c. Context takeover is a memory bill proxies refuse to pay</h3> <p>Keeping the zlib dictionary alive between messages is what gets WebSocket to even 22×. Without it the narrow-patch case collapses from <strong>10.0× to 1.8×</strong>. It costs roughly 256 KB per connection, so servers and proxies routinely negotiate <code>no_context_takeover</code> to bound memory — silently halving your ratio.</p>
<div class="callout"> <span class="lab">And to match, you'd rebuild what the browser already has</span> <p>SSE + brotli is decoded by the browser's network stack: zero JavaScript, off the main thread, before your code sees a byte. To get brotli over a WebSocket you ship a brotli decoder to the client — WASM, 200 KB and up — and run it per message on the main thread. You would be reimplementing a codec every browser already ships, to reach a ratio SSE gets from one response header.</p> </div> </div>
<div class="tablewrap"> <table> <thead><tr><th>Codec</th><th>Available to</th><th>Avg / patch</th><th>Warm patch</th><th>Ratio</th><th>Encode CPU</th></tr></thead> <tbody> <tr><td>none</td><td>both</td><td>88,460 B</td><td>88,559 B</td><td>1.0×</td><td>0 ms</td></tr> <tr><td>deflate · ctx takeover</td><td><span class="dot" style="background:var(--ws)"></span>WS default</td><td>3,941 B</td><td>4,256 B</td><td>22.4×</td><td>27.3 ms</td></tr> <tr><td>deflate · no ctx takeover</td><td><span class="dot" style="background:var(--ws)"></span>WS via proxy</td><td>4,496 B</td><td>4,780 B</td><td>19.7×</td><td>18.4 ms</td></tr> <tr><td>gzip stream</td><td><span class="dot" style="background:var(--sse)"></span>SSE</td><td>3,941 B</td><td>4,256 B</td><td>22.4×</td><td>23.3 ms</td></tr> <tr class="hi"><td>brotli q4 · w22</td><td><span class="dot" style="background:var(--sse)"></span>SSE only</td><td><span class="win">82 B</span></td><td><span class="win">52 B</span></td><td><span class="win">1,075×</span></td><td><span class="win">8.2 ms</span></td></tr> <tr class="hi"><td>brotli q5 · w24</td><td><span class="dot" style="background:var(--sse)"></span>SSE only</td><td><span class="win">68 B</span></td><td><span class="win">49 B</span></td><td><span class="win">1,301×</span></td><td><span class="win">9.8 ms</span></td></tr> </tbody> </table> </div> <div class="col"><p style="margin-top:16px;font-size:15px;color:var(--ink-3)">Brotli is 48× smaller <em>and</em> 3× cheaper to encode than the best a WebSocket can do — cheaper because once the dictionary is warm it emits back-references instead of doing fresh Huffman work.</p></div></section>
<section> <div class="col"> <div class="sechead"><span class="secnum">TEST 4</span><h2>The honest cost of splitting</h2></div> <p class="lede">A WebSocket is one ordered byte stream, so commands apply in send order for free. Splitting reads from writes gives that up — the question is how much it actually costs.</p> </div> <div class="tablewrap"> <table> <thead><tr><th>Transport</th><th>Out-of-order pairs</th><th>Delivered</th><th>Ack → patch p50</th></tr></thead> <tbody> <tr><td><span class="dot" style="background:var(--ws)"></span>WebSocket</td><td>0%</td><td>400 / 400</td><td>no ack exists</td></tr> <tr class="hi"><td><span class="dot" style="background:var(--sse)"></span>SSE + POST · HTTP/2</td><td><span class="win">0%</span></td><td>400 / 400</td><td>0.14 ms</td></tr> <tr><td><span class="dot" style="background:var(--h1)"></span>SSE + POST · HTTP/1.1</td><td><span class="lose">2.11%</span></td><td>400 / 400</td><td>−0.12 ms</td></tr> </tbody> </table> </div> <div class="col"> <p style="margin-top:22px">Bursts of 10 un-awaited commands. <strong>HTTP/2 preserved order in every trial</strong> — concurrent POSTs multiplex onto one ordered TCP connection, so they reach the server in send order. HTTP/1.1's 5-socket pool reordered 2.11% of pairs, which is a genuine hazard if your commands don't commute.</p> <p>The ack→patch race is real but sub-millisecond: the POST's 204 lands ~0.14ms <em>before</em> the patch it caused, so code that re-enables a button on POST success runs just before the DOM updates. Drive UI off the patch, not the ack.</p> <div class="callout"> <span class="lab">Worth naming</span> <p>The WebSocket row has no ack because <strong>a WebSocket send has no ack at all.</strong> <code>socket.send()</code> returns instantly and tells you nothing — no status code, no error, no correlation to a response. The POST gives you one for free. That's a point <em>for</em> the CQRS split, not against it.</p> </div> </div></section>
<section> <div class="col"> <div class="sechead"><span class="secnum">ERGONOMICS</span><h2>Where the connection object lives</h2></div> <p class="lede">The argument that needs no benchmark. Both architectures need the <em>same</em> subscriber registry — the claim that WebSockets need one and SSE doesn't is simply false, and in this codebase that registry is shared verbatim.</p> <p>What differs is what the <strong>write path</strong> has to touch:</p><pre><span class="c">// SSE + CQRS — the command handler has no transport in it at all.</span><span class="k">function</span> handleCommand(room, id, nonce) { <span class="k">const</span> row = apply(board, { id, nonce }) <span class="k">return</span> broadcast(room, () => sseEvent(renderPatch(board, row, MODE)))}</pre> <p style="margin-top:20px"><code>handleCommand</code> is a pure function of <code>(room, id, nonce)</code>. It's callable from an HTTP route, a cron job, a webhook, a queue consumer, or another user's request, and it never sees a socket. The WebSocket equivalent is a closure <em>inside</em> <code>wss.on('connection')</code>, structurally scoped to one socket — so dispatching from anywhere else means reaching back through the registry anyway. At which point you've re-derived the CQRS split with extra steps.</p> </div>
<div class="tablewrap"> <table> <thead><tr><th>Concern</th><th style="text-align:left">SSE + POST</th><th style="text-align:left">WebSocket</th></tr></thead> <tbody> <tr><td>Auth</td><td style="text-align:left">existing cookie/session middleware</td><td style="text-align:left">re-implement in the message switch</td></tr> <tr><td>Routing</td><td style="text-align:left">URL + method</td><td style="text-align:left">hand-rolled <code>switch (msg.t)</code></td></tr> <tr><td>Validation, CSRF, rate limiting</td><td style="text-align:left">existing middleware stack</td><td style="text-align:left">hand-rolled</td></tr> <tr><td>Observability</td><td style="text-align:left">access logs, APM spans, per-route metrics</td><td style="text-align:left">one opaque long-lived connection</td></tr> <tr><td>Errors</td><td style="text-align:left">status codes</td><td style="text-align:left">invent a convention</td></tr> <tr><td>Reconnect & resume</td><td style="text-align:left"><code>EventSource</code> auto-reconnects, replays <code>Last-Event-ID</code></td><td style="text-align:left">write it yourself</td></tr> <tr><td>Backpressure</td><td style="text-align:left">h2 per-stream flow control</td><td style="text-align:left">monitor <code>bufferedAmount</code></td></tr> </tbody> </table> </div> <div class="col"><p style="margin-top:16px;font-size:15px;color:var(--ink-3)"><code>Last-Event-ID</code> deserves emphasis: SSE resume is a browser built-in and a server-side one-liner. Every WebSocket app that cares about messages missed during a reconnect writes that machinery by hand, and most write it wrong.</p></div></section>
<section> <div class="col"> <div class="sechead"><span class="secnum">TEST 7</span><h2>What the compression win costs</h2></div> <p class="lede">Brotli's ratio isn't free — the codec context lives per connection. 2,000 idle connections, measured as RSS delta.</p> </div> <div class="tablewrap"> <table> <thead><tr><th>Configuration</th><th>RSS / conn</th><th>Extrapolated @ 50k</th></tr></thead> <tbody> <tr><td>WebSocket · no compression</td><td>56.8 KB</td><td>2.7 GB</td></tr> <tr><td>SSE · no compression</td><td>92.2 KB</td><td>4.4 GB</td></tr> <tr><td>WebSocket · deflate</td><td>430.3 KB</td><td>20.5 GB</td></tr> <tr><td>SSE · gzip</td><td>458.0 KB</td><td>21.8 GB</td></tr> <tr><td>SSE · brotli lgwin=17 (128 KB)</td><td>923.4 KB</td><td>44.0 GB</td></tr> <tr><td>SSE · brotli lgwin=22 (4 MB, default)</td><td>942.2 KB</td><td>44.9 GB</td></tr> </tbody> </table> </div> <div class="col"> <div class="callout refuted"> <span class="lab">This refuted my own hypothesis</span> <p>I expected <code>lgwin=17</code> to be dramatically cheaper than <code>lgwin=22</code>, since Test 6 shows 17 already saturates the ratio. It isn't — 923 KB vs 942 KB, flat within noise. Node's brotli sizes its ring buffer against the input it actually sees, not the declared window. So <em>“tune lgwin down to save RAM”</em> is wrong. Tune it down anyway for the ratio; expect no memory win from it.</p> </div> <p style="margin-top:24px"><strong>Compression itself is the 5–10× memory multiplier</strong>, not the choice of window — and it isn't a WebSocket-vs-SSE axis at all: <code>ws deflate</code> and <code>sse gzip</code> are the same zlib bill (430 vs 458 KB).</p> <p>So the honest shape of the trade: fat morphs plus brotli buys a 48× byte reduction at roughly 10× the per-connection memory. Excellent for a few thousand concurrent editors; bad for 50k idle viewers. Above that scale you send narrow patches, drop to gzip, or terminate compression at an edge tier instead of in the origin process.</p> </div></section>
<section class="col"> <div class="sechead"><span class="secnum">SO</span><h2>When to use which</h2></div> <p><strong>Reach for a WebSocket when</strong> you're sending small binary or JSON messages at high frequency (sustained >20/s), you need strict cross-command ordering without thinking about it, or you need true push to a client that also streams continuously — games, cursors, audio. None of those describe an HTML-over-the-wire CRUD app.</p> <p><strong>Reach for SSE + CQRS when</strong> the payload is HTML, the write rate is human-scale, and you want auth, logging, caching, and resume to be things you already have rather than things you build.</p> <p>“Bidirectional” was never the property you needed a WebSocket for. The client-to-server channel is HTTP, and it always was — the article's own framing, that with SSE “if the client wants to send something, there is no channel for it,” is the load-bearing error. What actually varies by use case is payload shape and message rate, and for HTML specifically, <strong>SSE is the faster transport, not merely the simpler one.</strong></p></section>
<div class="footer"> Live demo — <a href="https://htmlwire.exe.xyz">htmlwire.exe.xyz</a> runs both transports as two chat panes on one server, one session, one SQLite, one renderer, and Idiomorph on both sides, so only the wire differs. Growing the log past ~16 KB reproduces the divergence above in real time. The host proxy passes <code>content-encoding: br</code> through untouched <em>and</em> negotiates <code>permessage-deflate</code> on the WebSocket, so both transports keep their best case — which is why this is not deployed on Workers, where WebSocket has no compression extension at all.<br><br> Method — Node 24.14.1, 10-core arm64. Both architectures share <code>src/domain.js</code>, so the HTML is byte-identical and the only variable is the wire. A TCP-level delay proxy sits below TLS so handshakes pay real latency; loopback-only benchmarks hide exactly the cost under argument. Bytes are counted at that proxy, beneath encryption. Connection counts capped at 4,000 by the host's ~16k ephemeral port range, not by either server.</div>
</div>
<script>const $ = (s) => document.querySelector(s);const NS = 'http://www.w3.org/2000/svg';const tip = $('#tip');const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
function el(p, n, a = {}) { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); p.appendChild(e); return e;}function showTip(evt, html) { tip.innerHTML = html; tip.style.opacity = '1'; tip.style.left = evt.clientX + 'px'; tip.style.top = (evt.clientY - 12) + 'px';}function hideTip() { tip.style.opacity = '0'; }function hover(node, html) { node.addEventListener('mousemove', (e) => showTip(e, html)); node.addEventListener('mouseleave', hideTip); node.setAttribute('tabindex', '0'); node.addEventListener('focus', (e) => { const r = node.getBoundingClientRect(); showTip({ clientX: r.left + r.width / 2, clientY: r.top }, html); }); node.addEventListener('blur', hideTip);}
const SERIES = [ { key: 'ws', label: 'WebSocket', v: '--ws' }, { key: 'h2', label: 'SSE + POST · HTTP/2', v: '--sse' }, { key: 'h1', label: 'SSE + POST · HTTP/1.1',v: '--h1' },];
function legend(sel, items) { $(sel).innerHTML = items.map((s) => `<span><i class="swatch" style="background:${css(s.v)}"></i>${s.label}</span>`).join('');}
/* ── Chart 1: grouped bars, latency vs RTT floor ───────────────────────── */function chart1() { const svg = $('#c1'); svg.innerHTML = ''; const data = [ { rtt: 0, floor: 0, ws: 1.44, h2: 2.00, h1: 0.74 }, { rtt: 25, floor: 25, ws: 27.34, h2: 27.96, h1: 26.52 }, { rtt: 100, floor: 100, ws: 103.49, h2: 105.42, h1: 102.51 }, ]; const W = 1000, H = 360, ml = 58, mr = 20, mt = 18, mb = 62; const iw = W - ml - mr, ih = H - mt - mb; const max = 120; const y = (v) => mt + ih - (v / max) * ih;
for (let t = 0; t <= 120; t += 20) { el(svg, 'line', { x1: ml, x2: W - mr, y1: y(t), y2: y(t), class: 'gridline' }); const lb = el(svg, 'text', { x: ml - 10, y: y(t) + 4, class: 'axis', 'text-anchor': 'end' }); lb.textContent = t; } const yl = el(svg, 'text', { x: 14, y: mt + ih / 2, class: 'axis', transform: `rotate(-90 14 ${mt + ih / 2})`, 'text-anchor': 'middle' }); yl.textContent = 'milliseconds';
const gw = iw / data.length; const bw = 46, gap = 14; data.forEach((d, gi) => { const cx = ml + gw * gi + gw / 2; const total = SERIES.length * bw + (SERIES.length - 1) * gap; const x0 = cx - total / 2;
SERIES.forEach((s, si) => { const v = d[s.key]; const x = x0 + si * (bw + gap); const h = Math.max(2, mt + ih - y(v)); const r = el(svg, 'rect', { x, y: y(v), width: bw, height: h, rx: 4, fill: css(s.v), class: 'bar', }); hover(r, `<b>${s.label}</b><br>${d.rtt}ms RTT · p50 <b>${v.toFixed(2)}ms</b><br>${(v - d.floor).toFixed(2)}ms above the network`); const t = el(svg, 'text', { x: x + bw / 2, y: y(v) - 9, class: 'vlabel', 'text-anchor': 'middle' }); t.textContent = v.toFixed(1); });
if (d.floor > 0) { el(svg, 'line', { x1: x0 - 16, x2: x0 + total + 16, y1: y(d.floor), y2: y(d.floor), stroke: css('--ink-3'), 'stroke-width': 1.5, 'stroke-dasharray': '5 4', }); const fl = el(svg, 'text', { x: x0 + total + 22, y: y(d.floor) + 4, class: 'axis' }); fl.textContent = 'network'; }
const lb = el(svg, 'text', { x: cx, y: mt + ih + 26, class: 'axis-strong', 'text-anchor': 'middle' }); lb.textContent = d.rtt + 'ms RTT'; const sb = el(svg, 'text', { x: cx, y: mt + ih + 44, class: 'axis', 'text-anchor': 'middle' }); sb.textContent = d.rtt === 0 ? 'loopback' : d.rtt === 25 ? 'same region' : 'cross-continent'; });
el(svg, 'line', { x1: ml, x2: W - mr, y1: mt + ih, y2: mt + ih, class: 'baseline' }); legend('#lg1', SERIES);}
/* ── Chart 2: horizontal log bars, bytes per fat morph ─────────────────── */function chart2() { const svg = $('#c2'); svg.innerHTML = ''; const rows = [ { l: 'WebSocket · none', v: 93842, c: '--ws' }, { l: 'SSE h2 · none', v: 88868, c: '--sse' }, { l: 'WebSocket · gzip', v: 3657, c: '--ws' }, { l: 'WebSocket · br requested',v: 3657, c: '--ws', note: 'same bytes as gzip — WS has no brotli' }, { l: 'SSE h2 · gzip', v: 3749, c: '--sse' }, { l: 'SSE h2 · brotli', v: 177, c: '--sse', win: true }, ]; const W = 1000, H = 300, ml = 210, mr = 150, mt = 16, mb = 44; const iw = W - ml - mr, ih = H - mt - mb; const lo = 100, hi = 100000; const x = (v) => ml + (Math.log10(v) - Math.log10(lo)) / (Math.log10(hi) - Math.log10(lo)) * iw;
[100, 1000, 10000, 100000].forEach((t) => { el(svg, 'line', { x1: x(t), x2: x(t), y1: mt, y2: mt + ih, class: 'gridline' }); const lb = el(svg, 'text', { x: x(t), y: mt + ih + 22, class: 'axis', 'text-anchor': 'middle' }); lb.textContent = t >= 1000 ? (t / 1000) + ' KB' : t + ' B'; }); const xl = el(svg, 'text', { x: ml + iw / 2, y: mt + ih + 40, class: 'axis', 'text-anchor': 'middle' }); xl.textContent = 'bytes per command (log scale)';
const bh = 24, step = ih / rows.length; rows.forEach((r, i) => { const yy = mt + step * i + (step - bh) / 2; const w = Math.max(3, x(r.v) - ml); const rect = el(svg, 'rect', { x: ml, y: yy, width: w, height: bh, rx: 4, fill: css(r.c), class: 'bar' }); hover(rect, `<b>${r.l}</b><br><b>${r.v.toLocaleString()}</b> bytes / command`); const lb = el(svg, 'text', { x: ml - 12, y: yy + bh / 2 + 4, class: 'axis', 'text-anchor': 'end' }); lb.textContent = r.l; const vl = el(svg, 'text', { x: ml + w + 10, y: yy + bh / 2 + 4, class: 'vlabel' }); vl.textContent = r.v.toLocaleString() + ' B'; if (r.note) { const n = el(svg, 'text', { x: ml + w + 100, y: yy + bh / 2 + 4, class: 'axis' }); n.textContent = '← ' + r.note; n.setAttribute('fill', css('--ws')); } if (r.win) { const n = el(svg, 'text', { x: ml + w + 92, y: yy + bh / 2 + 4, class: 'axis' }); n.textContent = '← 20× fewer bytes, same ask'; n.setAttribute('fill', css('--sse')); } }); el(svg, 'line', { x1: ml, x2: ml, y1: mt, y2: mt + ih, class: 'baseline' });}
/* ── Chart 3: the window cliff ─────────────────────────────────────────── */function chart3() { const svg = $('#c3'); svg.innerHTML = ''; const deflate = [[512, 38055], [2048, 11762], [8192, 4014], [32768, 3966]]; const brotli = [[1024, 9295], [65536, 2750], [131072, 39], [262144, 39], [1048576, 39], [4194304, 39], [16777216, 39]]; const PAYLOAD = 88375;
const W = 1000, H = 400, ml = 66, mr = 176, mt = 26, mb = 62; const iw = W - ml - mr, ih = H - mt - mb; const xlo = 512, xhi = 16777216, ylo = 20, yhi = 100000; const x = (v) => ml + (Math.log10(v) - Math.log10(xlo)) / (Math.log10(xhi) - Math.log10(xlo)) * iw; const y = (v) => mt + ih - (Math.log10(v) - Math.log10(ylo)) / (Math.log10(yhi) - Math.log10(ylo)) * ih;
[100, 1000, 10000, 100000].forEach((t) => { el(svg, 'line', { x1: ml, x2: ml + iw, y1: y(t), y2: y(t), class: 'gridline' }); const lb = el(svg, 'text', { x: ml - 10, y: y(t) + 4, class: 'axis', 'text-anchor': 'end' }); lb.textContent = t >= 1000 ? (t / 1000) + 'KB' : t + 'B'; }); [[512, '512B'], [8192, '8KB'], [131072, '128KB'], [2097152, '2MB'], [16777216, '16MB']].forEach(([t, l]) => { const lb = el(svg, 'text', { x: x(t), y: mt + ih + 24, class: 'axis', 'text-anchor': 'middle' }); lb.textContent = l; }); const xl = el(svg, 'text', { x: ml + iw / 2, y: mt + ih + 46, class: 'axis', 'text-anchor': 'middle' }); xl.textContent = 'sliding-window size (log)'; const yl = el(svg, 'text', { x: 16, y: mt + ih / 2, class: 'axis', transform: `rotate(-90 16 ${mt + ih / 2})`, 'text-anchor': 'middle' }); yl.textContent = 'warm patch bytes (log)';
// payload marker el(svg, 'line', { x1: x(PAYLOAD), x2: x(PAYLOAD), y1: mt, y2: mt + ih, stroke: css('--ink-3'), 'stroke-width': 1.5, 'stroke-dasharray': '4 4' }); const pm = el(svg, 'text', { x: x(PAYLOAD) + 8, y: mt + 14, class: 'axis' }); pm.textContent = 'one board = 86 KB';
const line = (pts, color, dashEnd) => { const d = pts.map((p, i) => (i ? 'L' : 'M') + x(p[0]) + ' ' + y(p[1])).join(' '); el(svg, 'path', { d, fill: 'none', stroke: css(color), 'stroke-width': 2.5, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }); pts.forEach((p) => { const c = el(svg, 'circle', { cx: x(p[0]), cy: y(p[1]), r: 5, fill: css(color), stroke: css('--surface'), 'stroke-width': 2 }); const kb = p[0] >= 1048576 ? (p[0] / 1048576) + ' MB' : (p[0] / 1024) + ' KB'; hover(c, `window <b>${kb}</b><br>warm patch <b>${p[1].toLocaleString()} B</b>`); }); }; line(deflate, '--ws'); line(brotli, '--sse');
// deflate ceiling const cx = x(32768); el(svg, 'line', { x1: cx, x2: cx, y1: y(3966) - 10, y2: mt + ih, stroke: css('--ws'), 'stroke-width': 1.5, 'stroke-dasharray': '3 3', opacity: '.75' }); // Annotation sits left of the ceiling and under the deflate curve — the only // region of this plot that is empty. Right of it, brotli's descent and its // flat tail occupy everything below y≈150. const c1 = el(svg, 'text', { x: cx - 12, y: 250, class: 'axis', 'text-anchor': 'end' }); c1.textContent = 'DEFLATE format ceiling — 32 KB'; c1.setAttribute('fill', css('--ws')); const c2 = el(svg, 'text', { x: cx - 12, y: 268, class: 'axis', 'text-anchor': 'end' }); c2.textContent = 'WebSocket cannot go past here'; c2.setAttribute('fill', css('--ws'));
// end labels const e1 = el(svg, 'text', { x: x(32768) + 10, y: y(3966) - 10, class: 'vlabel' }); e1.textContent = '3,966 B'; e1.setAttribute('fill', css('--ws')); const e2 = el(svg, 'text', { x: x(16777216) + 12, y: y(39) + 4, class: 'vlabel' }); e2.textContent = '39 B'; e2.setAttribute('fill', css('--sse'));
el(svg, 'line', { x1: ml, x2: ml + iw, y1: mt + ih, y2: mt + ih, class: 'baseline' }); el(svg, 'line', { x1: ml, x2: ml, y1: mt, y2: mt + ih, class: 'baseline' });
legend('#lg3', [ { label: 'DEFLATE — all WebSocket has', v: '--ws' }, { label: 'brotli — available to SSE', v: '--sse' }, ]);}
/* ── Chart 4: live demo — cost per patch as the document grows ─────────── */function chart4() { const svg = $('#c4'); svg.innerHTML = ''; const pts = [ { doc: 4.1, ws: 80, sse: 65 }, { doc: 9.3, ws: 176, sse: 66 }, { doc: 14.4, ws: 325, sse: 68 }, { doc: 19.6, ws: 2600, sse: 68 }, { doc: 24.8, ws: 3700, sse: 67 }, { doc: 31.1, ws: 4600, sse: 68 }, ]; const W = 1000, H = 340, ml = 66, mr = 150, mt = 26, mb = 60; const iw = W - ml - mr, ih = H - mt - mb; const xlo = 3, xhi = 34, ylo = 40, yhi = 10000; const x = (v) => ml + (v - xlo) / (xhi - xlo) * iw; const y = (v) => mt + ih - (Math.log10(v) - Math.log10(ylo)) / (Math.log10(yhi) - Math.log10(ylo)) * ih;
[100, 1000, 10000].forEach((t) => { el(svg, 'line', { x1: ml, x2: ml + iw, y1: y(t), y2: y(t), class: 'gridline' }); const lb = el(svg, 'text', { x: ml - 10, y: y(t) + 4, class: 'axis', 'text-anchor': 'end' }); lb.textContent = t >= 1000 ? (t / 1000) + 'KB' : t + 'B'; }); [5, 10, 15, 20, 25, 30].forEach((t) => { const lb = el(svg, 'text', { x: x(t), y: mt + ih + 24, class: 'axis', 'text-anchor': 'middle' }); lb.textContent = t + ' KB'; }); const xl = el(svg, 'text', { x: ml + iw / 2, y: mt + ih + 46, class: 'axis', 'text-anchor': 'middle' }); xl.textContent = 'document size — the whole log, re-rendered every message'; const yl = el(svg, 'text', { x: 16, y: mt + ih / 2, class: 'axis', transform: `rotate(-90 16 ${mt + ih / 2})`, 'text-anchor': 'middle' }); yl.textContent = 'bytes per patch (log)';
// the threshold band: where two copies stop fitting in DEFLATE's 32 KB el(svg, 'rect', { x: x(14.4), y: mt, width: x(19.6) - x(14.4), height: ih, fill: css('--ws'), opacity: '.10', }); const tl = el(svg, 'text', { x: (x(14.4) + x(19.6)) / 2, y: mt + 16, class: 'axis', 'text-anchor': 'middle' }); tl.textContent = 'two copies stop fitting'; tl.setAttribute('fill', css('--ws'));
const series = [ { k: 'ws', c: '--ws', label: 'WebSocket · permessage-deflate' }, { k: 'sse', c: '--sse', label: 'SSE · brotli' }, ]; series.forEach((s) => { const d = pts.map((p, i) => (i ? 'L' : 'M') + x(p.doc) + ' ' + y(p[s.k])).join(' '); el(svg, 'path', { d, fill: 'none', stroke: css(s.c), 'stroke-width': 2.5, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }); pts.forEach((p) => { const c = el(svg, 'circle', { cx: x(p.doc), cy: y(p[s.k]), r: 5, fill: css(s.c), stroke: css('--surface'), 'stroke-width': 2 }); hover(c, `document <b>${p.doc} KB</b><br>${s.label.split(' · ')[0]} · <b>${p[s.k].toLocaleString()} B</b> per patch<br>gap <b>${(p.ws / p.sse).toFixed(1)}×</b>`); }); const last = pts[pts.length - 1]; const lb = el(svg, 'text', { x: x(last.doc) + 12, y: y(last[s.k]) + 4, class: 'vlabel' }); lb.textContent = last[s.k].toLocaleString() + ' B'; lb.setAttribute('fill', css(s.c)); });
el(svg, 'line', { x1: ml, x2: ml + iw, y1: mt + ih, y2: mt + ih, class: 'baseline' }); el(svg, 'line', { x1: ml, x2: ml, y1: mt, y2: mt + ih, class: 'baseline' }); legend('#lg4', [ { label: 'WebSocket · permessage-deflate', v: '--ws' }, { label: 'SSE · brotli', v: '--sse' }, ]);}
function draw() { chart1(); chart2(); chart3(); chart4(); }draw();// Charts read their colors from tokens at draw time, so they must be rebuilt// on both theme signals: the OS preference, and an explicit data-theme stamp.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);new MutationObserver(draw).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'],});window.addEventListener('scroll', hideTip, { passive: true });</script>