Skip to main content
Daily provides three mechanisms for sharing custom data between participants. They have different persistence, scope, and delivery characteristics — picking the right one for a given use case matters.
sendAppMessage()setUserData()setMeetingSessionData()
ScopePer messagePer participantEntire room
Persists for late joinersNoYesYes
Size limit4KB4KB100KB
Rate limitingNoneEventual consistency~1 update/sec
Custom mode onlyNoNoYes
Received viaapp-message eventparticipant-updated eventmeeting-session-state-updated event

sendAppMessage() — ephemeral real-time messages

sendAppMessage() delivers a JSON payload to one or more participants currently in the call. Messages are ephemeral — they are not stored and participants who join after a message is sent will never see it.
// Broadcast to everyone in the room
call.sendAppMessage({ type: 'reaction', emoji: '🎉' });

// Send to a specific participant
call.sendAppMessage({ type: 'dm', text: 'Can you hear me?' }, participantSessionId);

// Send to a subset of participants
call.sendAppMessage({ type: 'alert', text: 'You are now on stage' }, [id1, id2]);
Use session_id to address recipients, not user_id. Broadcast messages ('*') are not delivered to the sender.
Receive messages by listening for app-message:
call.on('app-message', ({ data, fromId }) => {
  if (data.type === 'reaction') {
    showReaction(fromId, data.emoji);
  }
});
sendAppMessage is also available server-side via the REST API, which is useful for injecting messages from your backend — for example, sending a system notification when a server-side event occurs. Good for: chat, emoji reactions, hand raise notifications, one-time alerts, any event that only matters to participants currently in the room. Not suitable for: state that late joiners need to see, or anything requiring persistence.

setUserData() — per-participant state

setUserData() attaches arbitrary data to the local participant’s entry in the participants map. It is automatically synced to all other participants and is visible to anyone who joins later — they receive it as part of the participants snapshot when they join.
// Set on join
await call.join({
  url: roomUrl,
  userData: { role: 'moderator', handRaised: false },
});

// Update during the call
await call.setUserData({ role: 'moderator', handRaised: true });
Any participant can read any other participant’s userData:
// Read your own
const { userData } = call.participants().local;

// Read a remote participant's
const { userData } = call.participants()[sessionId];
Changes trigger participant-updated for all participants:
call.on('participant-updated', ({ participant }) => {
  if (participant.userData?.handRaised) {
    showHandRaisedIndicator(participant.session_id);
  }
});
The local copy of userData is updated immediately, but propagation to other participants is throttled. All participants are guaranteed to converge on the same final value.
Good for: per-person state that all participants need to see, including new joiners — raised hand status, custom role, display name supplements, avatar URL, speaking queue position. Not suitable for: high-frequency updates (e.g. cursor position), or state that belongs to the room rather than a specific person.

setMeetingSessionData() — room-wide shared state

setMeetingSessionData() writes to a single shared data object scoped to the meeting session. All participants receive updates in near real-time, and the data persists as participants join and leave — new joiners receive the current state immediately.
setMeetingSessionData() is available in custom call object mode only, not in Daily Prebuilt.
// Replace the entire session data object
call.setMeetingSessionData({ activeScene: 'lobby', pollActive: false });

// Shallow-merge: only update specific top-level keys
call.setMeetingSessionData({ pollActive: true }, 'shallow-merge');
Read the current state synchronously at any time:
const { data } = call.meetingSessionState();
console.log('Current scene:', data.activeScene);
Or reactively via event:
call.on('meeting-session-state-updated', ({ meetingSessionState }) => {
  const { activeScene, pollActive } = meetingSessionState.data;
  updateRoomUI(activeScene, pollActive);
});
Updates are batched and synced at most once per second. If multiple participants write concurrently, precise ordering is not guaranteed. This makes setMeetingSessionData() unsuitable for high-frequency updates where sub-second ordering matters — use sendAppMessage() for those instead.
Good for: room-level state shared by all participants — the current scene or layout, whether a poll is active, feature flags, a shared queue, any configuration that needs to survive participant churn. Not suitable for: per-participant state (use setUserData()), high-frequency real-time events (use sendAppMessage()), or data that needs to outlive the session (use your own backend).

Choosing the right mechanism

A few common scenarios: Chat messagessendAppMessage(). Messages are ephemeral by nature; you likely want your own backend for history anyway. Emoji reactionssendAppMessage(). One-shot, ephemeral, addressed to everyone. Raised hand indicatorsetUserData(). It’s per-person state, and new joiners should see who has their hand up. Active speaker layout / scenesetMeetingSessionData(). It’s room-level state, and late joiners should start in the right scene. Server-triggered notificationsendAppMessage() via the REST API. Your backend sends it directly without needing a participant to relay it. Shared poll statesetMeetingSessionData(). The poll and its results belong to the room, not a person.

Example: combining all three

A live Q&A session is a natural fit for all three mechanisms. Participants raise their hand (per-person state), the host brings someone on stage (room-wide state), and the selected participant gets a private cue to unmute (ephemeral targeted message).
// On join — each participant declares their role
await call.join({
  url: roomUrl,
  userData: { role: 'audience', handRaised: false },
});

// Host initializes room-wide state
call.setMeetingSessionData(
  { currentSpeaker: null, queueOpen: true },
  'replace'
);

// --- Audience: raise a hand ---

async function raiseHand() {
  const { userData } = call.participants().local;
  await call.setUserData({ ...userData, handRaised: true });
}

// --- Host: watch for raised hands ---

call.on('participant-updated', ({ participant }) => {
  if (participant.userData?.handRaised) {
    addToSpeakerQueue(participant.session_id, participant.user_name);
  } else {
    removeFromSpeakerQueue(participant.session_id);
  }
});

// --- Host: bring someone on stage ---

function bringOnStage(sessionId) {
  // Update room-wide state so everyone's UI reflects the current speaker
  call.setMeetingSessionData({ currentSpeaker: sessionId }, 'shallow-merge');

  // Send a private cue so the participant knows to unmute
  call.sendAppMessage({ type: 'you-have-the-floor' }, sessionId);
}

// --- Participant: receive the cue and clear their hand raise ---

call.on('app-message', async ({ data }) => {
  if (data.type === 'you-have-the-floor') {
    call.setLocalAudio(true);
    const { userData } = call.participants().local;
    await call.setUserData({ ...userData, handRaised: false });
  }
});

// --- Everyone: highlight the current speaker ---

call.on('meeting-session-state-updated', ({ meetingSessionState }) => {
  const { currentSpeaker } = meetingSessionState.data;
  highlightActiveSpeaker(currentSpeaker);
});
Why each mechanism was chosen here:
  • setUserData for handRaised — it’s per-person, and participants who join mid-session should immediately see who has their hand up.
  • setMeetingSessionData for currentSpeaker — it’s room-level state with one authoritative value, and late joiners need to know who’s on stage.
  • sendAppMessage for the floor cue — it’s a private one-time signal to a single participant, not state that belongs on the room or the participant object.

See also