Skip to content

signalling: add compression envelopes for signal messages - #1746

Draft
xianshijing-lk wants to merge 10 commits into
mainfrom
sxian/signal-payload-compression
Draft

signalling: add compression envelopes for signal messages#1746
xianshijing-lk wants to merge 10 commits into
mainfrom
sxian/signal-payload-compression

Conversation

@xianshijing-lk

@xianshijing-lk xianshijing-lk commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Proposal

Let signal messages on the WebSocket be compressed, the way WrappedJoinRequest already compresses the join payload in the connect URL. Adds a compressed arm to the SignalRequest / SignalResponse oneofs, carrying CompressedSignalRequest / CompressedSignalResponse and a shared SignalCompression.Type enum.

No negotiation handshake, no extra frames, no version gate. The receiver always parses a SignalRequest/SignalResponse; the oneof tag says whether the payload is compressed.

Draft — the proto compiles, but I'd like agreement on the shape before anyone implements against it.

Why

Everything after the handshake goes over the socket uncompressed. On the resume path that's where the bulk of the bytes are:

message when size
SyncState every resume two full SessionDescriptions + subscription / publish / data-channel lists
Offer (publisher ICE restart) every resume ~5–6 KB
Offer/Answer (subscriber) every resume ~5–6 KB
JoinResponse.other_participants join N × ParticipantInfo, unbounded via metadata/attributes

Measured against a real minimal one-audio/one-video offer: 5,734 bytes raw → 1,515 gzipped (3.8×).

Size isn't the only cost. Signalling is a single TCP connection, so a multi-kilobyte offer in flight head-of-line blocks everything queued behind it — including the trickle candidates the PeerConnection needs to finish its ICE restart. Compressing these directly shortens recovery on the degraded networks where recovery matters most.

How it works

message SignalResponse {
  oneof message {
    JoinResponse join = 1;
    ...
    CompressedSignalResponse compressed = 32;   // recursively, a SignalResponse
  }
}

Receiver parses SignalResponse. If it lands on compressed, it inflates signal_response and parses that as a SignalResponse. One level only; senders must not nest.

Senders use the arm only against a peer that advertised ClientInfo.CAP_COMPRESSION_DEFLATE_RAW — which clients already send today and nothing currently reads in livekit-server or cloud. An older peer would see an unknown field and silently drop the message, so the capability gate is load-bearing.

Below roughly 200 bytes, senders just use the ordinary arm — matching the threshold cloud/pkg/rtc/signal/compress.go:14 already uses internally.

Why this shape

Two earlier revisions in this branch, both worse:

  1. Envelope + flag on JoinResponse. A receiver has to know whether a frame is a SignalResponse or a WrappedSignalResponse before reading anything out of it, so the agreement had to be established first — and the message establishing it couldn't itself be compressed. That permanently excluded the join roster, the biggest message in a large room.
  2. Envelope + a SignalCompressionAck first frame. Fixed the above (no extra RTT — the server writes the ack and the JoinResponse back to back), but cost an extra message type and an extra frame.

As a oneof arm, the tag is the discriminator, so neither is needed. It's also cheaper on the wire: under an envelope, every message pays envelope overhead once compression is on, including small ones sent with compression = NONE. Here a small message is its ordinary arm and costs nothing, so the threshold has no downside.

Why not permessage-deflate

It would need no protocol change, and livekit-server already sets EnableCompression: true on the client-facing upgrader (pkg/service/rtcservice.go:76-77). But:

  • No usable Rust WebSocket crate implements it. Checked tungstenite 0.29 (current), fastwebsockets 0.10, tokio-websockets 0.13 — none. Only soketto does, and it's a poor structural fit.
  • It wouldn't reach SDKs where the host application supplies the WebSocket transport (Swift/Kotlin/Dart via livekit_net::set_ws_client). Each host would have to negotiate it independently.

Application-layer compression covers every transport uniformly, and mirrors what the SFU already does gzipping SignalRequest/SignalResponse on its own node-to-node transport.

Shape of the enum

SignalCompression is a holder message with a nested Type, rather than a top-level enum or an enum nested in each message:

  • Top-level forces prefixed value names. Enum values follow C++ scoping and are siblings of their type, so a bare NONE collides with the one already in livekit_sip.proto — protoc rejects it outright.
  • Nested per message avoids the prefix but yields two structurally identical, type-incompatible enums. Compression is naturally generic over direction — the SFU threads one compression type through both paths — so splitting it forces conversion code for no gain.
  • Holder message gets both.

Values are numbered to match WrappedJoinRequest.Compression rather than to rank the options; two enums in one file where GZIP has different numbers invites mistakes.

Open questions

  1. DEFLATE_RAW vs GZIP as the recommended default. Raw deflate saves gzip's ~18 bytes of header/trailer per message, which matters at a 200-byte threshold. Both WrappedJoinRequest and the SFU's internal path use gzip today. Recommendation, or mandate one?
  2. The 200-byte threshold is documented as SHOULD, not enforced. Reasonable as guidance?
  3. Capability gating. Reusing CAP_COMPRESSION_DEFLATE_RAW seemed right since it exists, is already advertised, and is read by nothing. But it was presumably minted for data streams — is overloading it acceptable, or would you rather have a distinct capability?

Notes

  • protoc validates clean; Go codegen committed by the Generate workflow.
  • Changeset included (minor for both Go and JS packages).
  • No behaviour changes here — wire contract only. Client and server implementations follow.

🤖 Generated with Claude Code

`WrappedJoinRequest` already compresses the join payload carried in the connect
URL, but every message after the handshake goes over the WebSocket uncompressed.
On the resume path that is where the bulk of the bytes are: `SyncState` alone
carries two full session descriptions plus the subscription, publish and data
channel lists, and the publisher ICE-restart offer follows right behind it.
Measured against a minimal one-audio/one-video offer, an SDP is 5734 bytes raw
and 1515 gzipped -- a 3.8x saving on the single largest repeated payload.

Signalling is one TCP connection, so these are not merely large, they are in the
way: a multi-kilobyte offer in flight head-of-line blocks everything queued
behind it, including the trickle candidates the PeerConnection needs to finish
its ICE restart. Compressing them shortens recovery on exactly the degraded
networks where recovery matters.

`permessage-deflate` would have avoided a protocol change -- livekit-server
already sets `EnableCompression: true` on the client-facing upgrader -- but no
usable Rust WebSocket crate implements it, and it would not reach SDKs whose
host application supplies its own WebSocket transport. Doing it at the
application layer covers every transport uniformly, and mirrors what the SFU
already does gzipping signal messages on its own internal transport.

Negotiation reuses the existing `CAP_COMPRESSION_DEFLATE_RAW` capability, which
clients already advertise and nothing currently reads. The server acknowledges
it in `JoinResponse`/`ReconnectResponse`; unset means the current uncompressed
format, so old and new peers on either side interoperate unchanged. Those two
responses are themselves unwrapped, since they are what establish the agreement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6febbf7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
github.com/livekit/protocol Minor
@livekit/protocol Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

github-actions Bot and others added 9 commits August 28, 2026 22:04
Replaces the top-level `SignalCompression` enum with a holder message carrying a
nested `Type`. Both envelopes now reference `SignalCompression.Type`.

The top-level form forced prefixed value names: enum values follow C++ scoping and
are siblings of their type, so a bare `NONE` collides with the one already declared
in livekit_sip.proto -- verified against protoc, which rejects it outright. Nesting
scopes the values, so `NONE`/`GZIP`/`DEFLATE_RAW` read the same way as every other
enum in this file.

Nesting an enum inside each envelope, mirroring WrappedJoinRequest exactly, would
also have avoided the prefix, but at the cost of two structurally identical yet
type-incompatible enums. Compression is naturally generic over direction -- the
SFU's own internal implementation threads a single compression type through both
the request and response paths -- and splitting the type would force conversion
code on every implementation for no gain.

Value numbers match WrappedJoinRequest.Compression rather than ranking the options;
two enums in one file where GZIP has different numbers invites mistakes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…flag

Replaces `JoinResponse.signal_compression` and
`ReconnectResponse.signal_compression` with a `SignalCompressionAck` arm on the
`SignalResponse` oneof, sent unwrapped as the first message on the connection.

The flags could not cover the message carrying them. A client has to know whether
to parse the first frame as `SignalResponse` or `WrappedSignalResponse` before it
can read anything out of it, so a flag inside the JoinResponse necessarily left
that JoinResponse uncompressed -- and on a large room the join roster is the
single biggest message on the wire, scaling with participant count and unbounded
through participant metadata and attributes. Exactly the case worth compressing.

A separate first frame removes the circularity without any sniffing. Both
possibilities for frame one are a plain `SignalResponse`, so the client parses one
type and switches on the arm it receives: `compression_ack` means compress from
here on, anything else means an older server and the connection carries on
uncompressed. A client that never advertised the capability is never sent the ack.
All four old/new combinations keep working, and there is no extra round trip --
the server writes the ack and the JoinResponse back to back.

Collapsing the two per-response booleans into one mechanism also drops the
question of what a resume inherits: the ack is exchanged per connection, so a
resume landing on a different node simply negotiates again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces WrappedSignalRequest/WrappedSignalResponse and the SignalCompressionAck
handshake with a `compressed` arm on the SignalRequest and SignalResponse oneofs.

The envelope form needed a negotiation handshake for one reason: a receiver had to
know whether to parse a frame as SignalResponse or WrappedSignalResponse before it
could read anything out of it. That forced an agreement to be established first,
and whatever message established it could not itself be compressed.

As an arm of the oneof, the tag is the discriminator. The receiver always parses a
SignalResponse and switches on the arm it got, so nothing needs to be agreed in
advance and the very first message can be compressed -- including JoinResponse,
which in a large room is the biggest message on the wire and the one this is most
worth doing for. That removes the extra frame, the ack message, and the two
per-response booleans that preceded it.

It is also cheaper on the wire. Under the envelope, once compression was on every
message paid envelope overhead, including messages too small to be worth
compressing and sent with compression NONE. Here a small message is sent as its
ordinary arm and costs nothing, so the threshold has no downside.

Gating is unchanged: the arm is used only against a peer that advertised
CAP_COMPRESSION_DEFLATE_RAW, since an older peer would see an unknown field and
silently drop the message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uests

The capability gate only covered one direction. ClientInfo.CAP_COMPRESSION_DEFLATE_RAW
tells the server what the client can decode, so server-to-server compression was
safe. Nothing told the client whether the server could decode a compressed
SignalRequest.

Guessing wrong fails in the worst possible way. An older server parses the unknown
field into its unknown-field set and leaves the oneof unset; the switch in
livekit-server's signalhandler has no default case, so the message is dropped with
no error and no warning. A resume would stall on a SyncState that never arrived
rather than failing and escalating.

Adds accepts_compressed_signal to JoinResponse and ReconnectResponse. This is not
the circular arrangement removed earlier: that flag had to be read before the client
could parse the message carrying it, whereas this one gates only what the client
SENDS. Parsing is still decided by the oneof tag alone, so the JoinResponse carrying
the flag can itself be compressed.

ReconnectResponse repeats it because a resume may land on a different node, and
because the largest client-to-server message of all -- SyncState, carrying two
session descriptions plus the subscription and publish lists -- is sent immediately
after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant