← Back to blog2026-09-13

Protobuf at the poker table: 46% less WebSocket traffic

A same-release A/B with 100 tables and 900 connections cut WS payload from 1.73 to 0.93 MB/s versus compact JSON, with similar action throughput. Four event types, shared queue objects, and the costs of keeping a mixed protocol.

100 tables: about 46% fewer bytes per completed action

On September 13, 2026, we moved part of BluffKing’s frequent table broadcasts to Protobuf and ran a comparison on production release web-2026.09.13.1. With 100 tables and 9 simulated players per table, total WebSocket application payload fell from roughly 1.73 MB/s to 0.93 MB/s, a reduction of about 46%. Both runs completed about 414 actions per second.

The baseline was already compact-v1, our optimized JSON profile, rather than the original full JSON representation. The new protobuf-v1 profile retains that compact behavior and sends four public event types as binary. Both runs used the same server binary; the negotiated connection profile changed. This comparison measures the wire-format effect. Queue sharing and other improvements already in that binary benefit both runs, so their full contribution cannot be attributed to this A/B.

100 tables / 900 connectionsCompact JSONProtobuf mixed stream
Total WS payload bytes104,109,573 B56,505,168 B
WS payload rate1,731,657 B/s933,142 B/s
WS bytes per completed action4,181.44 B2,253.99 B
Completed actions24,89825,069
Action throughput414.13 /s414.00 /s
Successful REST requests60,000 / 60,00060,000 / 60,000

The rate reduction is 1 − 933142 / 1731657 ≈ 46.1%; normalization per completed action also gives about 46.1%. Actual measurement windows were 60.121 and 60.554 seconds, so total bytes fell by a slightly different 45.7%. MB is decimal here. Counts include UTF-8 text and binary application payload, excluding WebSocket headers, TCP, TLS, and other API traffic. They do not establish a 46% reduction in the server’s bandwidth bill.

Why these four events came first

Our previous compact JSON change had already removed defaults, repeated static fields, and messages with no client-side effect. Frequent events still repeated field names and JSON structure. One action at a nine-player table distributes the same public result to several connections, making those bytes a useful target.

Binary eventContents
actor_deadlineActing seat, decision deadline, and epoch
action_appliedAction result, contribution, betting state, and next actor
pot_updatedPot and side pots
street_revealedNew community cards and street action state

These are frequent public events whose contents are identical across recipients. Snapshots, private cards, encrypted-dealing messages, and other control messages keep the existing JSON path, including privacy projection and per-connection compact state. REST also stays JSON. Every additional message type expands the field semantics, authorization boundaries, and recovery behavior to verify. A smaller migration covers much of the hot traffic and makes its effect easier to attribute.

Protobuf identifies fields by number and can represent integers with variable-width encoding, avoiding repeated long field names; see the official encoding guide. We kept strings for action kinds and street names instead of adding another custom short-code system. They could be smaller, but limiting changes to protocol semantics mattered in this pass.

The connection is still an ordinary WebSocket. H5 requests wire=protobuf-v1, receives binary messages for these four event types, and JSON text for the rest. We introduced no gRPC and did not enable permessage-deflate. Changing the representation and applying general compression are different mechanisms. We measured the former, without a comparative compression benchmark.

Keep objects in internal queues; encode at the connection boundary

The original question also asked whether Protobuf could reduce parsing in internal queues. For an in-process queue, the more direct approach is to pass a Rust object for the public event, avoiding serialization into bytes followed by parsing just to move it internally.

Table task creates a public event
  → Share Arc<PreparedEvent> across connection queues
  → Lazily encode and cache JSON / Protobuf for the profile
  → Write the WebSocket message
  → H5 decodes to the existing ServerMsg state contract

In server/src/outbound.rs, PreparedEvent holds the original message and uses two OnceLock caches for its representations. Recipients share the event and reuse an encoding of the same format. Sending a public event through the Protobuf path need not materialize JSON first. If another purpose needs JSON, such as accounting against the existing queued-byte budget, the cache can still produce it on demand.

This reduces repeated serialization; it is not zero-copy. The current implementation still copies encoded bytes when handing them to each socket. Each table task keeps ownership of its mutable game state; the shared object is a prepared public event. Private messages do not enter this public-event shortcut, and queue backpressure, settlement budgets, and session isolation remain in place.

We did not run an independent production toggle comparison for this change. The 46% figure is the whole-stream payload result of the protocol A/B. We have no isolated production throughput figure for removing internal parsing.

Preserve zero, absence, and chip precision

Successful binary decoding does not establish correct table state. Seat 0 is a real seat, not “no next actor”; a truthiness check would lose it. Likewise, min_raise_to: null and numeric 0 are different. An absent side-pot field and a present but empty list also need to restore the existing client contract.

protocol/realtime.proto uses optional for numeric fields that need presence, and a wrapper message for the side-pot list. H5 restores the existing ServerMsg shape in client-game/src/realtime.ts. Conversion of unsigned 64-bit values to JavaScript numbers checks the safe-integer range. An out-of-range value rejects the frame instead of silently rounding chips or timestamps.

Rust and TypeScript use the same protocol/realtime-fixtures.json examples to check actual bytes and restored semantics. Focused checks also cover real WS negotiation, queue backpressure, privacy, and settlement paths. Field numbers become a lasting contract: later edits must not casually renumber fields or reuse a removed number for a different meaning.

The cost is a schema, generated code, a decoding adapter, and cross-language fixtures. Binary messages are also less readable than JSON in a browser’s network panel and require decoding tools. Our load generator reuses the H5 decoder, while packet analysis now correlates binary actions. Otherwise, changing the protocol could make an observer miss events and falsely report less traffic or lower latency.

How the production comparison ran

All four runs with complete offered workloads used production commit ddb6c724: one compact JSON / Protobuf pair at 50 tables and another at 100. Each table had 9 authenticated simulated players and a target of 5 action opportunities per second, checking or calling. Hand transitions and outstanding acknowledgements prevent invalid actions from being forced through, so offered opportunities and completed actions differ.

Each run had 10 seconds of warmup and a target measurement window of 60 seconds. The 50-table runs offered 500 REST requests/s; the 100-table runs offered 1,000/s. All queried current seated-table state through /api/lobby/active. Both profiles disabled WS compression, with simulated client addresses spreading the fixture traffic across normal per-address admission budgets. This is a particular API and game-pace mix, not evidence for arbitrary REST workloads.

The generator ran at reduced scheduling priority on the production host, connecting directly to the game container over its private network. The host had 2 vCPUs and about 3.6 GiB of memory. This bypassed the public route, TLS, and reverse proxy, which helps compare payloads on the same service version, but the generator also competed for host resources. 900 connections describes this private-path test, not a public-network experience guarantee for 900 players.

All four runs completed their planned requests, retained connections through the end, kept every table active, acknowledged actions, and passed persistence and seat-release checks without recorded application errors. The 900 temporary accounts and associated test data were cleaned up afterward. Each scale has one sequential A/B pair, without randomized interleaving or confidence intervals from repeated runs. Millisecond differences still need repetition.

Throughput was similar; latency needs separate evidence

Fewer bytes leave more room on the outbound link, but this test offered a fixed workload rather than increasing it to saturation. Both profiles completed similar action rates. A 46% byte reduction does not establish a 46% throughput increase or a proportional increase in maximum player capacity.

Both 50-table captures had zero packet loss and complete request and nine-recipient action correlation. Server WS P99 fell from 2.9418 ms to 2.2299 ms, about 24%. Client end-to-end P99 was higher in that same comparison:

50 tables / 450 connections, P99Compact JSONProtobuf
Server action ingress → nine-recipient broadcast egress2.9418 ms2.2299 ms
Client scheduled action → all nine receive34.585 ms57.718 ms
Client REST end-to-end64.943 ms145.098 ms

The server boundary begins when the last byte of the complete action request enters the measured interface and ends when the correlated broadcast has been written to all nine recipients. It is broader than handler time and does not wait for remote acknowledgement. End-to-end timing also includes the generator’s scheduling wait and receive processing. Host contention and generator overhead can affect those results, but this run did not independently isolate their contributions. It cannot establish better player latency.

At 100 tables, moving packet parsing to an external machine still left kernel packet drops and correlation gaps. Those two runs failed capture-based latency acceptance, so we do not use their server P99. Traffic and action counts come from complete client counters and remain usable. That partial validity must not be described as every acceptance check passing.

The gap between 85% and 46% guides the next decision

In the 100-table compact JSON run, the four migrated types accounted for about 54.37 MB, or 52.2% of total WS payload. In the Protobuf run they accounted for about 7.84 MB, roughly 85.6% less. Event counts differ slightly between the runs, so this is an aggregate category comparison under this workload.

Optimizing about half the traffic cannot reduce the whole stream by 85%, even if that half becomes much smaller. Snapshots, room state, and standings still contribute substantial JSON. A rough estimate, 52.2% × 85.6% ≈ 44.7%, is close to the measured whole-stream reduction of about 46%; the difference reflects event mix and completed work. Showing only the smallest Protobuf message would overstate the application benefit.

ChoiceBenefitCost or remaining limit
Migrate four public event types firstCover frequent broadcasts with bounded verificationA mixed JSON / binary stream; large messages remain
Share internal events and encode lazilyReuse a broadcast’s encoded representationPer-socket byte copies remain; no isolated gain measurement
Preserve the H5 state contractAvoid rewriting table state handlingMaintain schema, generated code, and semantic adaptation
Keep REST as JSONLimit migration scope and retain API toolingNo REST payload reduction in this release

The default Android and iOS tables embed remotely served H5, so loading the updated H5 brings this codec change without a new store binary. An already open page does not spontaneously become new code, however. Connections explicitly negotiate their format, and the server retains a JSON path. We did not rewrite native Dart protocol consumers for this task.

Applying this approach elsewhere

  1. Count bytes by message type first. Track the entire stream and cost per successful business action, not only selected codec examples.
  2. Start with frequent public events with stable semantics. Define absence, zero, integer limits, and privacy projection before changing representation.
  3. Keep typed objects inside the process where practical. Encode at boundaries that need network transfer or persistence, while retaining queue budgets and backpressure.
  4. Compare profiles on the same release. Hold workload and route fixed, calibrate the generator, and check acknowledgements, errors, disconnects, and persistence.
  5. Accept payload, throughput, and latency separately. Stop interpreting metrics with incomplete capture. A capacity claim needs repeated load ladders from an independent generator beyond a fixed-load pass.

The measured result is about 46% less outbound WS payload per action than compact JSON under this production private-path workload. Further optimization should start by examining the remaining snapshot and standings bytes, then weighing a broader binary migration against its complexity. Those possible improvements are outside the results reported here.