Blog/Tutorials & Step-by-Step/Build a WebRTC Video Call App with Firebase Signaling
POST
January 25, 2026
LAST UPDATEDJanuary 25, 2026

Build a WebRTC Video Call App with Firebase Signaling

Build a peer-to-peer video call application using WebRTC for media streaming and Firebase Realtime Database for signaling, with room creation, screen sharing, and call controls.

Tags

WebRTCFirebaseVideoReal-Time
Build a WebRTC Video Call App with Firebase Signaling
4 min read

Build a WebRTC Video Call App with Firebase Signaling

TL;DR

Firebase Realtime Database serves as a lightweight signaling server for WebRTC, letting you build a fully functional peer-to-peer video call app with room codes, screen sharing, and call controls without deploying your own signaling infrastructure.

Prerequisites

  • A Firebase project with Firestore enabled
  • Basic understanding of async JavaScript and Promises
  • Node.js 18+ for the development environment
  • A modern browser with camera/microphone access (Chrome, Firefox, or Edge)

Step 1: Project Setup and Firebase Configuration

Create a new project and install Firebase.

bash
npm create vite@latest webrtc-video -- --template react-ts
cd webrtc-video
npm install firebase

Configure Firebase in your project:

typescript
// src/lib/firebase.ts
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
 
const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
 
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);

Create a .env file with your Firebase credentials (never commit this file):

VITE_FIREBASE_API_KEY=your-api-key
VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=your-project-id

Step 2: Understand the WebRTC Signaling Flow

Before writing code, understand the handshake process that two peers go through to establish a direct connection.

  1. Caller creates an RTCPeerConnection and generates an SDP offer
  2. Caller writes the offer to Firebase (the signaling channel)
  3. Callee reads the offer from Firebase and sets it as the remote description
  4. Callee generates an SDP answer and writes it to Firebase
  5. Caller reads the answer and sets it as the remote description
  6. Both peers exchange ICE candidates through Firebase as they are discovered
  7. Once ICE negotiation completes, a direct peer-to-peer media stream is established

Firebase acts only as a relay for the initial handshake. Once the connection is established, media flows directly between the browsers.

Step 3: Configure STUN/TURN Servers

STUN servers help peers discover their public IP addresses for NAT traversal. TURN servers relay traffic when direct connections fail.

typescript
// src/lib/rtcConfig.ts
export const rtcConfiguration: RTCConfiguration = {
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" },
    { urls: "stun:stun1.l.google.com:19302" },
    // Add a TURN server for production reliability
    // {
    //   urls: "turn:your-turn-server.com:3478",
    //   username: "user",
    //   credential: "pass",
    // },
  ],
};

Google provides free STUN servers suitable for development. For production, you should add a TURN server since roughly 10-15% of connections require relaying through restrictive NATs.

Step 4: Create the Media Stream Manager

Build a utility to handle camera and microphone access.

typescript
// src/lib/mediaStream.ts
export async function getLocalStream(
  video: boolean = true,
  audio: boolean = true
): Promise<MediaStream> {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: video
        ? {
            width: { ideal: 1280 },
            height: { ideal: 720 },
            facingMode: "user",
          }
        : false,
      audio: audio
        ? {
            echoCancellation: true,
            noiseSuppression: true,
          }
        : false,
    });
    return stream;
  } catch (error) {
    if (error instanceof DOMException) {
      if (error.name === "NotAllowedError") {
        throw new Error("Camera/microphone access was denied. Please grant permission.");
      }
      if (error.name === "NotFoundError") {
        throw new Error("No camera or microphone found on this device.");
      }
    }
    throw error;
  }
}
 
export async function getScreenShareStream(): Promise<MediaStream> {
  return navigator.mediaDevices.getDisplayMedia({
    video: { cursor: "always" } as MediaTrackConstraints,
    audio: false,
  });
}
 
export function stopStream(stream: MediaStream) {
  stream.getTracks().forEach((track) => track.stop());
}

Step 5: Build the Signaling Layer with Firebase

This is the core of the application. Firebase Firestore stores offers, answers, and ICE candidates in a room document structure.

typescript
// src/lib/signaling.ts
import {
  doc,
  collection,
  setDoc,
  getDoc,
  onSnapshot,
  addDoc,
  updateDoc,
  deleteDoc,
} from "firebase/firestore";
import { db } from "./firebase";
import { rtcConfiguration } from "./rtcConfig";
 
export async function createRoom(
  localStream: MediaStream,
  onRemoteStream: (stream: MediaStream) => void
): Promise<{ roomId: string; peerConnection: RTCPeerConnection }> {
  const pc = new RTCPeerConnection(rtcConfiguration);
 
  // Add local tracks to the connection
  localStream.getTracks().forEach((track) => {
    pc.addTrack(track, localStream);
  });
 
  // Listen for remote tracks
  const remoteStream = new MediaStream();
  pc.ontrack = (event) => {
    event.streams[0].getTracks().forEach((track) => {
      remoteStream.addTrack(track);
    });
    onRemoteStream(remoteStream);
  };
 
  // Create the room document
  const roomRef = doc(collection(db, "rooms"));
  const callerCandidatesRef = collection(roomRef, "callerCandidates");
  const calleeCandidatesRef = collection(roomRef, "calleeCandidates");
 
  // Collect ICE candidates and write them to Firebase
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      addDoc(callerCandidatesRef, event.candidate.toJSON());
    }
  };
 
  // Create and set the offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
 
  await setDoc(roomRef, {
    offer: {
      type: offer.type,
      sdp: offer.sdp,
    },
    createdAt: new Date().toISOString(),
  });
 
  // Listen for the answer from the callee
  onSnapshot(roomRef, (snapshot) => {
    const data = snapshot.data();
    if (data?.answer && !pc.currentRemoteDescription) {
      const answer = new RTCSessionDescription(data.answer);
      pc.setRemoteDescription(answer);
    }
  });
 
  // Listen for callee ICE candidates
  onSnapshot(calleeCandidatesRef, (snapshot) => {
    snapshot.docChanges().forEach((change) => {
      if (change.type === "added") {
        const candidate = new RTCIceCandidate(change.doc.data());
        pc.addIceCandidate(candidate);
      }
    });
  });
 
  return { roomId: roomRef.id, peerConnection: pc };
}
 
export async function joinRoom(
  roomId: string,
  localStream: MediaStream,
  onRemoteStream: (stream: MediaStream) => void
): Promise<RTCPeerConnection> {
  const pc = new RTCPeerConnection(rtcConfiguration);
 
  // Add local tracks
  localStream.getTracks().forEach((track) => {
    pc.addTrack(track, localStream);
  });
 
  // Listen for remote tracks
  const remoteStream = new MediaStream();
  pc.ontrack = (event) => {
    event.streams[0].getTracks().forEach((track) => {
      remoteStream.addTrack(track);
    });
    onRemoteStream(remoteStream);
  };
 
  const roomRef = doc(db, "rooms", roomId);
  const callerCandidatesRef = collection(roomRef, "callerCandidates");
  const calleeCandidatesRef = collection(roomRef, "calleeCandidates");
 
  // Collect ICE candidates
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      addDoc(calleeCandidatesRef, event.candidate.toJSON());
    }
  };
 
  // Get the offer and create an answer
  const roomSnapshot = await getDoc(roomRef);
  if (!roomSnapshot.exists()) {
    throw new Error("Room not found. Check the room code and try again.");
  }
 
  const offer = roomSnapshot.data().offer;
  await pc.setRemoteDescription(new RTCSessionDescription(offer));
 
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
 
  await updateDoc(roomRef, {
    answer: {
      type: answer.type,
      sdp: answer.sdp,
    },
  });
 
  // Listen for caller ICE candidates
  onSnapshot(callerCandidatesRef, (snapshot) => {
    snapshot.docChanges().forEach((change) => {
      if (change.type === "added") {
        const candidate = new RTCIceCandidate(change.doc.data());
        pc.addIceCandidate(candidate);
      }
    });
  });
 
  return pc;
}

Step 6: Build the Video Call UI

Create the main component that ties everything together.

tsx
// src/components/VideoCall.tsx
import { useState, useRef, useEffect, useCallback } from "react";
import { getLocalStream, getScreenShareStream, stopStream } from "../lib/mediaStream";
import { createRoom, joinRoom } from "../lib/signaling";
 
type CallState = "idle" | "connecting" | "connected" | "ended";
 
export function VideoCall() {
  const [callState, setCallState] = useState<CallState>("idle");
  const [roomId, setRoomId] = useState("");
  const [joinRoomId, setJoinRoomId] = useState("");
  const [isMuted, setIsMuted] = useState(false);
  const [isVideoOff, setIsVideoOff] = useState(false);
  const [isScreenSharing, setIsScreenSharing] = useState(false);
 
  const localVideoRef = useRef<HTMLVideoElement>(null);
  const remoteVideoRef = useRef<HTMLVideoElement>(null);
  const localStreamRef = useRef<MediaStream | null>(null);
  const pcRef = useRef<RTCPeerConnection | null>(null);
 
  const setupLocalVideo = useCallback(async () => {
    const stream = await getLocalStream();
    localStreamRef.current = stream;
    if (localVideoRef.current) {
      localVideoRef.current.srcObject = stream;
    }
    return stream;
  }, []);
 
  const handleRemoteStream = useCallback((stream: MediaStream) => {
    if (remoteVideoRef.current) {
      remoteVideoRef.current.srcObject = stream;
    }
    setCallState("connected");
  }, []);
 
  const handleCreateRoom = async () => {
    setCallState("connecting");
    const stream = await setupLocalVideo();
    const { roomId: newRoomId, peerConnection } = await createRoom(stream, handleRemoteStream);
    pcRef.current = peerConnection;
    setRoomId(newRoomId);
 
    peerConnection.onconnectionstatechange = () => {
      if (peerConnection.connectionState === "disconnected") {
        handleEndCall();
      }
    };
  };
 
  const handleJoinRoom = async () => {
    if (!joinRoomId.trim()) return;
    setCallState("connecting");
    const stream = await setupLocalVideo();
    const peerConnection = await joinRoom(joinRoomId, stream, handleRemoteStream);
    pcRef.current = peerConnection;
    setRoomId(joinRoomId);
 
    peerConnection.onconnectionstatechange = () => {
      if (peerConnection.connectionState === "disconnected") {
        handleEndCall();
      }
    };
  };
 
  const handleEndCall = () => {
    pcRef.current?.close();
    if (localStreamRef.current) stopStream(localStreamRef.current);
    setCallState("ended");
    setRoomId("");
  };
 
  const toggleMute = () => {
    const audioTrack = localStreamRef.current?.getAudioTracks()[0];
    if (audioTrack) {
      audioTrack.enabled = !audioTrack.enabled;
      setIsMuted(!audioTrack.enabled);
    }
  };
 
  const toggleVideo = () => {
    const videoTrack = localStreamRef.current?.getVideoTracks()[0];
    if (videoTrack) {
      videoTrack.enabled = !videoTrack.enabled;
      setIsVideoOff(!videoTrack.enabled);
    }
  };
 
  const toggleScreenShare = async () => {
    if (!pcRef.current) return;
    const sender = pcRef.current.getSenders().find((s) => s.track?.kind === "video");
    if (!sender) return;
 
    if (isScreenSharing) {
      // Switch back to camera
      const cameraTrack = localStreamRef.current?.getVideoTracks()[0];
      if (cameraTrack) await sender.replaceTrack(cameraTrack);
    } else {
      // Switch to screen share
      const screenStream = await getScreenShareStream();
      const screenTrack = screenStream.getVideoTracks()[0];
      await sender.replaceTrack(screenTrack);
 
      // Revert to camera when screen share ends
      screenTrack.onended = async () => {
        const cameraTrack = localStreamRef.current?.getVideoTracks()[0];
        if (cameraTrack) await sender.replaceTrack(cameraTrack);
        setIsScreenSharing(false);
      };
    }
    setIsScreenSharing(!isScreenSharing);
  };
 
  return (
    <div className="video-call-container">
      <div className="video-grid">
        <div className="video-wrapper">
          <video ref={localVideoRef} autoPlay muted playsInline />
          <span className="video-label">You</span>
        </div>
        <div className="video-wrapper">
          <video ref={remoteVideoRef} autoPlay playsInline />
          <span className="video-label">Remote</span>
        </div>
      </div>
 
      {callState === "idle" && (
        <div className="call-actions">
          <button onClick={handleCreateRoom}>Create Room</button>
          <div className="join-section">
            <input
              value={joinRoomId}
              onChange={(e) => setJoinRoomId(e.target.value)}
              placeholder="Enter room code"
            />
            <button onClick={handleJoinRoom}>Join Room</button>
          </div>
        </div>
      )}
 
      {roomId && callState === "connecting" && (
        <div className="room-info">
          <p>Share this room code: <strong>{roomId}</strong></p>
          <p>Waiting for the other person to join...</p>
        </div>
      )}
 
      {callState === "connected" && (
        <div className="call-controls">
          <button onClick={toggleMute}>{isMuted ? "Unmute" : "Mute"}</button>
          <button onClick={toggleVideo}>{isVideoOff ? "Camera On" : "Camera Off"}</button>
          <button onClick={toggleScreenShare}>
            {isScreenSharing ? "Stop Share" : "Share Screen"}
          </button>
          <button onClick={handleEndCall} className="end-call">End Call</button>
        </div>
      )}
    </div>
  );
}

How Screen Sharing Works

Screen sharing uses RTCRtpSender.replaceTrack() to swap the camera video track for the screen capture track. This is seamless because it does not renegotiate the entire peer connection -- it simply replaces the media being sent through the existing connection. When the user stops screen sharing (either via the button or the browser's built-in "Stop sharing" UI), the onended event fires and we revert to the camera track.

Step 7: Add Firestore Security Rules

Secure your signaling data so only call participants can read and write.

// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /rooms/{roomId} {
      allow read, write: if true; // For development only

      // For production, require authentication:
      // allow read, write: if request.auth != null;

      match /callerCandidates/{candidateId} {
        allow read, write: if true;
      }
      match /calleeCandidates/{candidateId} {
        allow read, write: if true;
      }
    }
  }
}

Putting It All Together

The complete video call application has four layers working together:

  1. Media layer -- getUserMedia captures the local camera and microphone streams
  2. Connection layer -- RTCPeerConnection manages the peer-to-peer connection and media exchange
  3. Signaling layer -- Firebase Firestore relays the SDP offer, answer, and ICE candidates between peers
  4. UI layer -- React components display video feeds and provide call controls

The flow is: create a room (writes an offer to Firebase), share the room code, the other person joins (reads the offer, writes an answer), ICE candidates are exchanged through Firebase, and once negotiation completes, video and audio stream directly between browsers without Firebase involvement.

Next Steps

  • Add authentication -- require Firebase Auth to create or join rooms
  • Clean up rooms -- use a Firebase Cloud Function to delete room documents after calls end
  • Multi-party calls -- implement a mesh topology or integrate a Selective Forwarding Unit (SFU) for group calls
  • Recording -- use the MediaRecorder API to record the remote stream locally
  • Connection quality indicators -- use RTCPeerConnection.getStats() to display bandwidth, packet loss, and latency metrics

FAQ

What is WebRTC signaling and why is it needed?

Signaling is the process of exchanging connection metadata (SDP offers, answers, and ICE candidates) between peers before a direct connection is established. WebRTC does not define a signaling protocol, so you need a channel like Firebase to relay this initial handshake data.

Can WebRTC work without a TURN server?

WebRTC can work peer-to-peer using STUN servers for NAT traversal in many cases. However, about 10-15% of connections fail without a TURN relay server, especially behind symmetric NATs or corporate firewalls. For production apps, always configure a TURN server as fallback.

How do you add screen sharing to a WebRTC call?

Use navigator.mediaDevices.getDisplayMedia() to capture the screen, then replace the video track in your existing peer connection using RTCRtpSender.replaceTrack(). This swaps the camera feed for the screen share without renegotiating the entire connection.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

How to Add Observability to a Node.js App with OpenTelemetry

Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
Mar 21, 20266 min read
Next.js
Node.js
BFF

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js

A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.

How I Structure CI/CD for Next.js, Docker, and GitHub Actions
Mar 21, 20265 min read
CI/CD
Next.js
Docker

How I Structure CI/CD for Next.js, Docker, and GitHub Actions

A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.