Data messages
Every channel carries a data channel alongside audio and video. Anyone in the channel can broadcast bytes to everyone else, which is enough to build chat, reactions, hand-raising, cursor positions and any other signalling your app needs — without a second server connection.
Sending and receiving#
sendData broadcasts to every other participant in the channel. There is no addressing: everyone receives everything. Receivers get the raw bytes and the user who sent them.
// Send. Strings are UTF-8 encoded for you.await engine.sendData(JSON.stringify({ type: 'app.chat', text: 'hello' }), true);// Send raw bytes instead, if you have your own encoding.await engine.sendData(new Uint8Array([1, 2, 3]), true);// Receive.engine.on('data-received', (bytes, user) => {// user is the sender, or undefined for platform-originated messagesconst text = new TextDecoder().decode(bytes);console.log(user?.uid, text);});
Audience members can send data
Publish permission covers audio and video. Every role — including audience — may publish data, which is why chat and reactions work in a live stream where viewers cannot speak. See live streaming.
Reliable or lossy#
The second argument to sendData (reliable in Flutter) chooses the delivery mode. It defaults to reliable.
| Mode | Guarantees | Use for |
|---|---|---|
reliable = true (default) | Delivered and in order, retransmitted if a packet is lost. A message may be delayed by a retransmission. | Chat, hand-raise, moderation commands, state changes, anything a user would notice missing. |
reliable = false | Sent once. May arrive out of order, or not at all. | High-frequency ephemeral updates: cursor positions, drawing strokes, live scores, typing indicators. |
The rule of thumb: if a newer message makes an older one irrelevant, send it lossy. If losing one leaves the receiver's state wrong, send it reliable.
// A chat line must arrive.await engine.sendData(JSON.stringify({ type: 'app.chat', text }), true);// A cursor position at 30 Hz does not — the next one is a second away.await engine.sendData(JSON.stringify({ type: 'app.cursor', x, y }), false);
Size and rate#
- Keep messages small. A few kilobytes is comfortable; aim to stay under about 15 KB per message.
- Do not send images, audio or file contents. Upload them to your own storage and send the URL.
- Do not send a message per keystroke. Batch or throttle high-frequency updates to something like 10–30 per second, and prefer lossy for those.
- Large or frequent messages compete with audio and video on the same connection. When in doubt, put the payload behind a link and send the link.
Data messages are not stored
A message reaches the people who are connected at that moment. Nobody who joins later sees it, and there is no history to fetch. If your chat needs a backlog, persist it on your server — either by having clients POST each message to your API alongside sending it, or by treating your API as the source of truth and using the data channel only for live delivery.
Building chat on it#
Wrap every message in a small envelope with a type field. This is what lets one channel carry chat, reactions and your own signals at once, and it is what makes unknown messages safe to skip.
const CHAT = 'app.chat';// --- sending -------------------------------------------------------------export async function sendChat(engine, text) {const trimmed = text.trim().slice(0, 500); // cap it yourselfif (!trimmed) return;const msg = {type: CHAT,id: crypto.randomUUID(), // for de-duplicationtext: trimmed,at: Date.now(),};await engine.sendData(JSON.stringify(msg), true);// Your own message is NOT echoed back to you — render it locally.appendMessage({ ...msg, uid: engine.localUid, mine: true });// Optional: persist so late joiners and history work.void fetch('/api/chat', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ channel: engine.channelName, ...msg }),});}// --- receiving -----------------------------------------------------------const seen = new Set();engine.on('data-received', (bytes, user) => {let msg;try {msg = JSON.parse(new TextDecoder().decode(bytes));} catch {return; // not JSON — not ours}if (msg?.type !== CHAT) return; // someone else's message typeif (seen.has(msg.id)) return; // duplicateseen.add(msg.id);appendMessage({uid: user?.uid,name: user?.displayName,text: String(msg.text ?? '').slice(0, 500),at: msg.at,});});
Things that catch people out
- You do not receive your own messages. Render locally when you send.
- The sender identity comes from the SDK, not the payload. Use
user.uid, never auidfield inside the message — a client can put anything there. - Treat every payload as untrusted. Clamp lengths and escape text before inserting it into the DOM. Any participant can send any bytes.
- Moderation belongs on your server. A client-side filter is bypassed by anyone who calls
sendDatadirectly.
Platform events share this channel#
AppLooma broadcasts some of its own messages on the same data channel. A virtual gift arrives as JSON with type: "applooma.gift", and more platform message types may be added over time.
Ignore JSON you do not recognise
Your data-received handler must check type and return quietly for anything it does not handle, including payloads that are not JSON at all. Code that assumes every message is its own chat message will break the first time a gift is sent, or the first time a new platform event type ships.
engine.on('data-received', (bytes, user) => {let msg;try {msg = JSON.parse(new TextDecoder().decode(bytes));} catch {return; // binary or non-JSON: not ours}if (typeof msg?.type !== 'string') return;switch (msg.type) {case 'app.chat': return onChat(msg, user);case 'app.reaction': return onReaction(msg, user);case 'app.raise-hand': return onRaiseHand(msg, user);default: return; // platform events and future types}});
Prefix your own types with something of your own (app., myapp.) and leave the applooma. prefix to us. That way a new platform event can never collide with one of yours.
The SDKs already parse gift messages and re-emit them as a dedicated gift event, so the usual arrangement is: listen to the gift event for gifts, and use data-received for your own types only.
Other things to build on it#
| Feature | Mode | Shape |
|---|---|---|
| Emoji reactions | Lossy | { type: 'app.reaction', emoji: '❤' } |
| Typing indicator | Lossy | { type: 'app.typing', on: true } |
| Raise hand | Reliable | { type: 'app.raise-hand' } |
| Poll result update | Reliable | { type: 'app.poll', id, counts } |
| Whiteboard stroke | Lossy | { type: 'app.stroke', points } |
Anything that changes what a user is allowed to do — being promoted to speaker, being muted by a moderator — must not be decided on the data channel. Send the request over it if you like, but make the decision on your server and act on it by issuing a new token. See audio rooms.