Binary messaging formats

AnyCable Pro allows you to use Msgpack or Protobufs instead of JSON to serialize incoming and outgoing data. Using binary formats bring the following benefits: faster (de)serialization and less data passing through network (see comparisons below).

Msgpack

Usage

In order to initiate Msgpack-encoded connection, a client MUST use "actioncable-v1-msgpack" or "actioncable-v1-ext-msgpack" subprotocol during the connection.

A client MUST encode outgoing and incoming messages using Msgpack.

Using Msgpack with AnyCable JS client

AnyCable JavaScript client supports Msgpack out-of-the-box:

// cable.js
import { createCable } from '@anycable/web'
import { MsgpackEncoder } from '@anycable/msgpack-encoder'

export default createCable({protocol: 'actioncable-v1-msgpack', encoder: new MsgpackEncoder()})

// or for the extended Action Cable protocol
// export default createCable({protocol: 'actioncable-v1-ext-msgpack', encoder: new MsgpackEncoder()})

Action Cable JavaScript client patch

Here is how you can patch the built-in Action Cable JavaScript client library to support Msgpack:

import { createConsumer, logger, adapters, INTERNAL } from "@rails/actioncable";
// Make sure you added msgpack library to your frontend bundle:
//
//    yarn add @ygoe/msgpack
//
import msgpack from "@ygoe/msgpack";

let consumer;

// This is an application specific function to create an Action Cable consumer.
// Use it everywhere you need to connect to Action Cable.
export const createCable = () => {
  if (!consumer) {
    consumer = createConsumer();
    // Extend the connection object (see extensions code below)
    Object.assign(consumer.connection, connectionExtension);
    Object.assign(consumer.connection.events, connectionEventsExtension);
  }

  return consumer;
}

// Msgpack support
// Patches this file: https://github.com/rails/rails/blob/main/actioncable/app/javascript/action_cable/connection.js

// Replace JSON protocol with msgpack
const supportedProtocols = [
  "actioncable-v1-msgpack"
]

const protocols = supportedProtocols
const { message_types } = INTERNAL

const connectionExtension = {
  // We have to override the `open` function, since we MUST provide custom WS sub-protocol
  open() {
    if (this.isActive()) {
      logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`)
      return false
    } else {
      logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${protocols}`)
      if (this.webSocket) { this.uninstallEventHandlers() }
      this.webSocket = new adapters.WebSocket(this.consumer.url, protocols)
      this.webSocket.binaryType = "arraybuffer"
      this.installEventHandlers()
      this.monitor.start()
      return true
    }
  },
  isProtocolSupported() {
    return supportedProtocols[0] == this.getProtocol()
  },
  send(data) {
    if (this.isOpen()) {
      const encoded = msgpack.encode(data);
      this.webSocket.send(encoded)
      return true
    } else {
      return false
    }
  }
}

// Incoming messages are handled by the connection.events.message function.
// There is no way to patch it, so, we have to copy-paste :(
const connectionEventsExtension = {
  message(event) {
    if (!this.isProtocolSupported()) { return }
    const {identifier, message, reason, reconnect, type} = msgpack.decode(new Uint8Array(event.data))
    switch (type) {
      case message_types.welcome:
        this.monitor.recordConnect()
        return this.subscriptions.reload()
      case message_types.disconnect:
        logger.log(`Disconnecting. Reason: ${reason}`)
        return this.close({allowReconnect: reconnect})
      case message_types.ping:
        return this.monitor.recordPing()
      case message_types.confirmation:
        return this.subscriptions.notify(identifier, "connected")
      case message_types.rejection:
        return this.subscriptions.reject(identifier)
      default:
        return this.subscriptions.notify(identifier, "received", message)
    }
  },
};

See the demo of using Msgpack in a Rails project with AnyCable Rack server.

Protobuf

We squeeze a bit more space by using Protocol Buffers. AnyCable uses the following schema:

syntax = "proto3";

package action_cable;

enum Type {
  no_type = 0;
  welcome = 1;
  disconnect = 2;
  ping = 3;
  confirm_subscription = 4;
  reject_subscription = 5;
  confirm_history = 6;
  reject_history = 7;
}

enum Command {
  unknown_command = 0;
  subscribe = 1;
  unsubscribe = 2;
  message = 3;
  history = 4;
  pong = 5;
}

message StreamHistoryRequest {
  string epoch = 2;
  int64 offset = 3;
}

message HistoryRequest {
  int64 since = 1;
  map<string, StreamHistoryRequest> streams = 2;
}

message Message {
  Type type = 1;
  Command command = 2;
  string identifier = 3;
  // Data is a JSON encoded string.
  // This is by Action Cable protocol design.
  string data = 4;
  // Message has no structure.
  // We use Msgpack to encode/decode it.
  bytes message = 5;
  string reason = 6;
  bool reconnect = 7;
  HistoryRequest history = 8;
}

message Reply {
  Type type = 1;
  string identifier = 2;
  bytes message = 3;
  string reason = 4;
  bool reconnect = 5;
  string stream_id = 6;
  string epoch = 7;
  int64 offset = 8;
  string sid = 9;
  bool restored = 10;
  repeated string restored_ids = 11;
}

When using the standard Action Cable protocol (v1), both incoming and outgoing messages are encoded as action_cable.Message type. When using the extended version, incoming messages are encoded as action_cable.Reply type.

Note that Message.message field and Reply.message have the bytes type. This field carries the information sent from a server to clients, which could be of any form. We Msgpack to encode/decode this data. Thus, AnyCable Protobuf protocol is actually a mix of Protobufs and Msgpack.

Using Protobuf with AnyCable JS client

AnyCable JavaScript client supports Protobuf encoding out-of-the-box:

// cable.js
import { createCable } from '@anycable/web'
import { ProtobufEncoder } from '@anycable/protobuf-encoder'

export default createCable({protocol: 'actioncable-v1-protobuf', encoder: new ProtobufEncoder()})

See the demo of using Protobuf encoder in a Rails project with AnyCable JS client.

To use Protobuf with the extended Action Cable protocol, use the following configuration:

// cable.js
import { createCable } from '@anycable/web'
import { ProtobufEncoderV2 } from '@anycable/protobuf-encoder'

export default createCable({protocol: 'actioncable-v1-ext-protobuf', encoder: new ProtobufEncoderV2()})

Formats comparison

Here is the in/out traffic comparison:

Encoder Sent Rcvd
protobuf 315.32MB 327.1KB
msgpack 339.58MB 473.6KB
json 502.45MB 571.8KB

The data above were captured while running a websocket-bench benchmark with the following parameters:

websocket-bench broadcast ws://0.0.0.0:8080/cable —server-type=actioncable —origin http://0.0.0.0 —sample-size 100 —step-size 1000 —total-steps 5 —steps-delay 2 —wait-broadcasts=5 —payload-padding=100

NOTE: The numbers above depend on the messages structure. Binary formats are more efficient for objects (JSON-like) and less efficient when you broadcast long strings (e.g., HTML fragments).

Here is the encode/decode speed comparison:

Encoder Decode (ns/op) Encode (ns/op)
protobuf (base) 425 1153
msgpack (base) 676 1512
json (base) 1386 1266
protobuf (long) 479 2370
msgpack (long) 763 2506
json (long) 2457 2319

Where base payload is:

{
  "command": "message",
  "identifier": "{\"channel\":\"test_channel\",\"channelId\":\"23\"}",
  "data": "hello world"
}

And the long one is:

{
  "command": "message",
  // x10 means repeat string 10 times
  "identifier": "{\"channel\":\"test_channel..(x10)\",\"channelId\":\"123..(x10)\"}",
  // message is the base message from above
  "message": {
    "command": "message",
    "identifier": "{\"channel\":\"test_channel\",\"channelId\":\"23\"}",
    "data": "hello world"
  }
}