Skip to main content
RapidDev - Software Development Agency
flutterflow-tutorials

How to Implement a Video Conferencing Tool Using WebRTC in FlutterFlow

Implement peer-to-peer video conferencing using a Custom Widget with the flutter_webrtc package. Firestore acts as the signaling server where caller and callee exchange SDP offers, answers, and ICE candidates through a calls collection. The Custom Widget creates an RTCPeerConnection, captures local media, and renders both local and remote video streams using RTCVideoView. STUN servers handle NAT traversal, with TURN server fallback for restrictive networks.

What you'll learn

  • How the WebRTC signaling flow works with Firestore as the signaling server
  • How to create an RTCPeerConnection and exchange SDP offers and answers
  • How to handle ICE candidate exchange for NAT traversal
  • How to build call UI with local preview, remote video, and mute/camera controls
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner8 min read30-35 minFlutterFlow Pro+ (Custom Code required)March 2026RapidDev Engineering Team
TL;DR

Implement peer-to-peer video conferencing using a Custom Widget with the flutter_webrtc package. Firestore acts as the signaling server where caller and callee exchange SDP offers, answers, and ICE candidates through a calls collection. The Custom Widget creates an RTCPeerConnection, captures local media, and renders both local and remote video streams using RTCVideoView. STUN servers handle NAT traversal, with TURN server fallback for restrictive networks.

Building WebRTC Video Calls in FlutterFlow

WebRTC enables real-time peer-to-peer video and audio communication directly between browsers and mobile devices. Unlike managed SDKs like Agora, WebRTC gives you full control over the connection with no per-minute vendor costs. This tutorial implements the complete signaling flow using Firestore, the flutter_webrtc package for media handling, and STUN/TURN servers for reliable connectivity.

Prerequisites

  • A FlutterFlow project on the Pro plan or higher
  • Firebase project with Firestore enabled
  • A TURN server for production use (Twilio TURN or self-hosted coturn)
  • Camera and microphone permissions configured in your FlutterFlow project settings

Step-by-step guide

1

Set up the Firestore signaling schema for call sessions

Create a `calls` collection with fields: callerId (String), calleeId (String), offer (Map with type and sdp fields), answer (Map with type and sdp fields), status (String: ringing/active/ended), and createdAt (Timestamp). Add two subcollections under each call document: `callerCandidates` and `calleeCandidates`, each containing documents with candidate (String), sdpMid (String), and sdpMLineIndex (int) fields. This structure lets both peers exchange ICE candidates independently through their own subcollection.

Expected result: The Firestore schema supports the full WebRTC signaling flow with separate spaces for offer, answer, and ICE candidates from each peer.

2

Create the WebRTC Custom Widget with RTCPeerConnection

Add flutter_webrtc to Pubspec Dependencies. Create a Custom Widget named VideoCallWidget with parameters: callId (String), isCaller (bool), and userId (String). In initState, create an RTCPeerConnection using the createPeerConnection method with a configuration map that includes STUN servers (stun:stun.l.google.com:19302) and your TURN server credentials. Capture the local media stream using navigator.mediaDevices.getUserMedia with audio and video constraints. Add the local stream to the peer connection with addStream. Set up the onTrack callback to capture the remote stream and assign it to the remote RTCVideoRenderer.

video_call_widget.dart
1// RTCPeerConnection setup
2final config = {
3 'iceServers': [
4 {'urls': 'stun:stun.l.google.com:19302'},
5 {
6 'urls': 'turn:your-turn-server.com:3478',
7 'username': 'user',
8 'credential': 'pass',
9 },
10 ],
11};
12
13_pc = await createPeerConnection(config);
14_localStream = await navigator.mediaDevices
15 .getUserMedia({'audio': true, 'video': true});
16_localStream!.getTracks().forEach((track) {
17 _pc!.addTrack(track, _localStream!);
18});
19
20_pc!.onTrack = (event) {
21 if (event.streams.isNotEmpty) {
22 _remoteRenderer.srcObject = event.streams[0];
23 setState(() {});
24 }
25};

Expected result: The peer connection is initialized with STUN/TURN servers and the local camera/microphone stream is captured and added to the connection.

3

Implement the caller flow: create offer and listen for answer

When isCaller is true, the widget creates an SDP offer using _pc.createOffer(), sets it as the local description with _pc.setLocalDescription(offer), and writes the offer to the Firestore call document's offer field as a map with type and sdp. Set up an onIceCandidate callback that writes each ICE candidate to the `callerCandidates` subcollection. Then listen to the call document for changes. When the answer field is populated (the callee has responded), read the answer, create an RTCSessionDescription from it, and set it as the remote description with _pc.setRemoteDescription(answer). Also listen to the `calleeCandidates` subcollection and add each candidate to the peer connection with _pc.addCandidate().

Expected result: The caller creates and sends an offer, then listens for the callee's answer and ICE candidates. Once received, the peer connection establishes.

4

Implement the callee flow: receive offer and send answer

When isCaller is false, the widget reads the call document and retrieves the offer. Set the offer as the remote description with _pc.setRemoteDescription(offer). Create an SDP answer with _pc.createAnswer(), set it as the local description, and write it to the call document's answer field. Set up onIceCandidate to write candidates to the `calleeCandidates` subcollection. Listen to the `callerCandidates` subcollection and add each incoming candidate to the peer connection. Update the call document status to 'active' once the answer is sent.

Expected result: The callee receives the offer, sends an answer, and both peers exchange ICE candidates until the connection is established with two-way video and audio.

5

Build the call UI with local/remote video and controls

In the build method, return a Stack. The bottom layer is the remote video rendered in an RTCVideoView filling the full widget area. The top-right corner shows the local video preview in a small Positioned Container (120x160) with rounded corners using ClipRRect. At the bottom center, add a Row of control IconButtons: microphone toggle (mutes/unmutes the local audio track), camera toggle (enables/disables the local video track), camera flip (switches front/back camera using Helper.switchCamera), and a red end-call CircleAvatar button. The end call action stops all local tracks, closes the peer connection, updates the Firestore call status to 'ended', and navigates back.

Expected result: The call screen shows the remote participant full-screen with the local preview as a small overlay. Mute, camera, flip, and end-call buttons work correctly.

Complete working example

VideoCallWidget Custom Widget
1// Custom Widget: VideoCallWidget
2// Pubspec: flutter_webrtc: ^0.9.47
3import 'package:flutter/material.dart';
4import 'package:flutter_webrtc/flutter_webrtc.dart';
5import 'package:cloud_firestore/cloud_firestore.dart';
6
7class VideoCallWidget extends StatefulWidget {
8 final double width, height;
9 final String callId;
10 final bool isCaller;
11 const VideoCallWidget({Key? key, required this.width, required this.height,
12 required this.callId, required this.isCaller}) : super(key: key);
13 @override
14 State<VideoCallWidget> createState() => _VCState();
15}
16
17class _VCState extends State<VideoCallWidget> {
18 RTCPeerConnection? _pc;
19 MediaStream? _localStream;
20 final _localR = RTCVideoRenderer();
21 final _remoteR = RTCVideoRenderer();
22 bool _muted = false, _camOff = false;
23
24 @override
25 void initState() { super.initState(); _init(); }
26
27 Future<void> _init() async {
28 await _localR.initialize();
29 await _remoteR.initialize();
30 _pc = await createPeerConnection({
31 'iceServers': [{'urls': 'stun:stun.l.google.com:19302'}],
32 });
33 _localStream = await navigator.mediaDevices.getUserMedia({'audio': true, 'video': true});
34 _localR.srcObject = _localStream;
35 _localStream!.getTracks().forEach((t) => _pc!.addTrack(t, _localStream!));
36 _pc!.onTrack = (e) { if (e.streams.isNotEmpty) { _remoteR.srcObject = e.streams[0]; setState(() {}); } };
37 final ref = FirebaseFirestore.instance.collection('calls').doc(widget.callId);
38 _pc!.onIceCandidate = (c) => ref.collection(widget.isCaller ? 'callerCandidates' : 'calleeCandidates').add(c.toMap());
39 if (widget.isCaller) {
40 final offer = await _pc!.createOffer();
41 await _pc!.setLocalDescription(offer);
42 await ref.set({'offer': offer.toMap(), 'status': 'ringing'});
43 ref.snapshots().listen((s) async {
44 if (s.data()?['answer'] != null && _pc != null)
45 await _pc!.setRemoteDescription(RTCSessionDescription(
46 s.data()!['answer']['sdp'], s.data()!['answer']['type']));
47 });
48 _listenICE(ref, 'calleeCandidates');
49 } else {
50 final s = await ref.get();
51 await _pc!.setRemoteDescription(
52 RTCSessionDescription(s['offer']['sdp'], s['offer']['type']));
53 final answer = await _pc!.createAnswer();
54 await _pc!.setLocalDescription(answer);
55 await ref.update({'answer': answer.toMap(), 'status': 'active'});
56 _listenICE(ref, 'callerCandidates');
57 }
58 setState(() {});
59 }
60
61 void _listenICE(DocumentReference r, String sub) {
62 r.collection(sub).snapshots().listen((s) { for (final c in s.docChanges) {
63 final d = c.doc.data()!;
64 _pc?.addCandidate(RTCIceCandidate(d['candidate'], d['sdpMid'], d['sdpMLineIndex']));
65 }});
66 }
67
68 @override
69 void dispose() { _localStream?.dispose(); _pc?.close();
70 _localR.dispose(); _remoteR.dispose(); super.dispose(); }
71
72 @override
73 Widget build(BuildContext context) {
74 return SizedBox(width: widget.width, height: widget.height,
75 child: Stack(children: [
76 RTCVideoView(_remoteR, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover),
77 Positioned(top: 16, right: 16, child: ClipRRect(
78 borderRadius: BorderRadius.circular(12),
79 child: SizedBox(width: 120, height: 160,
80 child: RTCVideoView(_localR, mirror: true)))),
81 Positioned(bottom: 32, left: 0, right: 0, child: Row(
82 mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
83 IconButton(icon: Icon(_muted ? Icons.mic_off : Icons.mic),
84 onPressed: () { setState(() => _muted = !_muted);
85 _localStream?.getAudioTracks().first.enabled = !_muted; }),
86 IconButton(icon: Icon(_camOff ? Icons.videocam_off : Icons.videocam),
87 onPressed: () { setState(() => _camOff = !_camOff);
88 _localStream?.getVideoTracks().first.enabled = !_camOff; }),
89 FloatingActionButton(backgroundColor: Colors.red,
90 child: const Icon(Icons.call_end), onPressed: () async {
91 _localStream?.getTracks().forEach((t) => t.stop());
92 await _pc?.close();
93 await FirebaseFirestore.instance.collection('calls')
94 .doc(widget.callId).update({'status': 'ended'});
95 }),
96 ])),
97 ]));
98 }
99}

Common mistakes when implementing a Video Conferencing Tool Using WebRTC in FlutterFlow

Why it's a problem: Not configuring TURN servers for production deployments

How to avoid: Add a TURN server configuration alongside your STUN server in the iceServers array. Use a managed TURN service like Twilio Network Traversal or self-host coturn.

Why it's a problem: Setting the remote description after creating the answer on the callee side

How to avoid: Always call setRemoteDescription with the offer first, then call createAnswer. The correct order is: receive offer, set remote description, create answer, set local description.

Why it's a problem: Not disposing the RTCVideoRenderer and peer connection when the widget is removed

How to avoid: Override dispose() to stop all local stream tracks, close the peer connection, and dispose both RTCVideoRenderers before calling super.dispose().

Best practices

  • Use a small picture-in-picture overlay for the local video preview so the remote participant occupies the full screen
  • Set mirror: true on the local RTCVideoView so users see a natural mirror image of themselves
  • Disable the microphone and camera buttons during the initial connection setup to prevent state issues
  • Listen for the peer connection's onConnectionState to detect disconnections and show a reconnecting indicator
  • Delete the Firestore call document and its subcollections after the call ends to avoid accumulating stale data
  • Add a ringing UI for the callee with Accept and Decline buttons before joining the peer connection

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I want to build a WebRTC video call in FlutterFlow using the flutter_webrtc package and Firestore for signaling. Walk me through the full flow: creating the peer connection, exchanging SDP offer/answer via Firestore, handling ICE candidates, and rendering local and remote video. Include the complete Dart Custom Widget code.

FlutterFlow Prompt

Create a video call Custom Widget with WebRTC. Use Firestore as the signaling server. Show the remote video full-screen with a small local preview in the corner. Add mute, camera toggle, and end call buttons at the bottom.

Frequently asked questions

What is the difference between WebRTC and using Agora for video calls?

WebRTC is a free, open protocol you implement yourself, giving full control but requiring you to handle signaling, TURN servers, and connection management. Agora is a managed SDK that handles all of this for you at a per-minute cost. WebRTC is better for cost-sensitive projects; Agora is better for faster development.

Can I add more than two participants to a WebRTC call?

Yes, but it requires a mesh or SFU architecture. In mesh, each participant connects to every other participant, which works for 3-4 people. For larger groups, you need an SFU (Selective Forwarding Unit) server like mediasoup or Janus.

Why do I need both STUN and TURN servers?

STUN helps peers discover their public IP addresses for direct connections. TURN relays traffic when direct connections are impossible due to restrictive firewalls or symmetric NATs. STUN handles about 85% of cases; TURN covers the remaining 15%.

How do I handle the callee not answering the call?

Set a timeout in the caller widget. If the call document status has not changed to 'active' within 30 seconds, update status to 'ended', close the peer connection, and show a 'No Answer' message. The callee can check for expired calls by comparing createdAt to the current time.

Can I record WebRTC calls?

Yes. Use the MediaRecorder API on the remote stream to record locally, or set up a server-side recording service that joins the call as a silent participant and records both streams.

Can RapidDev help implement a production-grade video calling system?

Yes. RapidDev can build multi-party video conferencing with screen sharing, recording, waiting rooms, breakout rooms, and TURN server infrastructure for reliable global connectivity.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.