Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 074 / 100

Binary protocols over WebSockets: lessons from production

When JSON becomes the bottleneck.

Expona Admin7 min read

Binary protocols over WebSockets

You've scaled your real-time platform to thousands of concurrent users sending market data, GPS coordinates, or chat messages every few milliseconds. Suddenly your WebSocket server's CPU and bandwidth are maxed out, even though the business logic is trivial. The culprit isn't your code—it's JSON. When milliseconds and megabytes matter, binary protocols over WebSockets become the difference between a system that scales and one that melts. This post walks you through when and how to switch from JSON to MessagePack or Protobuf, and what tooling you need to keep your sanity in the process.

The JSON bottleneck

JSON is wonderful for human readability and API contracts. But it's genuinely wasteful for high-frequency WebSocket streams:

  • Size overhead: A simple market tick like {"symbol":"AAPL","price":150.25,"volume":1000} is 50+ bytes as JSON. MessagePack: 18 bytes. Protobuf: 12 bytes.
  • Parse cost: Each message triggers a full JSON parse on both client and server. For 1,000 messages per second, that's expensive.
  • Type ambiguity: JSON doesn't enforce schemas, so you're always doing defensive validation.
  • Bandwidth on mobile: Users on 4G with 50ms latency and 5 Mbps throughput notice the difference immediately.

I've seen platforms shed 40–60% bandwidth and 30% CPU just by switching serialization, with zero business logic changes. The catch: you lose browser console visibility and require custom tooling to debug.

MessagePack—Binary JSON-like encoding. Easy drop-in.

MessagePack is the gentlest introduction to binary protocols. It's JSON's compact cousin: same structure (objects, arrays, primitives), dramatically smaller wire size, and widely supported across languages.

Why MessagePack first:

  • Drop-in replacement for JSON. Your existing message structure works unchanged.
  • ~50% smaller than JSON on typical payloads.
  • Negligible parsing overhead vs. JSON.
  • Libraries for Node.js, Python, Go, Rust, Swift, Kotlin, etc.
  • Debuggable with CLI tools that decode to JSON-like output.

Example: Real-time trading feed

You're broadcasting EURUSD tick data to 500 traders. Each tick is sent 10 times per second.

typescript
// Before: JSON
const tickJSON = JSON.stringify({
  symbol: "EURUSD",
  bid: 1.0856,
  ask: 1.0857,
  timestamp: Date.now(),
  volume: 5000000
});
// ~65 bytes per message
// 500 clients × 10/sec = 5,000 msgs/sec = 325 KB/s

// After: MessagePack
import { pack, unpack } from "msgpack-lite";

const tickData = {
  symbol: "EURUSD",
  bid: 1.0856,
  ask: 1.0857,
  timestamp: Date.now(),
  volume: 5000000
};

const tickBinary = pack(tickData);
// ~22 bytes per message
// 500 clients × 10/sec = 5,000 msgs/sec = 110 KB/s
// 66% bandwidth reduction

ws.send(tickBinary);

// On client
ws.addEventListener("message", (event) => {
  const tick = unpack(new Uint8Array(event.data));
  console.log(tick.bid, tick.ask);
});

Bandwidth savings compound across your entire infrastructure. You also get faster serialization—MessagePack encoding is often 2–3× faster than JSON stringification.

Protobuf—Typed schemas and very compact payloads

MessagePack is great, but Protobuf goes further by enforcing strict schemas. Every field is typed and versioned. You get backward/forward compatibility, smaller payloads, and generated code for multiple languages.

Why Protobuf for stricter systems:

  • Compact: 7–15 byte overhead per message for most real-world payloads.
  • Strongly typed with code generation (TypeScript, Go, Rust, Python, etc.).
  • Schema evolution: add fields without breaking old clients.
  • Validation: malformed data is caught during deserialization.
  • Widely adopted in high-frequency trading, telemetry, and microservices.

Example: GPS tracking for ride-hailing

Drivers send location updates every 2 seconds. Without schema enforcement, you risk silent data corruption.

typescript
// driver_location.proto
syntax = "proto3";

message DriverLocation {
  string driver_id = 1;
  double latitude = 2;
  double longitude = 3;
  int64 timestamp_ms = 4;
  int32 accuracy_meters = 5;
  float speed_kmh = 6;
}

// Generated TypeScript (via protobufjs or ts-proto)
import { DriverLocation } from "./driver_location";

const location: DriverLocation = {
  driverId: "drv_12345",
  latitude: 6.9271,
  longitude: 80.7789,
  timestampMs: Date.now(),
  accuracyMeters: 8,
  speedKmh: 45.2
};

const encoded = DriverLocation.encode(location).finish();
// ~28 bytes for full GPS + metadata + timestamp
// JSON version: ~95 bytes

ws.send(encoded);

// On server
ws.on("message", (buffer) => {
  const decoded = DriverLocation.decode(buffer);
  // Type-safe. No "driverId" vs "driver_id" ambiguity.
  updateDriverPosition(decoded.driverId, decoded.latitude, decoded.longitude);
});

Protobuf's schema registry also means you catch API drift early. Add a field, bump the proto version, deploy new clients—old clients still work.

When it matters—High-frequency updates and mobile networks

Not every WebSocket stream needs binary protocols. Know your thresholds:

Scenario Frequency Recommendation Why
Chat app 1–5 msg/sec per connection JSON is fine Latency matters more than bandwidth. JSON is debuggable.
Live notifications 10–50 msg/sec Consider MessagePack Mobile traffic + volume start to add up.
Market data / gaming 100+ msg/sec Protobuf or MessagePack Bandwidth and CPU matter. Schema enforcement pays off.
Sensor/telemetry streams 1000+ msg/sec Protobuf + compression Volume is extreme. Schema validation is essential.

Mobile networks especially benefit. A user on 4G with 20 Mbps throughput using your app for an hour will consume 60–70% less data with Protobuf vs. JSON. That's real battery savings and lower carrier costs.

Debuggability cost—Binary is harder to inspect. Build tooling.

The trade-off: you can't console.log a binary message and understand it. This is serious for production debugging. You need tooling.

Strategies I use:

  1. Binary-to-JSON debug endpoint: Spin up a local WebSocket that listens to your server's broadcasts, decodes them, and logs as JSON.
  2. Conditional JSON mode: Let clients request JSON for debugging (e.g., ?format=json), then switch to binary in production.
  3. Message schema documentation: Keep your .proto files in Git with clear comments. Generate HTML docs via protoc plugins.
  4. Structured logging: Log every decoded message to a structured log (JSON lines) with timestamp, type, and key fields. Index it in CloudWatch or ELK.

Example: Debug harness for Protobuf

typescript
// On server: optional JSON bridge for development
const debugMode = process.env.DEBUG_BINARY === "true";

ws.on("message", (buffer) => {
  const decoded = DriverLocation.decode(buffer);
  
  if (debugMode) {
    console.log("Decoded message:", JSON.stringify(decoded, null, 2));
  }
  
  // Structured logging for production
  logger.info("driver_location_update", {
    driverId: decoded.driverId,
    latitude: decoded.latitude,
    longitude: decoded.longitude,
    timestamp: new Date(Number(decoded.timestampMs))
  });
  
  processLocationUpdate(decoded);
});

// Optional: expose debug endpoint that forwards as JSON
if (debugMode) {
  debugWss.on("connection", (ws) => {
    originalWss.on("connection", (originalWs) => {
      originalWs.on("message", (buffer) => {
        const decoded = DriverLocation.decode(buffer);
        ws.send(JSON.stringify(decoded));
      });
    });
  });
}

Good logging turns binary protocols from a black box into something manageable. Invest in it early.

Common mistakes

  • Switching too early: Don't binary-encode your chat app. JSON is fine for 1–2 msg/sec. Premature optimization costs readability and debug time.
  • No schema versioning: Deploy a Protobuf change without bumping the version. Old clients send old messages. Server crashes or corrupts data silently.
  • Forgetting compression: Binary + gzip is even better. Protobuf messages compress to ~30% of their already-small size.
  • No client library strategy: You're running iOS, Android, web, and backend. Ensure code generation works for all. protobufjs is good for TS; grpc-swift for iOS; etc.
  • Logging PII in binary form: Decoding happens at log time, so accidentally logging raw buffers won't expose user data. But do verify before going to prod.

Key takeaways

  • JSON is the default but inefficient for high-frequency WebSocket streams. Binary protocols cut bandwidth 40–70% and reduce CPU by 25–40% with zero business logic change.
  • Start with MessagePack for a drop-in replacement. Move to Protobuf if you need strong typing, schema versioning, and multi-language support.
  • Only switch to binary when throughput exceeds ~50 msg/sec per connection or mobile bandwidth is critical. Chat apps, low-frequency notifications, and internal APIs are fine with JSON.
  • Debuggability is the main cost. Invest in debug endpoints, structured logging, and clear schema documentation. Make decoding transparent in development.
  • Combine binary protocols with compression (gzip or brotli) for maximum savings. A 20-byte Protobuf message compresses to 6–8 bytes.
  • Version your schemas from day one. Protobuf's backward compatibility is only valuable if you use it intentionally.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles