DEV Community

Cover image for Flutter + WebRTC + Firebase: Build a Real-Time Video App
Codexlancers
Codexlancers

Posted on

Flutter + WebRTC + Firebase: Build a Real-Time Video App

How we built a cross-platform video calling app with camera, mic, screen sharing, live chat, polls, and emoji reactions using WebRTC, Flutter, and Firebase.

Why WebRTC?
WebRTC (Web Real-Time Communication) is an open-source technology that enables browsers and mobile applications to exchange:

  • Audio
  • Video
  • Arbitrary data

directly between users.
The biggest advantage?

Peer-to-Peer Communication
Instead of routing every video frame through your servers, WebRTC allows devices to communicate directly whenever possible.
That means:

  • Lower latency
  • Better privacy
  • Reduced infrastructure costs

But it raises an important question:
How do two devices on different networks actually find each other?
That question is where the real WebRTC journey begins.

Understanding WebRTC: The Phone Call Analogy
WebRTC has two completely separate parts:

1. Signaling
Helps peers discover each other and exchange connection information. WebRTC doesn't provide this. You build it yourself.

2. Media/Data Transport
Once peers know how to connect, WebRTC handles the actual communication.
Think of it like making a phone call.

  • 📖 Phone book → Signaling
  • ☎️ Actual conversation → Peer Connection

The phone book doesn't hear the conversation. It only helps establish it. The Firebase Firestore acts as the phone book.

The WebRTC Handshake
Whenever someone joins a meeting, this sequence occurs:

Breaking Down the Terminology
SDP (Session Description Protocol)

A text description of what each peer supports.
It contains information such as:

  • Supported codecs
  • Video resolutions
  • Audio capabilities

The caller creates an Offer. The receiver responds with an Answer.

ICE Candidates
Possible network routes that peers can use to communicate.

Examples include:

  • Local IP addresses
  • Public IP addresses discovered through STUN

WebRTC automatically selects the best working path.

STUN Servers
STUN servers answer one simple question:
"How does the internet see me?" They help devices discover their public IP addresses. Google provides free STUN servers that are perfect for learning and prototypes.

Building the app

Step 1: Capturing Camera and Microphone
Everything begins with obtaining media access.

_cameraStream = await navigator.mediaDevices.getUserMedia({
  'audio': true,
  'video': {
    'facingMode': 'user',
    'width': {'ideal': 1280},
    'height': {'ideal': 720},
  },
});
Enter fullscreen mode Exit fullscreen mode

Simple enough. Until real users show up.
What if:

  • Camera permission is denied?
  • No webcam exists?
  • Another app is already using the camera?

In production, you need fallbacks.

Step 2: Creating the Peer Connection
The heart of WebRTC is: RTCPeerConnection 
This object represents the live connection.

_peerConnection = await createPeerConnection({
 'iceServers': [
   {'urls': 'stun:stun.l.google.com:19302'},
   {'urls': 'stun:stun1.l.google.com:19302'},
 ],
});
Enter fullscreen mode Exit fullscreen mode

STUN vs TURN
One important production consideration:
STUN

  • Direct peer-to-peer
  • Free
  • Low latency

TURN

  • Relays media through a server
  • Works behind restrictive NATs
  • Costs bandwidth

STUN is enough for demos.
TURN is required for production reliability.

Step 3: Signaling with Firebase Firestore
This is the part tutorials often skip. WebRTC doesn't tell you how to exchange Offers and Answers.
Many implementations use:

  • Node.js
  • Socket.IO

We choose: Firebase Firestore
Because Firestore provides:

  • Realtime listeners
  • Serverless infrastructure
  • Minimal backend maintenanceon.

Step 4: The ICE Candidate Race Condition
Every WebRTC developer eventually encounters this bug. ICE candidates often arrive before the remote description has been set.

If you process them immediately: WebRTC throws errors.
The solution: Buffer them.

if (!_remoteDescriptionSet) {
 _pendingCandidates.add(data);
 return;
}
Enter fullscreen mode Exit fullscreen mode

Once the remote description is available:

_flushPendingCandidates();
Enter fullscreen mode Exit fullscreen mode

Step 5: Data Channels
Most people think WebRTC only handles video. It doesn't.

It also supports:
RTCDataChannel
A peer-to-peer data pipeline.
we used it for:

  • 💬 Chat
  • 😄 Emoji reactions
  • 📊 Live polls
  • 🔇 Mic status
  • 📷 Camera status

Example:

sendMessage(String text);
sendReaction(String emoji);

switch(data['type']) {
 case 'chat':
 case 'reaction':
 case 'poll':
}
Enter fullscreen mode Exit fullscreen mode

Chat messages never touch a server. They're encrypted and delivered directly between peers.

Step 6: Mute, Camera, and Screen Sharing
Real meetings aren't static.
Users:

  • Mute microphones
  • Disable cameras
  • Share screens

Muting is surprisingly simple.

track.enabled = false;
Enter fullscreen mode Exit fullscreen mode

Screen sharing is even cooler. Instead of rebuilding the connection:

replaceTrack(screenTrack);
Enter fullscreen mode Exit fullscreen mode

The remote user seamlessly sees your screen. No renegotiation required.

The Reality of Production WebRTC
This is where the real work begins. A demo can be built quickly. A product is much harder.

Here are some of the problems we had to solve while building a production-ready implementation.

Peer-Left Detection
Users don't always leave gracefully. They close browser tabs, force-stop the app, or lose connectivity.

We relied on Firestore room state to detect this:

roomSubscription = roomRef.snapshots().listen((snapshot) {
  final data = snapshot.data();

  if (data == null) return;

  if (data['calleeJoined'] == false) {
    leaveCall();
    showMessage("The other participant left the meeting.");
  }
});
Enter fullscreen mode Exit fullscreen mode

Without proper cleanup, cameras remain active and rooms become unusable.

Reconnection Logic
Mobile networks are unreliable. Users switch between Wi-Fi and cellular networks all the time.

Instead of assuming a stable connection, we watched the ICE connection state:

_peerConnection?.onIceConnectionState = (state) {
  if (state == RTCIceConnectionState.RTCIceConnectionStateDisconnected) {
    attemptReconnect();
  }

  if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
    restartIce();
  }
};
Enter fullscreen mode Exit fullscreen mode

A failed connection shouldn't automatically end the call. Sometimes it just needs another chance.

Lifecycle Management
Users minimize apps. Browsers close. Operating systems kill background processes.

We used Flutter lifecycle callbacks to release resources safely:

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (state == AppLifecycleState.detached) {
    leaveCall();
  }
}
Enter fullscreen mode Exit fullscreen mode

Ignoring lifecycle events often leads to stuck cameras and orphaned sessions.

Renderer Management
RTCVideoRenderer instances are expensive. Creating and disposing them repeatedly can cause black screens.

Instead, initialize them once:

await localRenderer.initialize();
await remoteRenderer.initialize();

// Reuse throughout the call
localRenderer.srcObject = localStream;
remoteRenderer.srcObject = remoteStream;
Enter fullscreen mode Exit fullscreen mode

Dispose them only after the meeting ends.

Camera Hardware Timing
Stopping camera tracks immediately doesn't always release hardware properly.

We introduced a small delay:

await Future.delayed(const Duration(milliseconds: 300));

await localStream
    ?.getVideoTracks()
    .first
    .stop();
Enter fullscreen mode Exit fullscreen mode

Without this, some devices kept the camera indicator active even after leaving the call.

ICE Candidate Race Conditions
ICE candidates can arrive before the remote description is set. Processing them too early causes exceptions.

Buffer first:

if (!_remoteDescriptionSet) {
  pendingCandidates.add(candidate);
  return;
}

await addIceCandidate(candidate);

Flush them later:
for (final candidate in pendingCandidates) {
  await addIceCandidate(candidate);
}

pendingCandidates.clear();
Enter fullscreen mode Exit fullscreen mode

This tiny piece of logic eliminated one of the most common WebRTC bugs. A WebRTC demo is 20% of the work. The remaining 80% is handling edge cases.
WebRTC User Limits: A Quick Guide
"How many users can WebRTC support?" It depends on your architecture:

  • 1-on-1 Calls (P2P) - Perfect for two users: low latency, minimal infrastructure.
  • Small Groups (Mesh) - Each participant connects to all others. Works for 3–6 users; connections grow rapidly beyond that.
  • Large Meetings (SFU) - A server forwards each user's stream to others. Supports dozens or hundreds of participants with lower CPU and bandwidth usage, but requires server infrastructure.

When to use what:

  • 1–2 users → Pure P2P
  • 3–6 users → Mesh (use with caution)
  • 6+ users → SFU
  • Large webinars → SFU with recording and simulcast

Architecture Overview

Firebase only handles signaling.Media flows directly between peers.
That separation is the essence of WebRTC.

Should You Use WebRTC?
Yes, if you're building:

  • Video conferencing
  • Audio calling
  • Collaborative applications
  • Peer-to-peer file sharing
  • Realtime gaming experiences

Plan ahead for:

  • TURN infrastructure
  • Reconnection strategies
  • Edge cases
  • Group-call scalability

Once you move beyond one-to-one calls, you'll eventually need an SFU.
But for MVPs and real-world one-to-one applications:
Flutter + WebRTC + Firebase is an incredibly powerful combination.

Key Takeaways

  1. WebRTC handles media, not signaling.
  2. Firebase Firestore makes an excellent serverless signaling layer.
  3. Offer → Answer → ICE is the universal handshake.
  4. Data channels are massively underrated.
  5. Edge cases are where production engineering happens.

we'd love to hear your experiences building with WebRTC. Drop your questions in the comments.

Top comments (0)