DEV Community

Falgrim78
Falgrim78

Posted on

Realtime WebRTC Message Limits — Failure Recovery for Node.js Video Consults

A video consultation room should treat message size as a negotiated runtime constraint, not a constant hidden in a client library. Read the WebRTC data-channel limit, reserve bytes for the envelope, split only payloads that are safe to reassemble, and make an oversized message observable and recoverable. Keep media on the media path; keep typing indicators and read receipts small enough to survive reconnects.

Short answer: cap application messages below the channel's advertised maximum, reject or chunk oversized payloads before send(), and backfill durable receipt state after reconnect instead of replaying every transient keystroke.

Decision Pick this when Failure behavior
Single bounded message Typing or a receipt fits the negotiated limit Send once with an idempotent event id; record success or rejection
Chunked payload A document or audit bundle must cross the same channel Add a transfer id and sequence; expire incomplete transfers
Durable backfill A receipt affects the room's shared truth Store the state server-side and fetch from the last revision after reconnect

The room in this guide is a logistics call between a driver and a dispatcher. A typing signal can disappear. A read receipt cannot silently move backward because a phone changed networks.

What should a realtime video consultation room do about message size limits and failure handling?

Start with the value exposed by the browser. The W3C WebRTC Recommendation defines RTCSctpTransport.maxMessageSize, which represents the maximum size for a message sent over the SCTP transport. It is a property of the negotiated connection, so two rooms or two browser pairs may produce different values. Do not bake one number into a shared constant and call it a protocol.

The diagram in words: application event -> envelope budget check -> data channel -> receiver reassembly -> reducer -> durable reconciliation. The envelope is part of the budget. If a JSON object is 900 bytes in one language and 1,200 after UTF-8 encoding, a character count is the wrong measurement.

A practical envelope carries a stable event id, room id, kind, revision when the event is durable, and the payload. The client measures encoded bytes, not JavaScript string length. It rejects before send() so the transport exception does not become the user's failure message.

type RoomEvent = {
  eventId: string;
  roomId: string;
  kind: "typing" | "read_receipt" | "attachment_chunk";
  revision?: number;
  payload: unknown;
};

function encodedBytes(event: RoomEvent): number {
  return new TextEncoder().encode(JSON.stringify(event)).byteLength;
}

function channelLimit(channel: RTCDataChannel): number {
  const limit = channel.transport?.maxMessageSize;
  if (typeof limit !== "number" || !Number.isFinite(limit)) {
    throw new Error("SCTP message limit is unavailable");
  }
  return limit;
}

export function sendBounded(
  channel: RTCDataChannel,
  event: RoomEvent,
  safetyBytes = 256,
): "sent" | "rejected" {
  const budget = channelLimit(channel) - safetyBytes;
  if (budget <= 0 || encodedBytes(event) > budget) return "rejected";
  channel.send(JSON.stringify(event));
  return "sent";
}
Enter fullscreen mode Exit fullscreen mode

The safety margin is local policy, not a WebRTC requirement. I use it to leave room for a future envelope field, then measure rejection rates. Your mileage may vary; the right margin depends on whether the channel carries only tiny control events or also carries occasional structured records.

Keep typing ephemeral and receipts durable

Typing indicators are presence-like hints. Give them a short expiry, coalesce repeated updates, and drop them while the channel is backpressured. A dispatcher does not need ten typing=true events queued behind a stale network path. A single typing event with a timestamp is enough to render a temporary state.

Read receipts are different. Persist the highest message sequence each participant has read, or persist an equivalent monotonic marker, then publish a compact notification. On reconnect, the client asks for the authoritative receipt revision and applies it only if it is newer than its local value. That is backfill, not replay.

type Receipt = {
  participantId: string;
  messageSeq: number;
  revision: number;
};

export function applyReceipt(current: Receipt | undefined, incoming: Receipt): Receipt {
  if (!current) return incoming;
  if (incoming.revision <= current.revision) return current;
  if (incoming.messageSeq < current.messageSeq) return current;
  return incoming;
}

export function shouldQueue(kind: RoomEvent["kind"], buffered: number): boolean {
  if (kind === "typing") return false;
  return buffered < 64 * 1024;
}
Enter fullscreen mode Exit fullscreen mode

One early implementation queued both event types in the same FIFO. It looked orderly in a test room, then a brief disconnect made the queue contain old typing pulses ahead of a receipt. The receipt arrived late, the UI appeared to un-read a message, and the only useful clue was a rising bufferedAmount. Separate the semantics before you add retries.

Instrument the rejection path before shipping

A failed send() is not enough telemetry. Record the event kind, encoded byte length, negotiated limit, channel label, connection state, and a pseudonymous room identifier. Never record consultation text or a full attachment while diagnosing a size problem. The useful metric is a ratio: rejected bytes or events divided by attempted events, split by browser and release.

Emit counters for message_rejected_size, message_dropped_backpressure, receipt_backfill_requested, and receipt_revision_gap. Add a duration for reconnect-to-convergence. An alert should fire on a sustained increase in receipt gaps, not on one driver's laptop losing Wi-Fi.

type SizeObservation = {
  kind: RoomEvent["kind"];
  bytes: number;
  limit: number;
  connectionState: RTCPeerConnectionState;
  roomKey: string;
};

export function observeSize(o: SizeObservation): void {
  metrics.increment("message_rejected_size", 1, {
    kind: o.kind,
    browserLimit: String(o.limit),
    connectionState: o.connectionState,
  });
  logs.info("realtime message rejected", {
    ...o,
    roomKey: hashForMetrics(o.roomKey),
  });
}

const metrics = {
  increment(name: string, value: number, labels: Record<string, string>) {
    void [name, value, labels];
  },
};

const logs = {
  info(message: string, fields: unknown) {
    void [message, fields];
  },
};

function hashForMetrics(value: string): string {
  return value.slice(0, 8);
}
Enter fullscreen mode Exit fullscreen mode

That hash is a placeholder for an application-approved pseudonymization function, not a security primitive. The point is to keep the metric joinable without putting a room identifier into every dashboard label.

One useful drill is a three-step reconnect with a dispatcher tablet. First, send a read receipt at revision 41, then cut the data channel while the tablet is offline and let the driver read two more messages. The tablet must not manufacture revisions 42 and 43 from local timestamps; it should mark its receipt as stale and request the authoritative marker. Next, deliver a duplicate notification and a notification whose payload is larger than the current budget. The duplicate should be harmless, while the oversized event should be rejected before transport and counted with the negotiated limit that was observed for that room. Finally, restore the channel with a smaller maxMessageSize than the previous connection, apply the backfilled receipt, and confirm that the UI converges once. This sequence exercises the awkward ordering that happy-path demos skip: a local queue, a changed transport capability, a revision gap, and a duplicate can all coexist. Logs should make each transition legible without storing the consultation itself, so an on-call engineer can explain why the receipt moved forward even though a typing indicator vanished.

Keep the signal small.

Test reconnects, limits, and partial transfers

The catch is scope. A data channel is not a durable log, and maxMessageSize does not give you a server-side history. This design is unsuitable when a consultation needs guaranteed delivery of large clinical or legal artifacts; use an authenticated upload or durable message store and send only its reference through realtime. It is also a poor fit if your target browsers do not expose a dependable SCTP limit, because guessing creates silent fragmentation failures.

Test with the smallest observed negotiated limit, a payload exactly at the budget, one byte over, a channel with a growing bufferedAmount, a disconnect between chunks, duplicate chunks, and a reconnect after the receipt revision has advanced. Verify that an incomplete transfer expires, that a duplicate event id is harmless, and that the final screen converges to the durable receipt state.

I am not sure a single chunk size will remain optimal as browser implementations and network paths change. Capture the negotiated limit at connection time, keep the policy configurable, and review the distributions after each release. The operational win is clarity: a driver sees a temporary typing gap, while the dispatcher and driver eventually agree on the same read receipt.

References

Top comments (0)