Migrating a basic video call app from Agora to Daily

In our quest to make it easier for developers to evaluate video API platforms, I’m back with another code migration example—this time, we’ll be diving into Agora. This post is going to have a technical implementation focus. If you'd like to learn more about the differences between Daily's and Agora's higher-level API features and pricing, check out our comparison page.

We’re going to go through the main points of converting Agora’s basic video call demo to Daily. This will include:

  • Joining a video call with an optional meeting token and user name
  • Managing audio and video track subscriptions
  • Handling local and remote tracks
  • Input device selection for fun and profit
  • Switching between track constraints at runtime
  • Codec selection
  • Handling the local call lifecycle

Getting started

You can check out a deployed version of the converted demo here.

Alternatively, follow the steps below to run the demo repo locally:

Running the demo app

To start the demo app, run the following commands in your terminal:

  1. git clone git@github.com:daily-demos/agora-daily-conversion.git
  2. cd agora-daily-conversion
  3. npm i && npm demo:dev

In your terminal, you should see a message specifying what port the demo is running on:

Screenshot of terminal log line saying "Example app listening on port 3000"

Go to  localhost:3000 in your web browser.

To join a video call in the demo, you’ll need a Daily room. Follow the steps below to create one.

Creating a Daily room

  1. Create a free Daily account.
  2. Create a Daily room. With a free account, you can have up to 50 rooms and 10,000 free participant minutes per month.
  3. On the Daily dashboard rooms page, click on the name of the room you just created and take note of the room URL. This is the URL you’ll put into the demo call entry form.
Rooms details in Daily's developer dashboard with the URL field highlighted.
You can also create Daily rooms programmatically with our REST API.

Once you navigate to either the deployed demo or the relevant localhost address in your browser, you’ll be presented with a “Basic Video Calling” demo to try. Click “Try it now”:

Demo entry view showing "Basic Video Calling" demo card with a button that says "Try it now"

Paste the room URL you took note of earlier in the “Room URL” text input box. Enter an optional user name. You can also specify a meeting token if you’d like. We won’t delve too deeply into tokens here, but you can learn all about them in our meeting tokens guide:

An HTML form with input fields for room URL, optional token, and optional user name

Click “Join”. You should now see your own video displayed underneath the entry form:

In-call view showing the local video feed

Click the link in the green banner at the top of the app UI to join the same call in a new browser tab. At this point, the remote participant’s video will also be displayed:

In-call view showing one local and one remote participant

You can also click the “Advanced Settings” button to select your media devices, update your track constraints, and select your preferred codec. I won’t go into the details of these settings here, because they’ll be covered in the migration sections below.

Now that we’ve got the app up and running and know how to join a video call, let’s go through how I modified the demo to migrate it from Agora to Daily, starting with creation of the call client.

Call client creation

With Agora, the call client (an instance of AgoraRTCClient) is created when the app user submits the join form.

Agora call client creation:

$("#join-form").submit(async function (e) {
  $("#join").attr("disabled", true);
  try {
    if (!client) {
      client = AgoraRTC.createClient({
        mode: "rtc",
        codec: getCodec()
// The rest of the handler below...

With Daily, I’m going to create the call client (an instance of Daily’s call object earlier in the process, when the page first loads:

Daily call object creation:

() => {
   if (!client) {
     client = DailyIframe.createCallObject({
       subscribeToTracksAutomatically: false,
   // The rest of the setup logic below

I do this in order to set up some Daily event handlers before the user joins the call.  For example, Daily’s ”track-started" event will let me detect which input devices the participant is currently using. I’ll use this information to update the device selection UI with the appropriate device names.

You can also see that I have set subscribeToTracksAutomatically to false during call object creation above. By default, all participants in a Daily room subscribe to all other participants automatically. To remain in-line with Agora’s initial implementation and show how track subscriptions work with Daily, I’ve changed this default to not subscribe to other participants by default.

Call entry form

In Agora’s version of the basic video call demo entry form, you see the following input elements:

Form with Agora's original input fields (app ID, token, channel name, and user ID)

In Daily’s version, we’re going to replace the App ID field with a room URL. Where in Agora you create up to 20 projects in your developer dashboard, with Daily you create rooms: up to 50 of them by default, and up to 100,000 if you add a payment card to your account.

Each Agora project gets a globally unique ID, and each Daily room gets its own unique room URL.

Agora’s entry form also asks you to enter a user ID. With Daily, a user ID can be specified through a Daily meeting token. This property is often used to tie a user’s Daily meeting token to a user in your own account management system. It’s optional, because each Daily user also gets their own session ID automatically. The session ID enables Daily (and you!) to identify each unique participant.

In the converted entry form of this demo, we’re going to optionally enter a user name. This doesn’t require a meeting token to set, and can be changed during the call if desired (though I won’t implement name-changing functionality in this demo, sticking to Agora’s basics).

With that said, the Daily version of the call entry form will look like this:

Call join form converted to Daily, with input fields for room URL, optional token, and optional user name.

Input device selection

The next thing we should look at is input device selection, which the user can perform either pre-call or after they’ve joined. The relevant dialog looks as follows and is accessible through the “Advanced Settings” button:

Microphone and camera device selection list

Below, I’ve listed the functions used in Agora’s version of this functionality, and what I’ve replaced them with when converting it to Daily:

Let’s take a look at the code illustrating the above changes (note the inline comments for explanations of the logic as you read):

Daily’s device initialization logic:

 * Start the user's camera and microphone and populate
 * the device selection list.
async function initDevices() {

  // Obtain the current meeting state
  const meetingState = client.meetingState();

  // Only start the camera if this function is called
  // before the user has joined (or started the process
  // of joining) a meeting.
  if (meetingState !== "joined-meeting" && meetingState !== "joining-meeting") {
  // Get all input devices, then update the
  // device selection UI.
  client.enumerateDevices().then(devices => {

  // Set up device change listener, for handling newly plugged in
  // or removed devices.
  navigator.mediaDevices.addEventListener('devicechange', () => {
    client.enumerateDevices().then(devices => {

In upateDeviceSelection(), I take a list of all available input devices and update the device selection dropdown DOM elements:

Daily’s device selection list update logic:

 * Update the list of available cameras and microphones.
 * @param {Object} devices - All available devices
function updateDeviceSelection(devices) {
  const d = devices.devices;

  // Reset global device list
  mics = [];
  cams = [];

  // Iterate through all devices
  for (let i = 0; i < d.length; i += 1){
    const device = d[i];
    const kind = device.kind;
    // If device is a camera or microphone,
    // add it to relevant device array.
    if (kind === "audioinput") {
    } else if (kind === "videoinput") {

  // Populate mic list
  mics.forEach(mic => {
    $(".mic-list").append(`<a class="dropdown-item" href="#">${mic.label}</a>`);

  // Populate cam list
  cams.forEach(cam => {
    $(".cam-list").append(`<a class="dropdown-item" href="#">${cam.label}</a>`);

Now, for the actual switching of the camera or microphone, the click handlers remain totally unchanged from Agora’s prior logic:

$(".cam-list").delegate("a", "click", function (e) {
$(".mic-list").delegate("a", "click", function (e) {

The difference here is in the content of those switching functions: switchCamera() and switchMicrophone(). As an example, I’ll cover switchCamera() below:

Agora’s camera-switching logic:

async function switchCamera(label) {
   currentCam = cams.find(cam => cam.label === label);
   // switch device of local audio track.
   await localTracks.videoTrack.setDevice(currentCam.deviceId);

In the Daily conversion, instead of accessing a local audio track and calling setDevice(), we simply call the setDevicesAsync() call object instance method:

Daily’s camera-switching logic:

async function switchCamera(label) {
   currentCam = cams.find(cam => cam.label === label);
   // switch device of local video track.
     videoSource: currentCam.deviceId,  

With that, our device selection functionality migration is complete! Let’s move on to the next configuration option in the demo: selecting a video profile.

Video profile selection

Video call demo is video profile selection is another feature in the “Advanced Settings” menu of the demo:

Video profile selection dialog

Like input devices, these profiles can be selected before or after joining a video call.

Agora has a number of video profile presets that dictate track constraints in the demo dropdown. Agora sets these profiles with a method called setEncoderConfiguration() on its video track object.

With Daily, these constraints are set using the setBandwidth() call object instance method.

The changes for this are pretty minor. First, I updated the value property on each profile in the videoProfiles array to be an object instead of a preset string:

Agora profile definition:

var videoProfiles = [{
   label: "360p_7",
   detail: "480×360, 15fps, 320Kbps",
   value: "360p_7"
 // Remaining video profiles defined below...

Daily profile definition:

var videoProfiles = [{
   label: "360p_7",
   detail: "480×360, 15fps, 320Kbps",
   value: { 
     kbs: 320, 
     trackConstraints: { 
       width: 480,
       height: 360,
       frameRate: 15,
// Remaining video profiles defined below…

Then, when the user selects a profile to switch to, instead of calling localTracks.videoTrack.setEncoderConfiguration(), I call client.setBandwidth():

Agora video profile switching:

async function changeVideoProfile(label) {
   curVideoProfile = videoProfiles.find(profile => profile.label === label);
   // change the local video track`s encoder configuration
   localTracks.videoTrack && (await localTracks.videoTrack.setEncoderConfiguration(curVideoProfile.value));

Daily video profile switching:

async function changeVideoProfile(label) {
   curVideoProfile = videoProfiles.find(profile => profile.label === label);
   // change the local video track`s encoder configuration

Note that by default, Daily makes use of dynamic simulcast layers to send video call participants the best quality video for their network. The track constraints set by setBandwidth() constrain the highest simulcast layer. However, the call machine may still choose to send lower layers depending on the user’s network conditions. In order to avoid this and reliably present the video profile explicitly chosen by the user, in this demo I’ve defined a single simulcast layer using Daily’s call object camSimulcastEncodings property when joining the call. This ensures that the video track being received by participants will have the resolution, frame rate, and bitrate the sending user expects.

Sneak peek: Additional Daily API features making it even easier to update your media publishing settings are coming soon!

With that, we’ve got the profile selection functionality ported to Daily.

Codec selection

The final setting the user can configure in this demo is their preferred codec, selecting from either H264 or VP8. This selection needs to take place before the call is joined:

Codec selection showing VP8 and H264 radio buttons.

Agora’s  createClient()  function takes an object with an optional codec property, which is the value the user has selected:

Agora’s codec specification logic:

     client = AgoraRTC.createClient({
         mode: "rtc",
         codec: getCodec()

Daily uses the VP8 codec by default. It provides two call object properties you can use to favor the H264 codec: preferH264ForCam and preferH264ForScreenSharing.

Because this demo does not utilize screen sharing, I’ve just used the preferH264ForCam property in this implementation.

Daily’s codec specification logic:

  const wantH264 = getCodec() === "h264";
  if (wantH264) {
    joinOptions.dailyConfig.preferH264ForCam = true;

You then pass the joinOptions object referenced above to the join() call object instance method, which I’ll be going through next.

Joining the video call

The call join flow is pretty similar between Agora and Daily, with the main difference being that with Agora, you await a call to client.join() whereas with Daily, I’m going to instead listen for the "joined-meeting"  event that is emitted on the call object instance.

Agora’s call joining logic:

  // Join the channel.
   options.uid = await client.join(options.appid, options.channel, options.token || null, options.uid || null);
   if (!localTracks.audioTrack) {
     localTracks.audioTrack = await AgoraRTC.createMicrophoneAudioTrack({
       encoderConfig: "music_standard"
   if (!localTracks.videoTrack) {
     localTracks.videoTrack = await AgoraRTC.createCameraVideoTrack({
       encoderConfig: curVideoProfile.value

   // Play the local video track to the local browser and update the UI with the user ID.

  $("#joined-setup").css("display", "flex");

  // Publish the local video and audio tracks to the channel.
  await client.publish(Object.values(localTracks));
  console.log("publish success");

Above, Agora waits for the client.join() call to be resolved. It then creates the local user’s camera and microphone tracks if they have not already been created. Finally, it plays the local tracks and awaits a call to client.publish(), which actually sends these local tracks to other participants in the channel.

Daily’s flows are different. Instead of explicitly publishing media tracks, I set startAudioOff: false and startVideoOff: false in the join configuration:

Daily’s call joining logic:

 const joinOptions = {
     url: options.roomurl,
     startAudioOff: false,
     startVideoOff: false,
     userName: "No name",
     dailyConfig: {
       modifyLocalSdpHook: hook,

   const userName = options.uname;
   if (userName) {
     joinOptions.userName = userName;
   const token = options.token;
   if (token) {
     joinOptions.token = token;

   // Join the room.

Once the local participant has joined the Daily video call room, a "joined-meeting" event will be emitted on the call object, which I handle as follows:

Daily "joined-meeting" event handler:

       .on("joined-meeting", (ev) => {
         // Get local participant from event payload
         const p = ev.participants.local;
         $("#local-player-name").text(`localVideo(${p.user_name} - ${p.session_id})`); 

        // Set SFU network topology, to more closely resemble
        // Agora's behavior.
        client.getNetworkTopology().then((res => {
          if (res.topology !== "sfu") {
            // In a production app where you create rooms
            // via the REST API, I suggest using our
            // `sfu_switchover` room property instead.
            client.setNetworkTopology({topology: "sfu"});

        // Update local user's video track if it already exists
        const videoTrack = p.tracks?.video?.persistentTrack;
        if (videoTrack) {
          updateMedia(p.session_id, videoTrack, true);

         // As soon as the user joins, set bandwidth
         // to their chosen video profile.

Let’s briefly go through what’s happening up above:

First, I update the local participant’s label in the demo UI.
Second, I check the room’s current network topology. This will tell me if the room is in P2P (Peer-to-Peer) or SFU (Select Forwarding Unit) mode. Daily’s rooms start out in P2P mode by default, and auto-toggle between them based on how many participants are in the call. However, to keep this demo in-line with Agora’s default functionality, I go ahead and switch topology to SFU mode right away if it is not already there. Check out our guide on video call architecture for more information about network topologies. Out of the implementation scope of this demo, but you may also be interested in our REST API property to enable mesh SFU, which should be used for large calls
Third, I go ahead and display their video track if it is already available (if not, it’ll be handled as part of our ”track-started” event logic, which I’ll cover below!)
Finally, I call setBandwidth(), which we discussed earlier, to set their media track constraints to the selected video profile.

Managing remote participants

In Agora’s original demo join() function, the had a couple of calls to handle user events:

Agora user event handler setup:

  client.on("user-published", handleUserPublished);
  client.on("user-unpublished", handleUserUnpublished);

With Daily, we’re instead going to listen for "participant-joined" and `"participant-left" events:

  client.on("participant-joined", (ev) => {
  client.on("participant-left", (ev) => {

The two events above will be fired for every existing remote participant in the room, as well as any new participant. They will not fire for the local participant.

In my migration to Daily, I removed Agora’s original handleUserPublished() and handleUserUnpublished() methods in favor of an arrow function. Those handlers contained some unnecessary logic (specifically, keeping remote participants in a global variable which we never use).

So, when a remote participant joins the call, I instruct Daily to subscribe to their tracks:

Daily track subscription logic:

async function subscribe(uid) {
  // Set up user's media player

  // Subscribe to a remote user
  client.updateParticipant(uid, {
    setSubscribedTracks: { audio: true, video: true }

Above, I first create the relevant DOM elements to contain things like the participant’s label, video tag, and audio tag. Then, I use Daily’s updateParticipant() instance method to subscribe to the video and audio tracks of the participant with the given ID.

So now, we’ve subscribed to the remote participant’s tracks. How do we know when the subscription is complete? And how do we actually display local and remote media tracks in our application?

Track handling

Alright, we’ve joined the Daily video call. We’ve subscribed to all remote participants. How do we display their (and our!) video, and play their audio in the application?

This is possibly where we have the biggest difference between the Agora and Daily implementations of this demo. Agora’s version keeps a global reference to the local user’s video and audio tracks. It then performs various operations on those tracks, like playing them or updating the user media constraints.

Daily is more event-based in this regard. With Daily, we retrieve each participant’s video and audio tracks based on the emission of "track-started" and "track-stopped" events. We don’t need to keep a global reference to the local tracks because when and if they change, we’ll get a new reference through events emitted on Daily’s call object instance.

Let’s take a look at the "track-started" handler below. I set up this handler when the Daily call object is first created, which is when the page loads. This allows me to handle the local user’s tracks when they change their camera or microphone device, in order to grab the device labels and display them in the UI. I’ve left comments in-line to help guide you through what’s happening:

Daily track start handling logic:

     client.on("track-started", (ev) => {
        const p = ev.participant;
        const track = ev.track;
        const kind = track.kind;
        const label = track.label;

        // Make sure device selection is populated with
        // currently chosen devices.
        if (kind === "audio") {
        } else if (kind === "video") {

        // Only show media if already in the call, or in 
        // the process of joining the call.
        const meetingState = client.meetingState();
        if (meetingState === "joined-meeting" || meetingState === "joining-meeting") {
          updateMedia(p.session_id, track, p.local);

Above, the first thing I do is update the Advanced settings UI to reflect the user’s currently used devices:


Then, if the local participant is in a call or in the process of joining one, I call updateMedia(), which is where the participant’s media is actually rendered:

Daily’s media update logic

function updateMedia(uid, track, isLocal) {
  const tagName = track.kind;
  if (tagName !== "video") {
    // If this is a local user, early out if this
    // isn't a video track (we don't want to play 
    // local audio). If this is a remote user and
    // this is not a video OR audio track, early out
    // as any other track type is unsupported in this demo.
    if (isLocal || tagName !== "audio") {
  let playerContainer = getPlayerContainer(uid, isLocal);
  if (!playerContainer) {
    playerContainer = getPlayerContainer(uid);

  // Retrieve either video or audio element based on
  // what kind of track this is.
  const ele = getMediaEle(playerContainer, tagName);
  updateTracksIfNeeded(ele, track)

The last function called there (updateTracksIfNeeded()) ensures that the retrieved media element is updated with the provided track, and any old tracks are removed:

Daily’s track update logic:

function updateTracksIfNeeded(mediaEle, newTrack) {
  // Get existing source object from media element
  const src = mediaEle.srcObject;
  // If source object does not already exist, create a new one
  // and early out. Update complete!
  if (!src) {
    mediaEle.srcObject = new MediaStream([newTrack]);

  // Get all existing tracks from the existing source object
  const allTracks = src.getTracks();
  const l = allTracks.length;
  if (l === 0) {
    // If the existig source object has no tracks, just add
    // the new track and early  out.
  if (l > 1) {
    console.warn(`Expected 1 track, got ${l}. Only working with the first.`)
  // Retrieve the first track
  const existingTrack = allTracks[0];
  // If the existing track ID does not match the new track ID,
  // remove the existing track from the source object and add
  // the new track. If IDs match, it must be the same track, so
  // no need to do anything.
  if (existingTrack.id !== newTrack.id) {

Both local and remote participant tracks are handled via this one code path. In this way, we keep all participant tracks updated at all times.

Leaving the video call

Leaving the video call remains largely the same after converting the code to Daily. I remove the remote player DOM elements, await the leave() call object instance method, and remove the local media DOM elements.


In this post, we’ve covered the migration of a simple Agora video call demo over to Daily. To learn more about Daily versus Agora on data privacy and security, HD quality and pricing, and other features, visit our comparison page.

Want to chat more about converting your Agora app to our video APIs? Get in touch with our support team or jump into our WebRTC community to discuss your use case!

Never miss a story

Get the latest direct to your inbox.