Daily models every media stream — camera, microphone, screen share, and custom tracks — through the DailyTrackState interface. Understanding track states is essential for building a reliable call object UI.
DailyTrackState
See the DailyTrackState reference for the full interface definition and field descriptions.
Track States
The state field describes the current condition of a track — not a step in a sequential progression:
| State | Meaning |
|---|
blocked | The track cannot be sent. Check blocked.byDeviceMissing, byDeviceInUse, or byPermissions for the cause. |
off | The track is intentionally not being sent. Check the off sub-fields to distinguish an implicit user mute from other causes like remote muting. |
sendable | The track is ready to be sent, but has not been subscribed to, and is neither blocked nor off. |
loading | Daily is acquiring the device or establishing the stream. |
interrupted | The track was previously playable but has temporarily stalled — common during network fluctuations or tab backgrounding on iOS. |
playable | The track is live and renderable. |
Why the off sub-fields matter
const audioState = participant.tracks.audio;
if (audioState.state === 'off') {
if (audioState.off?.byUser) {
// Participant muted themselves
} else if (audioState.off?.byRemoteRequest) {
// Owner muted them remotely
} else if (audioState.off?.byBandwidth) {
// Daily throttled the track due to bandwidth
} else if (audioState.off?.byCanSendPermission) {
// Participant lacks permission to send this track
} else if (audioState.off?.byServerLimit) {
// Server-side participant limit reached
}
}
Track Types
Each DailyParticipant carries a tracks object of type DailyParticipantTracks:
interface DailyParticipantTracks {
audio: DailyTrackState; // microphone
video: DailyTrackState; // camera
screenAudio: DailyTrackState; // screen share audio
screenVideo: DailyTrackState; // screen share video
[customTrackKey: string]: DailyTrackState | undefined; // custom tracks
}
Custom tracks
Custom tracks let you send arbitrary MediaStreamTrack objects alongside the standard camera and mic. Start them with startCustomTrack() and stop them with stopCustomTrack():
const trackName = await call.startCustomTrack({
track: myCanvasStream.getVideoTracks()[0],
trackName: 'my-canvas',
});
// Accessible on the local participant at:
// call.participants().local.tracks['my-canvas']
await call.stopCustomTrack(trackName);
Custom track keys appear as string index entries in DailyParticipantTracks.
Local vs. remote track behavior
How a track’s initial state is reached differs between local and remote:
- Local tracks start directly as
playable, off, or blocked — they never pass through loading. If a local track starts off and the user enables it with setLocalVideo(true) or setLocalAudio(true), it transitions straight to playable (or blocked if the user denied device permissions).
- Remote tracks can start in any state except
playable or interrupted — most commonly loading (subscribed but stream not yet established), off (sender muted before you joined), blocked (sender has a device issue), or sendable (sender is ready but you haven’t subscribed).
interrupted is never an initial state — it only occurs after a track was playable and then transiently stalled, most commonly during network fluctuations or iOS tab backgrounding. It can return to playable once the condition resolves.
Track Subscription
By default Daily subscribes to tracks from all participants automatically (subscribeToTracksAutomatically: true). In large calls, subscribing to every track is wasteful. Disable automatic subscription to control this explicitly.
const call = Daily.createCallObject({
subscribeToTracksAutomatically: false,
});
DailyTrackSubscriptionState
type DailyTrackSubscriptionState = 'staged' | boolean;
| Value | Meaning |
|---|
true | Subscribed to the track (receiving it). |
false | Not subscribed. |
'staged' | Receive metadata (SDP negotiated) but do not decode. Staged tracks can then transition to playable faster when you set them to true. |
DailyTrackSubscriptionOptions
type DailyTrackSubscriptionOptions =
| DailyTrackSubscriptionState // applies to all tracks for participant
| {
audio?: DailyTrackSubscriptionState;
video?: DailyTrackSubscriptionState;
screenVideo?: DailyTrackSubscriptionState;
screenAudio?: DailyTrackSubscriptionState;
custom?: DailyCustomTrackSubscriptionState;
};
Subscribing per participant
// Subscribe to audio only for a participant
call.updateParticipant(sessionId, {
setSubscribedTracks: {
audio: true,
video: false,
screenVideo: false,
screenAudio: false,
},
});
// Subscribe to everything for a specific participant
call.updateParticipant(sessionId, {
setSubscribedTracks: true,
});
// Stage video for everyone (efficient on-demand loading)
call.updateParticipants('*',
{ setSubscribedTracks: { video: 'staged', audio: true } }
);
Global subscription toggle
// Re-enable automatic subscription after disabling it
call.setSubscribeToTracksAutomatically(true);
// Check current value
console.log(call.subscribeToTracksAutomatically());
Calling setSubscribeToTracksAutomatically(false) during a call will unsubscribe you from all current tracks and require you to manually re-subscribe. Use with caution to avoid unintended media loss. For most cases, it’s best to set this at call creation time and manage subscriptions explicitly from the start.
Attaching Tracks to Video Elements
The recommended approach for rendering tracks is to use track-started and track-stopped events:
const videoElements: Map<string, HTMLVideoElement> = new Map();
call.on('track-started', ({ participant, track, type }) => {
let videoEl = videoElements.get(participant.session_id);
if (!videoEl) {
videoEl = document.createElement('video');
videoEl.autoplay = true;
videoEl.playsInline = true;
document.getElementById('grid')?.appendChild(videoEl);
videoElements.set(participant.session_id, videoEl);
}
videoEl.srcObject = new MediaStream([track]);
});
call.on('track-stopped', ({ participant, track }) => {
// Clear srcObject when track stops
const videoEl = videoElements.get(participant?.session_id ?? '');
if (videoEl) {
videoEl.srcObject = null;
}
});
track vs. persistentTrack
The key difference: track is undefined whenever state !== 'playable'. persistentTrack is present whenever a track exists, regardless of playable state.
We recommend using persistentTrack over track when attaching to media elements. It acts as a proactive defense against black frames during call disruptions and helps avoid browser bugs related to auto-playing media tracks.
call.on('participant-updated', ({ participant }) => {
const trackState = participant.tracks.video;
const track = trackState.persistentTrack;
if (track) {
videoEl.srcObject = new MediaStream([track]);
} else {
videoEl.srcObject = null;
}
});
Use track only when you need a guarantee that the media is currently flowing.
Track Events
| Event | Fires when | Event object |
|---|
track-started | A track becomes playable, or a new track replaces an existing one | { participant, track, type } |
track-stopped | A track leaves playable state, or is replaced by a new track | { participant, track, type } |
participant-updated | Any track state changes | { participant } — check tracks |
The type field in DailyEventObjectTrack is one of 'video', 'audio', 'screenVideo', 'screenAudio', or a custom track name string.
Track replacement
track-started and track-stopped fire not only on state transitions but also when the underlying MediaStreamTrack is replaced — for example when a participant switches cameras or a device change causes a new track to be issued. In this case the track state stays playable throughout, but you will receive a track-stopped for the old track immediately followed by a track-started for the new one.
This means you should key your media elements on the track object itself (or its id), not on participant or track type alone:
call.on('track-stopped', ({ track, type, participant }) => {
// Remove the specific track that stopped, not just any track for this participant
const el = document.getElementById(`track-${track.id}`);
el?.remove();
});
call.on('track-started', ({ track, type, participant }) => {
const el = document.createElement(type === 'audio' ? 'audio' : 'video');
el.id = `track-${track.id}`;
el.autoplay = true;
el.playsInline = true;
el.srcObject = new MediaStream([track]);
document.getElementById(`participant-${participant?.session_id}`)?.appendChild(el);
});