Protocol Buffers over WebSockets #
Your real-time protocol is JSON, and two things are starting to hurt. Market-data frames of mostly numbers are three times larger than they need to be, and on low-end phones JSON.parse of large snapshots shows up as long tasks. Meanwhile the backend already describes its data in .proto files, and every client re-declares the same shapes by hand. Protocol Buffers over binary WebSocket frames addresses both: messages shrink substantially, schemas are defined once and compiled into typed code for every language, and the rules for evolving them are well understood. The costs are debuggability (you can no longer read frames in DevTools) and a build step. This page shows how to structure the protocol, what the evolution rules mean for long-lived clients, and when the switch pays off.
Root cause #
JSON repeats field names in every message, encodes numbers as decimal text, and must be parsed character by character. For a price update like {"symbol":"AAPL","bid":191.02,"ask":191.04,"ts":1757000000123}, more than half the bytes are keys and punctuation. Protobuf encodes each field as a small numeric tag plus a compact binary value — varints for integers, 8 bytes for doubles, length-prefixed bytes for strings — and omits fields at their default value. The same update is roughly 25–35 bytes instead of 70.
Protobuf does not, by itself, tell the receiver which message a frame contains. A WebSocket carrying several message types needs an outer message — an envelope — that identifies the payload, and a oneof field is the idiomatic way to express it.
Resolution #
Define one envelope with a oneof for every message type, generate TypeScript with a modern generator (@bufbuild/protobuf via protoc-gen-es is used here), and send the encoded bytes as binary frames. Set binaryType = 'arraybuffer' in the browser so frames arrive as bytes rather than Blobs.
// realtime.proto
syntax = "proto3";
package realtime.v1;
message PriceTick { string symbol = 1; double bid = 2; double ask = 3; int64 ts = 4; }
message ChatMessage { string id = 1; string author_id = 2; string text = 3; int64 sent_at = 4; }
message Subscribe { repeated string channels = 1; uint64 after_seq = 2; }
message Error { string code = 1; string message = 2; bool retryable = 3; }
message Envelope {
uint64 seq = 1; // stream position for ordered pushes (0 when not used)
string req_id = 2; // correlation for request/response (empty for pushes)
oneof body { // exactly one payload per frame
PriceTick price = 10;
ChatMessage chat = 11;
Subscribe subscribe = 12;
Error error = 13;
}
reserved 14, 15; // retired message types: never reuse these numbers
}
// client.ts — generated types from protoc-gen-es (@bufbuild/protobuf v2)
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import { EnvelopeSchema, type Envelope } from './gen/realtime_pb';
const ws = new WebSocket('wss://rt.example.com/ws', ['realtime.v1.protobuf']); // subprotocol names the encoding
ws.binaryType = 'arraybuffer'; // bytes, not Blob
export function send(env: Envelope) {
ws.send(toBinary(EnvelopeSchema, env)); // Uint8Array → one binary frame
}
ws.onopen = () => send(create(EnvelopeSchema, {
body: { case: 'subscribe', value: { channels: ['prices:AAPL'], afterSeq: 0n } },
}));
ws.onmessage = (e) => {
if (typeof e.data === 'string') return; // ignore stray text frames
const env = fromBinary(EnvelopeSchema, new Uint8Array(e.data));
switch (env.body.case) { // discriminated union, fully typed
case 'price': return onPrice(env.body.value.symbol, env.body.value.bid, env.body.value.ask);
case 'chat': return onChat(env.body.value);
case 'error': return onError(env.body.value.code, env.body.value.retryable);
case undefined: return; // unknown type from a newer server: ignore
}
};
declare function onPrice(symbol: string, bid: number, ask: number): void;
declare function onChat(m: unknown): void;
declare function onError(code: string, retryable: boolean): void;
The case undefined branch is essential for long-lived clients. When a newer server sends a oneof variant this client’s generated code does not know, Protobuf decodes it as unset rather than failing, and the client must ignore it — the tolerant-reader behaviour described in versioning WebSocket message schemas. Protobuf’s own evolution rules make that work: add fields and variants with new numbers, never change a field’s type or number, and reserve the numbers of anything you remove.
Negotiate the encoding with a subprotocol (realtime.v1.protobuf alongside realtime.v1.json) so you can migrate clients gradually and debug with JSON when needed; see WebSocket subprotocol negotiation. The envelope mirrors the one in designing a WebSocket message envelope, with the oneof playing the role of the type discriminator.
Edge cases #
Compression interplay. permessage-deflate shrinks JSON dramatically — repeated keys compress well — which narrows the gap. Measure Protobuf against compressed JSON before deciding, and remember that compression has its own memory cost per connection, as covered in permessage-deflate compression trade-offs.
64-bit integers. Protobuf int64/uint64 exceed JavaScript’s safe integer range, so generators map them to bigint (as with afterSeq: 0n above) or strings. Choose deliberately, and use int32 or double for values that will never exceed 2^53.
Debugging. Binary frames are opaque in DevTools. Keep a JSON encoding of the same schema available behind a subprotocol or a debug flag, and add a small decoder to your development tooling that pretty-prints captured frames.
Verification #
Measure before and after on real traffic, not synthetic messages. Log encoded sizes for a sample of each message type in both encodings, compressed and uncompressed, and profile decode time on a low-end device:
import { toBinary } from '@bufbuild/protobuf';
function compareSizes(env: Envelope, asJson: object) {
const pb = toBinary(EnvelopeSchema, env).byteLength;
const json = new TextEncoder().encode(JSON.stringify(asJson)).byteLength;
console.table({ type: env.body.case, json, protobuf: pb, ratio: (pb / json).toFixed(2) });
}
Then run a compatibility test: an old generated client decoding frames from a newer server that has added a field and a oneof variant must succeed and ignore what it does not know. That test belongs in the contract suite described in contract testing WebSocket messages.
Operational checklist #
FAQ #
Is Protobuf faster than JSON in the browser? #
It is smaller on the wire, especially for numeric data. Decode speed depends on the library and payload: modern JavaScript Protobuf decoders are competitive with JSON.parse, which is heavily optimized native code, so measure on your target devices rather than assuming a speedup.
How do I send Protobuf over a WebSocket? #
Encode the message to a Uint8Array and pass it to ws.send(); it goes as a binary frame. On receipt, set binaryType = 'arraybuffer' and decode new Uint8Array(event.data) with the generated schema.
Do I need gRPC to use Protobuf? #
No. Protobuf is just an encoding; gRPC is one RPC framework that uses it. WebSockets carry Protobuf messages perfectly well, and the comparison with gRPC streaming itself is in gRPC streaming vs WebSockets.
Should I use MessagePack instead? #
MessagePack needs no schema and works as a drop-in replacement for JSON, which makes it an easier first step; see binary WebSocket frames with MessagePack. Choose Protobuf when you want generated types, cross-language contracts and built-in evolution rules.
Related #
- Binary WebSocket Frames with MessagePack — the schemaless binary option.
- Designing a WebSocket Message Envelope — the envelope this encodes.
- Parsing WebSocket Messages in a Web Worker — decoding off the main thread.
- Fragmented WebSocket Frames and Max Payload — size limits for large binary snapshots.
Back to Message Framing & Serialization.