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.

Illustrative encoded size of three message types A price tick shrinks from 68 to 27 bytes, a chat message from 142 to 96 and a 500-row numeric snapshot from 41,000 to 16,500 bytes when encoded as Protobuf instead of JSON. Illustrative encoded size of three message types before compression JSON bytes Protobuf bytes 0 20k 40k 60k Price tick Chat message 41k 17k 500-row snapshot
Numeric, key-heavy data gains most; text-heavy messages gain least, because the text itself dominates.

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.

JSON, MessagePack and Protobuf on the wire Comparison of JSON, MessagePack and Protobuf on schema requirements, size for numeric data, readability in DevTools, typing across languages and schema evolution rules. JSON, MessagePack and Protobuf on the wire JSON MessagePack Protobuf Schema required no no .proto, codegen Size (numeric data) largest smaller smallest Readable in DevTools yes no no Typed across languages by hand by hand generated Evolution rules conventions conventions built into format MessagePack is the drop-in binary option; Protobuf is the contract-first one
Protobuf's advantage is the schema as much as the bytes.

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.

Schema evolution with a long-lived client A new server sends an old client a known price message, a price message with an added field that the client skips, and a new oneof variant that the client decodes as undefined and ignores. Schema evolution with a long-lived client Old client (v1 schema) New server (v2 schema) Envelope{ price } (field known) PriceTick + new field 5 (mid) unknown field skipped Envelope{ new variant 16 } body.case undefined → ignored Only changing a field's number or type breaks old clients — adding never does
Protobuf's evolution rules are what make binary safe for clients that stay connected for days.

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.

Back to Message Framing & Serialization.