Build an Omegle clone using WebRTC and websockets

A guide on how to build an Omegle clone using modern tech like WebRTC and websockets. I built the entire thing using Sveltekit so this guide will be in that.

Remember Omegle? Yes that video calling website that got shutdown in 2023. Don’t tell me you did not used it even once.

In India it kinda blew up in popularity due to some youtubers making videos about it. Have some good memories talking to strangers with my friends.

Now that I can call myself a full stack software engineer specializing in web dev when innocent people ask me what do I do. I can build it, not the memories but the damn website.

And I did, here’s how. (Live website and github links at the end)

First, Research

Don’t be that guy who just jumps on coding the thing before knowing what it takes to build it. So I did the same, searched on youtube to copy other people’s work. It’s not like this is an unique idea or something. There are plenty of clones of the original.

I found a well documented youtube video showing a similar thing here. This is in hindi, the same guy has an explainer video of how all this works. It was a saviour.

Now how it works

Working with WebRTC can be a bit tricky sometimes even though the API is quite good and intuitive (if you have the basics of signaling and p-2-p connection done).

So what we need is a signaling server. A server which works as a broker to connect the peers. And after the connection is done, you do not need the server anymore.

Cool right?

Here’s a diagram to help you understand.

WebRTC diagram

Let’s create the signaling server

For the signaling server I’m using SocketIO, which is a very efficient way to not fight with the socket connections and get some work done.

Initially I built the first version of the server in Deno. It was fun and all but it lacked hosting options. So I switched to vanilla NodeJS.

Here’s the server setup.

// Import required modules
const { createServer } = require("http");
const { Server } = require("socket.io"); 
const { instrument } = require("@socket.io/admin-ui");
const admin = require("firebase-admin");

// Load environment variables
const dotenv = require("dotenv");
dotenv.config();

// Initialize Firebase Admin with credentials from environment
const firebaseAdminCred = JSON.parse(process.env.SERVICE_ACCOUNT_CREDENTIAL);

admin.initializeApp({
  credential: admin.credential.cert(firebaseAdminCred),
});

// Create basic HTTP server with health check endpoint
const httpServer = createServer((req, res) => {
  // Health check endpoint for monitoring
  if (req.method === "GET" && req.url === "/health") {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("ok");
    return;
  }

  // Simple homepage showing server status, used for deployment debugging
  if (req.method === "GET" && req.url === "/") {
    res.writeHead(200, { "Content-Type": "text/html" });
    res.end(`
      <html>
        <head>
          <title>WebRTC Server</title>
        </head>
        <body>
          <h1>WebRTC Server</h1>
          <p>Server is running successfully.</p>
          <p>Status: <a href="/health">Check health</a></p>
        </body>
      </html>
    `);
    return;
  }
});

// Initialize Socket.IO with CORS settings
const io = new Server(httpServer, {
  cors: {
    // Allow connections only from these origins
    origin: [
      "https://kawaiikiwi.online",
      "http://kawaiikiwi.online",
      "http://www.kawaiikiwi.online",
      "https://www.kawaiikiwi.online",
      "https://admin.socket.io",
    ],
    credentials: true,
    methods: ["GET", "POST"],
  },
});

// Store connected users and their pairings
// This is a not so efficient way of keeping track of peered users.
// Ideally we can use something like Redis to store these values momentarily.
const users = {};

// Middleware to verify Firebase authentication token
io.use(async (socket, next) => {
  const token = socket.handshake.auth.token;
  if (!token) {
    console.log("Token not found");
    return next(new Error("Authentication token missing"));
  }

  try {
    const decodedToken = await admin.auth().verifyIdToken(token);
    socket.user = decodedToken;
    console.log("User authenticated");
    next();
  } catch (error) {
    console.error("Authentication error:", error);
    return next(new Error("Authentication failed"));
  }
});

// Handle socket connections
io.on("connection", (socket) => {
  const callerID = socket.id;

  // When a user wants to find a peer
  socket.on("peer:find", () => {
    // Add user to the pool of available users
    users[callerID] = null;

    // Find other available users (not paired with anyone)
    const availableUsers = Object.keys(users).filter(
      (userID) => userID !== socket.id && users[userID] === null
    );

    // Randomly select an available user
    const randomUser =
      availableUsers[Math.floor(Math.random() * availableUsers.length)];

    if (randomUser) {
      // Create pairing between users
      users[callerID] = randomUser;
      users[randomUser] = callerID;

      // Notify both users about the pairing
      io.to(randomUser).emit("peer:accepted");
      io.to(callerID).emit("peer:found");
      console.log(`paired ${callerID} with ${randomUser}`);
    } else {
      // No available users found
      console.log(`no available users for ${callerID}`);
      io.to(callerID).emit("peer:alone");
    }
  });

  // Handle WebRTC signaling - when a peer sends an offer
  socket.on("peer:offer", (offer) => {
    console.log("peer:offer", socket.id);
    const sendTo = users[socket.id];
    if (sendTo) {
      io.to(sendTo).emit("peer:call", offer);
    }
  });

  // Handle WebRTC signaling - when a peer sends an answer
  socket.on("peer:answer", (answer) => {
    console.log("peer:answer", socket.id);
    const sendTo = users[socket.id];

    if (sendTo) {
      io.to(sendTo).emit("peer:answer", answer);
    }
  });

  // Handle WebRTC ICE candidates for connection establishment
  socket.on("peer:sent:icecandidate", (candidate) => {
    console.log("peer:sent:icecandidate", socket.id);
    const sendTo = users[socket.id];
    if (sendTo) {
      io.to(sendTo).emit("peer:sent:icecandidate", candidate);
    }
  });

  // Handle user disconnection
  socket.on("disconnect", () => {
    // Notify partner if user was paired
    const partnerId = users[callerID];
    if (partnerId && users[partnerId] === callerID) {
      io.to(partnerId).emit("peer:disconnected");
      users[partnerId] = null;
    }

    // Remove user from pool
    delete users[callerID];
  });

  // Handle connection errors
  socket.on("connect_error", (error) => {
    console.error("Connection error:", error);
    socket.emit("error", "Failed to connect");
  });
});

// Setup Socket.IO Admin UI for monitoring
instrument(io, {
  auth: {
    type: process.env.SOCKET_ADMIN_TYPE,
    username: process.env.SOCKET_ADMIN_USERNAME,
    password: process.env.SOCKET_ADMIN_PASSWORD,
  },
  mode: "production",
});

// Start the server
httpServer.listen(8080, "0.0.0.0", () => {
  console.log("Server is running on 0.0.0.0:8080");
});

Here you might have noticed I am using firebase admin SDK to verify the incoming requests.

This can be very important as with WebRTC the IP addresses of peers is visible on connection intends and connection answers. If anyone gets their way into the socket server and was able to capture these delicate info, it will be a huge security blunder.

Anyways, let’s move on.

Configure the Sveltekit app

Now I have used Sveltekit because it’s just so simple. But the code should work on any web framework as it’s just JavaScript.

- First: access to user camera and mic

I used the vanilla navigator.mediaDevices API to get the local stream

localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    if (localVideo) {
        localVideo.srcObject = localStream;
    }
socketStore.findPeer();

In the video chat page, I used a button to initialize the entire process. See.

// Function that handles starting a video chat session
const handleVideoChatStart = async () => {
    try {
        // Get the user's authentication token
        // This is required for security verification with our server
        userIdToken = await userStore.getUser()?.getIdToken() ?? ""
        if (userIdToken === "") {
            throw new Error("User not found");
        }

        // Check if we have an active socket connection
        // If not, establish one before proceeding
        if (!socket || socket.connected === false || socket === null) {
            await socketStore.connect() // Will show how socketStore configured later
        }

        try {
            // Request access to user's camera and microphone
            // This will trigger the browser's permission prompt
            localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
            
            // Once we have the media stream, display it in the local video element
            if (localVideo) {
                localVideo.srcObject = localStream;
            }

            // Start looking for a peer to connect with
            socketStore.findPeer();
        } catch (error) {
            // Handle any errors related to accessing media devices
            console.error('Error getting user media:', error);
            alert('Error getting user media, please try again');
        }
    } catch (error) {
        // If there's an authentication error, redirect to login
        alert("Error starting chat, make sure you sign in")
        goto('/login');
    }
};
Second: socket store setup

I defined a class SocketStore() and exported that to be used elsewhere. You can use different ways, the functions can stay the same.

// Import required Socket.IO client library and types
import { io, Socket } from 'socket.io-client';
// Import server URL from environment variables
import { PUBLIC_SOCKET_SERVER_URL } from '$env/static/public';
// Import user store for authentication
import { userStore } from './user.svelte';

/**
 * SocketStore class handles all WebSocket communication for WebRTC signaling
 * This acts as a central store for managing the socket connection and related methods
 */
class SocketStore {
	// Socket instance stored as reactive state using Svelte's $state
	private socket = $state<Socket | null>(null);

	// Get the current socket instance
	getSocket() {
		return this.socket;
	}

	// Establish a new socket connection with authentication
	async connect() {
		try {
			// Clean up any existing connection first
			this.disconnect();
			
			// Validate server URL exists in environment
			if (!PUBLIC_SOCKET_SERVER_URL || PUBLIC_SOCKET_SERVER_URL === null) {
				throw Error('Socket server url not found in env');
			}

			// Create new socket connection with auth token
			this.socket = io(PUBLIC_SOCKET_SERVER_URL, {
				auth: {
					token: await userStore.getIdToken()
				}
			});
			return this.socket;
		} catch (e) {
			console.log(e);
			throw new Error('Socket connection error');
		}
	}

	// Safely disconnect the socket if it exists
	disconnect() {
		if (this.socket) {
			this.socket.disconnect();
		}
	}

	// Emit event to find a peer for video chat
	findPeer() {
		console.log('finding peer');
		if (this.socket) {
			this.socket.emit('peer:find');
		}
	}

	// Send generic message with WebRTC session data
	sendMessage(message: string, data: RTCSessionDescriptionInit | RTCIceCandidateInit) {
		if (this.socket) {
			this.socket.emit('message', message, data);
		}
	}

	// Send WebRTC connection offer to peer
	sendConnectionOffer(offer: RTCSessionDescriptionInit) {
		if (this.socket) {
			this.socket.emit('peer:offer', offer);
		}
	}

	// Send WebRTC connection answer to peer
	sendConnectionAnswer(answer: RTCSessionDescriptionInit) {
		if (this.socket) {
			this.socket.emit('peer:answer', answer);
		}
	}

	// Send ICE candidate information to peer
	// ICE candidates are used to establish the optimal connection path
	sendIceCandidate(candidate: RTCIceCandidateInit) {
		if (this.socket) {
			this.socket.emit('peer:sent:icecandidate', candidate);
		}
	}
}

// Export a single instance to be used throughout the app
export const socketStore = new SocketStore();

Some of these functions can look complicated, but will be used in the next sections.

Setup WebRTC config

Just like we have now setup the socket config, let’s setup the config for the WebRTC connections and ICE servers.

// Class to handle WebRTC configuration and peer connections
class RTCConfig {
	// Define ICE (Interactive Connectivity Establishment) servers
	// STUN servers help peers discover their public IP addresses
	private ICE_SERVERS = [
		{
			urls: 'stun:stun.l.google.com:19302' // Google's public STUN server
		}
	];

	// Initialize RTCPeerConnection with ICE servers config
	// Using Svelte 5's $state for reactivity
	private peerConnection: RTCPeerConnection = $state(
		new RTCPeerConnection({
			iceServers: this.ICE_SERVERS,
			iceCandidatePoolSize: 10 // Pre-gather ICE candidates
		})
	);

	// Create a fresh peer connection with the same config
	private createNewPeerConnection(): RTCPeerConnection {
		this.peerConnection = new RTCPeerConnection({
			iceServers: this.ICE_SERVERS,
			iceCandidatePoolSize: 10
		});
		return this.peerConnection;
	}

	// Get the current peer connection instance
	getPeerConnection(): RTCPeerConnection {
		return this.peerConnection;
	}

	// Set up a new peer connection with all necessary event handlers
	setupPeerConnection(
		localStream: MediaStream, // User's local audio/video stream
		onTrackCallback: (event: RTCTrackEvent) => Promise<void>, // Handler for receiving remote tracks
		onIceCandidateCallback: (event: RTCPeerConnectionIceEvent) => void, // Handler for ICE candidates
		onDisconnectCallback: () => void // Handler for connection termination
	): RTCPeerConnection {
		// Start fresh with a new connection
		this.createNewPeerConnection();

		// Add all local audio/video tracks to the connection
		localStream.getTracks().forEach((track) => {
			this.peerConnection.addTrack(track, localStream);
		});

		// Set up event handlers for incoming media and ICE candidates
		this.peerConnection.ontrack = onTrackCallback;
		this.peerConnection.onicecandidate = onIceCandidateCallback;

		// Monitor connection state changes
		this.peerConnection.oniceconnectionstatechange = () => {
			console.log('ICE connection state:', this.peerConnection.iceConnectionState);
			// Handle disconnection scenarios
			if (
				this.peerConnection.iceConnectionState === 'disconnected' ||
				this.peerConnection.iceConnectionState === 'failed' ||
				this.peerConnection.iceConnectionState === 'closed'
			) {
				onDisconnectCallback();
			}
		};

		return this.peerConnection;
	}

	// Handle incoming connection offers
	async answerOffer(offer: RTCSessionDescriptionInit) {
		await this.peerConnection.setRemoteDescription(offer);
	}

	// Create and set a connection offer to send to peer
	async createOffer() {
		const offer = await this.peerConnection.createOffer();
		await this.peerConnection.setLocalDescription(offer);
		return offer;
	}

	// Create and set a connection answer in response to an offer
	async createAnswer() {
		const answer = await this.peerConnection.createAnswer();
		await this.peerConnection.setLocalDescription(answer);
		return answer;
	}
}

// Export a singleton instance for app-wide use
export const rtcConfig = new RTCConfig();

This provides some helper functions which handles things like making an connection offer (a.k.a call), answering the offer and setting up the ICE servers.

But wait didn’t I told you that this will not need any server other than the socket one? Then what is the ICE server?

ICE servers (Interactive Connectivity Establishment servers) are the server that are responsible for finding the best connection path between peers. Let’s say your internet router is protected buy a firewall or a proxy. In that case these are the server will ensures a fast connection path is found during setting the connection.
You can think of them as a navigator for finding the highway of data packets to travel through.

We are using google’s ICE servers.

Setup the socket listeners

If you see the server files, you will see there are multiple SocketIo events been emitted by the server like this.

  // Handle WebRTC signaling - when a peer sends an offer
  socket.on("peer:offer", (offer) => {
    console.log("peer:offer", socket.id);
    // ... some operations
  });

  // Handle WebRTC signaling - when a peer sends an answer
  socket.on("peer:answer", (answer) => {
    console.log("peer:answer", socket.id);
    // ... some operations
  });

    // Handle WebRTC ICE candidates for connection establishment
  socket.on("peer:sent:icecandidate", (candidate) => {
    console.log("peer:sent:icecandidate", socket.id);
    // ... some operations
  });

So what we need to do now is to consume those events using even listeners in the front end. Let’s setup that.

Here I am using new Svelte 5 runes syntax. Feel free to use useEffect hook if you are using react. The idea is to initialize these listeners on the page. So that we can handle the incoming events.

// Main function to set up all socket event listeners
const setupSocketListeners = async () => {
    // Get socket instance from our store
    const socket = socketStore.getSocket();
    if (!socket) return;

    // When the peer accepts our connection request
    socket.on('peer:accepted', () => {
        // Clear any existing timeout for finding peers
        if (findingPeerTimeout) {
            clearTimeout(findingPeerTimeout);
        }

        // Verify we have access to user's media stream
        if (!localStream) {
            alert('Error: No local stream found');
            return;
        }

        // Initialize WebRTC peer connection with our local stream
        // and callback handlers for various events
        peerConnection = rtcConfig.setupPeerConnection(
            localStream,
            handleTrack,
            handleIceCandidate,
            handleDisconnect
        );
    });

    // When a peer is found and ready to connect
    socket.on('peer:found', async () => {
        // Clear any existing timeout for finding peers
        if (findingPeerTimeout) {
            clearTimeout(findingPeerTimeout);
        }

        // Verify we have access to user's media stream
        if (!localStream) {
            alert('Error: No local stream found');
            return;
        }

        // Initialize WebRTC peer connection
        peerConnection = rtcConfig.setupPeerConnection(
            localStream,
            handleTrack,
            handleIceCandidate,
            handleDisconnect
        );

        // Create and send connection offer to the peer
        const offer = await rtcConfig.createOffer();
        socketStore.sendConnectionOffer(offer);
    });

    // When receiving ICE candidates from the peer
    // ICE candidates contain information about how to connect to a peer
    socket.on('peer:sent:icecandidate', (candidate) => {
        if (!rtcConfig.getPeerConnection()) {
            throw new Error('Error: No peer connection found');
        }
        if (candidate.candidate) {
            const iceCandidate = new RTCIceCandidate(candidate);
            // If we already have remote description, add candidate immediately
            if (rtcConfig.getPeerConnection().remoteDescription) {
                console.log('adding ice candidate', candidate);
                rtcConfig.getPeerConnection().addIceCandidate(iceCandidate);
            } else {
                // Otherwise queue the candidate for later
                console.log('queuing ice candidate', candidate);
                pendingIceCandidates.push(iceCandidate);
            }
        }
    });

    // When receiving a call (connection offer) from a peer
    socket.on('peer:call', async (offer) => {
        // Process the received offer and create an answer
        await rtcConfig.answerOffer(offer);
        const answer = await rtcConfig.createAnswer();
        socketStore.sendConnectionAnswer(answer);
    });

    // When receiving an answer to our connection offer
    socket.on('peer:answer', async (answer) => {
        if (!rtcConfig.getPeerConnection()) {
            throw new Error('Error: No peer connection found');
        }

        try {
            // Set the remote description from the answer
            await rtcConfig.getPeerConnection().setRemoteDescription(new RTCSessionDescription(answer));

            // Now that we have remote description, add any pending ICE candidates
            for (const candidate of pendingIceCandidates) {
                await rtcConfig.getPeerConnection().addIceCandidate(candidate);
            }
            pendingIceCandidates = [];
        } catch (error) {
            console.error('Error setting remote description:', error);
        }
    });

    // When no peer is found (we're alone)
    socket.on('peer:alone', () => {
        // Clear any existing timeout
        if (findingPeerTimeout) {
            clearTimeout(findingPeerTimeout);
        }

        // Try finding a peer again after 7 seconds
        findingPeerTimeout = setTimeout(() => {
            socketStore.findPeer();
        }, 7000);
    });
};

// Use Svelte's effect to set up listeners when component mounts
$effect(() => {
    setupSocketListeners();
});
Setup other important functions

Here are some other important functions what are used in the chat page.

  • WebRTC track handler. This handles the incoming media track from the other peer.
// Handler function for when we receive media tracks from the remote peer
const handleTrack = async (event: RTCTrackEvent) => {
    // Log when we receive a track
    console.log('Track received', event);

    // Check if we have a valid stream from the event
    if (event && event.streams[0]) {
        console.log('Stream received', event.streams[0]);
        
        // Store the remote stream for later use
        remoteStream = event.streams[0];

        // If we have a video element to display the remote stream
        if (remoteVideo) {
            console.log('Setting remote video source', remoteVideo);
            // Set the stream as the source for our video element
            remoteVideo.srcObject = remoteStream;

            try {
                // Create a promise that resolves when the video metadata is loaded
                // This ensures the video is ready to play
                await new Promise((resolve) => {
                    remoteVideo!.addEventListener('loadedmetadata', resolve, { once: true });
                });

                // Try to play the video and catch any autoplay errors
                remoteVideo.play().catch((error) => {
                    console.error('Error playing remote video', error);
                });
            } catch (error) {
                console.error('Error playing remote video', error);

                // If playing fails, try again after a 1 second delay
                // This helps handle cases where the browser blocks autoplay
                setTimeout(async () => {
                    try {
                        await remoteVideo?.play();
                    } catch (retryError) {
                        console.error('Retry failed: ', retryError);
                    }
                }, 1000);
            }
        }
    }
};
  • This one handles the ICE candidates suggested by the STUN server
// Handler function for ICE (Interactive Connectivity Establishment) candidates
// This is called whenever a new ICE candidate is found by the local peer
const handleIceCandidate = (event: RTCPeerConnectionIceEvent) => {
    // Check if we have a valid candidate
    if (event.candidate) {
        // Log the candidate for debugging
        console.log('sending ice candidate', event.candidate);
        
        // Send the ICE candidate to the remote peer through our signaling server
        // The remote peer will use this to try establishing a direct connection
        socketStore.sendIceCandidate(event.candidate);
    }
};
  • Disconnect to peer and look for another.
// Handler function for when a peer disconnects or we want to disconnect from current peer
const handleDisconnect = () => {
    // Log disconnection event
    console.log('Peer disconnected, looking for new peer...');

    // Clear the remote media stream
    remoteStream = null;

    // Remove remote video source if video element exists
    if (remoteVideo) {
        remoteVideo.srcObject = null;
    }

    // Close existing peer connection if it exists
    if (peerConnection) {
        peerConnection.close(); // Close all media streams and data channels
        peerConnection = null; // Reset connection object
    }

    // Start process of finding a new peer through socket connection
    socketStore.findPeer();
};
  • Change the peer.
// Handler function for when user wants to switch to a different peer
const handleChangePeer = () => {
    // Log the peer change event
    console.log('Changing peer...');

    // Clear the remote media stream
    remoteStream = null;

    // Remove remote video source if video element exists
    if (remoteVideo) {
        remoteVideo.srcObject = null;
    }

    // Close existing peer connection if it exists
    if (peerConnection) {
        peerConnection.close(); // Close all media streams and data channels
        peerConnection = null; // Reset connection object
    }

    // Clear any existing timeout for finding peers
    if (findingPeerTimeout) {
        clearTimeout(findingPeerTimeout);
    }

    // Start process of finding a new peer through socket connection
    socketStore.findPeer();
};
  • Stop the video chat.
// Handler function to stop the video chat session and cleanup resources
const handleVideoChatStop = () => {
    // Clear any existing timeout for finding peers
    if (findingPeerTimeout) {
        clearTimeout(findingPeerTimeout);
    }

    // Stop all tracks in local media stream and cleanup
    if (localStream) {
        // Stop each audio/video track
        localStream.getTracks().forEach((track) => track.stop());
        localStream = null;
        // Remove stream from local video element
        if (localVideo) {
            localVideo.srcObject = null;
        }
    }

    // Close and cleanup WebRTC peer connection
    if (peerConnection) {
        peerConnection.close();
        peerConnection = null;
    }
};

That’s it. Checkout the github page if I missed something.

Now all that is left is UI

Now you can finally focus on the UI. I will not show that in this blog, but if you want you can check my github to see how everything is working.

Live URL and github