Envelope chunking
How the SDK splits control messages that exceed the data-channel threshold, when this becomes visible to callers, and how each SDK handles reassembly.
The LiveKit reliable data channel has a practical per-packet ceiling of roughly 16 KiB end to end, imposed by SCTP, browser constraints, and the SFU layer. The SDK keeps a margin under it: a message whose JSON exceeds SAFE_PACKET_BYTES (12,000) is split into *-envelope-chunk frames carrying CHUNK_RAW_BYTES (8,000) of inner payload each, which the peer buffers and reassembles before dispatching. Both constants live in src/transport/envelope.ts and are mirrored in the Swift reassembler.
Callers emit a single logical message and receive a single logical message. The chunking layer is below the event surface.
When chunking fires
The most common trigger is an oversized image frame sent with sendImage(). The send-image message carries a base64-encoded JPEG directly in JSON; a 640×480 JPEG at moderate quality is roughly 30–80 KiB serialized — well above the threshold.
send-image payload (80 KiB) ÷ 8,000 bytes per chunk
→ chunk 0: envelope-chunk { seq: 0, total: 11, data: "..." }
→ chunk 1: envelope-chunk { seq: 1, total: 11, data: "..." }
→ …
→ chunk 10: envelope-chunk { seq: 10, total: 11, data: "..." }
↓ reassembled on the server
single send-imageText-only turns and control frames (mute, ping, transcript deltas, tool events) are almost always under 1 KiB and never trigger chunking in practice.
Wire frame schema
Client → server:
{
"type": "envelope-chunk",
"envelope_id": "<shared-uuid-across-all-chunks-in-this-envelope>",
"seq": 0,
"total": 6,
"data": "<base64-encoded UTF-8 fragment>"
}Server → client:
{
"type": "server-envelope-chunk",
"envelope_id": "<shared-uuid>",
"seq": 0,
"total": 3,
"data": "<base64-encoded UTF-8 fragment>"
}data is base64 over the UTF-8 bytes of the inner message JSON. Base64 keeps every chunk ASCII-safe — splitting raw UTF-8 at byte boundaries can land mid-codepoint and corrupt the decode on the receiver. The receiving side base64-decodes, concatenates, then JSON-parses to recover the original message.
How each SDK chunks and reassembles
Chunking is handled in src/transport/envelope.ts. Outbound messages are serialized and split if needed before being published to the LiveKit data channel. Inbound server-envelope-chunk frames are buffered in an EnvelopeReassembler instance keyed by envelope_id, bounded like the Swift one: at most 8 in-flight envelopes, 4 MiB per envelope, and a 30-second TTL.
RealtimeClient.sendImage()
→ transport.send(send-image message)
→ envelope.ts: chunk when the JSON exceeds SAFE_PACKET_BYTES
→ livekit_transport: publish each chunkaddVideoStream() doesn't go through this path at all. It publishes a continuous LiveKit video track with publishTrack, which rides RTP rather than the data channel and is never envelope-chunked. Only discrete JSON messages are.
On the inbound side:
livekit_transport: raw data packet
→ envelope.ts: EnvelopeReassembler.consume()
→ if all chunks arrived: emit reassembled server message
→ RealtimeClient.handleServerMessage()Callers never see envelope-chunk in their on() handlers.
RealtimeSession._handle_payload() checks the type field before dispatching. On server-envelope-chunk, it calls _reassemble_envelope() which buffers chunks in self._envelope_buffers keyed by envelope_id and calls _handle_payload() recursively once all seq slots are filled.
# Simplified from python/src/cosmo_ai/session/_engine.py in cosmo-ai
async def _handle_payload(self, payload: bytes | str) -> None:
raw = json.loads(payload)
if raw["type"] == "server-envelope-chunk":
chunk = ServerEnvelope.model_validate(raw)
reassembled = self._reassemble_envelope(chunk)
if reassembled is not None:
await self._handle_payload(reassembled) # recurse once complete
return
# normal dispatch path: validate the typed event model and enqueue it
event = _SERVER_EVENT_BY_TYPE[raw["type"]].model_validate(raw)
self._emit(event)The buffers are bounded so a misbehaving server can't grow them without limit: at most 64 in-flight envelopes (a new envelope evicts the oldest), at most 1024 chunks per envelope, and at most 4 MiB accumulated per envelope — an envelope over any cap is dropped whole. A chunk that fails base64 validation surfaces as an UnknownEvent and never kills the stream. The exact bounds are deliberately per-SDK implementation details (Python evicts by count, TypeScript and Swift expire by TTL); only the wire format is shared.
The Swift SDK uses an actor-isolated EnvelopeReassembler that enforces:
- Maximum 8 in-flight envelopes at a time.
- Maximum 4 MiB total across all in-flight envelopes.
- 30-second TTL — stale envelopes are swept on each new chunk arrival.
// From Sources/CosmoRealtime/Session/RealtimeSession.swift in cosmo-swift-sdk
let result = await reassembler.consume(
envelopeId: envelopeId,
seq: seq,
total: total,
data: chunkData
)
switch result {
case .pending:
break // waiting for more chunks
case .complete(let assembled):
await _receiveFrame(assembled) // re-enter the normal dispatch path
case .invalid(let reason):
// Surface on the forward-compatibility variant; never terminal.
eventsContinuation.yield(.unknown(rawType: "server-envelope-chunk", payload: data))
}Invalid envelopes (bad base64, seq/total mismatch, byte cap exceeded) are logged, surfaced as .unknown events, and discarded without crashing the session.
Impact on image streaming
Chunking applies to sendImage(), not to video tracks. A ~60 KiB frame inflates to roughly 80 KiB once base64-encoded, so it splits into 11 chunks — the worked example above. Resize before encoding if you send stills on an interval — a 320×240 frame usually lands under the threshold and ships as a single packet. Sizing and format guidance for frames lives in Image input.
For continuous capture, prefer addVideoStream(): the track rides RTP, skips the data channel, and never chunks. Lowering fps there reduces bandwidth but has no bearing on envelope chunking.
// TypeScript — continuous camera track, no envelope chunking
const handle = await session.addVideoStream(cameraStream, {
kind: 'camera',
fps: 1,
});# Python — sending a resized JPEG
import base64
# Resize to 320×240 before encoding to stay under the threshold.
data = base64.b64encode(resized_jpeg_bytes).decode()
await session.send_image(data=data, mime_type="image/jpeg", stream_id="camera")send_image is the supported path — it applies the same chunking as any other
oversized frame, so you never assemble envelopes yourself.
See also
- Build a voice React app —
addVideoStreamusage - Image input — sizing and format guidance for one-shot frames
- Debugging — correlating chunked sessions in server logs