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
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.
npm create vite@latest webrtc-video -- --template react-ts
cd webrtc-video
npm install firebaseConfigure Firebase in your project:
// 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.
- ›Caller creates an
RTCPeerConnectionand generates an SDP offer - ›Caller writes the offer to Firebase (the signaling channel)
- ›Callee reads the offer from Firebase and sets it as the remote description
- ›Callee generates an SDP answer and writes it to Firebase
- ›Caller reads the answer and sets it as the remote description
- ›Both peers exchange ICE candidates through Firebase as they are discovered
- ›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.
// 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.
// 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.
// 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.
// 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:
- ›Media layer --
getUserMediacaptures the local camera and microphone streams - ›Connection layer --
RTCPeerConnectionmanages the peer-to-peer connection and media exchange - ›Signaling layer -- Firebase Firestore relays the SDP offer, answer, and ICE candidates between peers
- ›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
MediaRecorderAPI 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.
Related Articles
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
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
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.