In this tutorial, we will build a simple peer-to-peer chat app using WebRTC, Socket.IO, Node.js, and Express.
The goal is to connect two browser clients directly and allow them to send messages to each other without sending every message through the server.
The server will still be important, but only at the beginning. It will help the two clients find each other and exchange connection information. This step is called signaling.
After the WebRTC connection is ready, the messages will travel directly between the two browsers using a WebRTC data channel.
By the end of this tutorial, you will have a small but beautiful chat app that looks like this kind of modern mini-project:
- A clean landing section
- A room input
- A connection status badge
- A styled chat window
- Message bubbles
- Timestamps
- A disabled message box until the peer-to-peer connection is ready
What Are We Building?
We are building a two-person chat app.
The app will work like this:
- The first user opens the app and joins a room.
- The second user opens the same app and joins the same room.
- The server helps both users exchange WebRTC signaling data.
- A peer-to-peer connection is created.
- The two users send messages directly through a WebRTC data channel.
The important part is that Socket.IO is only used for signaling. It is not used to send the actual chat messages after the WebRTC connection is established.
The basic flow looks like this:
Client A <-- signaling --> Server <-- signaling --> Client B
Client A <----------- WebRTC Data Channel -----------> Client B
So the server introduces the two clients, but WebRTC handles the actual peer-to-peer communication.
What Is WebRTC?
WebRTC stands for Web Real-Time Communication.
It allows browsers to communicate directly with each other. It is commonly used for:
- Video calls
- Voice calls
- Screen sharing
- File sharing
- Real-time data transfer
- Peer-to-peer chat apps
In this tutorial, we will not use video or audio. We will only use a data channel.
A WebRTC data channel allows two peers to send data directly to each other. That data can be text, JSON, files, game state, or anything else your app needs.
For this project, we will send simple chat messages.
Why Do We Need Socket.IO?
WebRTC creates the peer-to-peer connection, but it does not define how two browsers should find each other before that connection exists.
Before two browsers can connect, they need to exchange some setup information:
- An offer
- An answer
- ICE candidates
This exchange is called signaling.
Socket.IO is a great choice for this tutorial because it gives us a simple real-time connection between the browser and the server.
The server will receive signaling messages from one client and forward them to the other client in the same room.
Project Setup
Create a new project folder:
mkdir webrtc-peer-chat
cd webrtc-peer-chat
Initialize a new Node.js project:
npm init -y
Install the required dependencies:
npm install express socket.io ejs
Install nodemon as a development dependency:
npm install --save-dev nodemon
Now create this project structure:
webrtc-peer-chat/
│
├── server.js
├── package.json
│
├── views/
│ └── index.ejs
│
└── public/
└── main.js
Update package.json
Open package.json and update the scripts section:
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
}
Your full package.json may look similar to this:
{
"name": "webrtc-peer-chat",
"version": "1.0.0",
"description": "A simple peer-to-peer chat app using WebRTC and Socket.IO",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"ejs": "^3.1.10",
"express": "^5.1.0",
"socket.io": "^4.8.1"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}
Do not worry if your installed versions are slightly different. That is normal.
Create the Signaling Server
Create a file called server.js.
const express = require("express");
const http = require("http");
const path = require("path");
const { Server } = require("socket.io");
const app = express();
const server = http.createServer(app);
const io = new Server(server);
const PORT = process.env.PORT || 3000;
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.use(express.static(path.join(__dirname, "public")));
app.get("/", (req, res) => {
res.render("index");
});
io.on("connection", (socket) => {
console.log("User connected:", socket.id);
socket.on("join-room", (roomId) => {
const room = io.sockets.adapter.rooms.get(roomId);
const numberOfClients = room ? room.size : 0;
if (numberOfClients >= 2) {
socket.emit("room-full", roomId);
return;
}
socket.join(roomId);
socket.data.roomId = roomId;
const isInitiator = numberOfClients === 0;
socket.emit("room-joined", {
roomId,
isInitiator
});
if (!isInitiator) {
socket.to(roomId).emit("peer-joined");
}
console.log(`${socket.id} joined room: ${roomId}`);
});
socket.on("signal", ({ roomId, data }) => {
socket.to(roomId).emit("signal", data);
});
socket.on("disconnect", () => {
const roomId = socket.data.roomId;
if (roomId) {
socket.to(roomId).emit("peer-left");
}
console.log("User disconnected:", socket.id);
});
});
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Server Code Explanation
Let’s quickly understand what the server does.
First, we create an Express app and an HTTP server:
const app = express();
const server = http.createServer(app);
Then we attach Socket.IO to that server:
const io = new Server(server);
We also tell Express to use EJS for rendering our view:
app.set("view engine", "ejs");
And we serve static files from the public folder:
app.use(express.static(path.join(__dirname, "public")));
This allows the browser to load our main.js file.
The most important part is the join-room event:
socket.on("join-room", (roomId) => {
const room = io.sockets.adapter.rooms.get(roomId);
const numberOfClients = room ? room.size : 0;
if (numberOfClients >= 2) {
socket.emit("room-full", roomId);
return;
}
socket.join(roomId);
});
Each room is limited to two clients.
The first client creates the room. The second client joins the room. If a third client tries to join, the server sends a room-full event.
The server also listens for signaling messages:
socket.on("signal", ({ roomId, data }) => {
socket.to(roomId).emit("signal", data);
});
This forwards WebRTC signaling data from one client to the other.
The server does not create the WebRTC connection. It only helps the two clients exchange the information they need to create it themselves.
Create a Better-Looking View
Now let’s create the UI.
Create a folder called views, then create a file called index.ejs.
This version is much more polished than a basic textarea and button. It includes a gradient background, a chat card, styled message bubbles, a room section, and a live connection status.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebRTC Peer-to-Peer Chat</title>
<style>
:root {
--bg-dark: #0f172a;
--bg-card: rgba(255, 255, 255, 0.92);
--primary: #6366f1;
--primary-dark: #4f46e5;
--secondary: #06b6d4;
--text-main: #111827;
--text-muted: #6b7280;
--border: #e5e7eb;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--bubble-local: #4f46e5;
--bubble-remote: #f3f4f6;
--shadow: 0 25px 80px rgba(15, 23, 42, 0.28);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text-main);
background:
radial-gradient(circle at top left, rgba(99, 102, 241, 0.45), transparent 30%),
radial-gradient(circle at bottom right, rgba(6, 182, 212, 0.35), transparent 35%),
linear-gradient(135deg, #0f172a 0%, #1e1b4b 45%, #0f172a 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.app {
width: min(1100px, 100%);
display: grid;
grid-template-columns: 0.95fr 1.05fr;
gap: 28px;
align-items: stretch;
}
.hero {
color: white;
padding: 36px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 32px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
position: relative;
}
.hero::after {
content: "";
position: absolute;
width: 180px;
height: 180px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
right: -60px;
bottom: -70px;
}
.eyebrow {
width: fit-content;
padding: 8px 12px;
border-radius: 999px;
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.12);
color: #dbeafe;
margin-bottom: 22px;
}
.hero h1 {
font-size: clamp(36px, 5vw, 64px);
line-height: 0.95;
margin: 0 0 20px;
letter-spacing: -0.06em;
}
.hero p {
font-size: 18px;
line-height: 1.7;
color: #dbeafe;
margin: 0;
max-width: 560px;
}
.hero-points {
display: grid;
gap: 14px;
margin-top: 40px;
position: relative;
z-index: 1;
}
.hero-point {
display: flex;
align-items: center;
gap: 12px;
color: #e0f2fe;
font-size: 15px;
}
.hero-point span {
width: 28px;
height: 28px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.14);
display: grid;
place-items: center;
}
.chat-card {
border-radius: 32px;
background: var(--bg-card);
backdrop-filter: blur(20px);
box-shadow: var(--shadow);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.5);
min-height: 680px;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 24px;
background: linear-gradient(135deg, #ffffff, #eef2ff);
border-bottom: 1px solid var(--border);
}
.chat-header-top {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-bottom: 20px;
}
.chat-title {
margin: 0;
font-size: 22px;
letter-spacing: -0.03em;
}
.chat-subtitle {
margin: 4px 0 0;
color: var(--text-muted);
font-size: 14px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: #f3f4f6;
color: #4b5563;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #9ca3af;
}
.status-badge[data-state="waiting"] .status-dot {
background: var(--warning);
}
.status-badge[data-state="connected"] .status-dot {
background: var(--success);
}
.status-badge[data-state="error"] .status-dot {
background: var(--danger);
}
.room-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
}
.room-input {
width: 100%;
border: 1px solid var(--border);
background: white;
border-radius: 16px;
padding: 14px 16px;
font-size: 15px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.room-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.16);
}
.button {
border: 0;
border-radius: 16px;
padding: 14px 18px;
font-size: 15px;
font-weight: 800;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(79, 70, 229, 0.28);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.button-primary {
color: white;
background: linear-gradient(135deg, var(--primary), var(--secondary));
}
.button-send {
color: white;
background: var(--primary-dark);
min-width: 110px;
}
.helper-text {
margin: 12px 0 0;
color: var(--text-muted);
font-size: 13px;
}
.messages {
flex: 1;
padding: 24px;
overflow-y: auto;
background:
linear-gradient(rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.72)),
radial-gradient(circle at top right, rgba(99, 102, 241, 0.12), transparent 35%);
}
.empty-state {
height: 100%;
min-height: 260px;
display: grid;
place-items: center;
text-align: center;
color: var(--text-muted);
}
.empty-state-card {
max-width: 320px;
padding: 26px;
border: 1px dashed #c7d2fe;
border-radius: 24px;
background: rgba(255, 255, 255, 0.65);
}
.empty-state-icon {
font-size: 38px;
margin-bottom: 10px;
}
.empty-state h3 {
margin: 0 0 8px;
color: var(--text-main);
}
.empty-state p {
margin: 0;
line-height: 1.6;
font-size: 14px;
}
.message-row {
display: flex;
margin-bottom: 14px;
}
.message-row.local {
justify-content: flex-end;
}
.message-bubble {
max-width: 75%;
padding: 12px 14px;
border-radius: 18px;
line-height: 1.45;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
}
.message-row.local .message-bubble {
color: white;
background: var(--bubble-local);
border-bottom-right-radius: 6px;
}
.message-row.remote .message-bubble {
color: var(--text-main);
background: var(--bubble-remote);
border-bottom-left-radius: 6px;
}
.message-meta {
margin-top: 6px;
font-size: 11px;
opacity: 0.72;
}
.composer {
padding: 18px;
border-top: 1px solid var(--border);
background: white;
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: end;
}
.message-input {
width: 100%;
min-height: 52px;
max-height: 130px;
resize: vertical;
border: 1px solid var(--border);
border-radius: 18px;
padding: 14px 16px;
font-size: 15px;
font-family: inherit;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.message-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.14);
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
}
.hero {
min-height: auto;
}
.chat-card {
min-height: 650px;
}
}
@media (max-width: 560px) {
body {
padding: 16px;
}
.hero,
.chat-card {
border-radius: 24px;
}
.chat-header-top {
align-items: flex-start;
flex-direction: column;
}
.room-form,
.composer {
grid-template-columns: 1fr;
}
.message-bubble {
max-width: 88%;
}
}
</style>
</head>
<body>
<main class="app">
<section class="hero">
<div>
<div class="eyebrow">WebRTC + Socket.IO</div>
<h1>Peer-to-peer chat in the browser</h1>
<p>
Join the same room from two browser tabs, create a WebRTC connection,
and send messages directly between peers using a data channel.
</p>
</div>
<div class="hero-points">
<div class="hero-point">
<span>⚡</span>
<strong>Socket.IO handles signaling</strong>
</div>
<div class="hero-point">
<span>🔗</span>
<strong>WebRTC creates the peer connection</strong>
</div>
<div class="hero-point">
<span>💬</span>
<strong>Messages travel through a data channel</strong>
</div>
</div>
</section>
<section class="chat-card">
<header class="chat-header">
<div class="chat-header-top">
<div>
<h2 class="chat-title">Mini P2P Chat</h2>
<p class="chat-subtitle">Open this page in two tabs and use the same room name.</p>
</div>
<div id="statusBadge" class="status-badge" data-state="idle">
<span class="status-dot"></span>
<span id="statusText">Not connected</span>
</div>
</div>
<div class="room-form">
<input
id="roomInput"
class="room-input"
type="text"
value="demo-room"
placeholder="Enter a room name"
/>
<button id="connectButton" class="button button-primary">
Connect
</button>
</div>
<p class="helper-text">
Tip: Use the same room name in both tabs. Only two clients can join the same room.
</p>
</header>
<div id="messages" class="messages">
<div id="emptyState" class="empty-state">
<div class="empty-state-card">
<div class="empty-state-icon">💬</div>
<h3>No messages yet</h3>
<p>
Connect two clients, wait for the data channel to open,
and start chatting peer-to-peer.
</p>
</div>
</div>
</div>
<footer class="composer">
<textarea
id="messageInput"
class="message-input"
placeholder="Write a message..."
disabled
></textarea>
<button id="sendButton" class="button button-send" disabled>
Send
</button>
</footer>
</section>
</main>
<script src="/socket.io/socket.io.js"></script>
<script src="/main.js"></script>
</body>
</html>
Create the Client JavaScript
Now create a folder called public, then create a file called main.js.
This file handles:
- Joining a room
- Creating the WebRTC peer connection
- Exchanging signaling messages
- Creating the data channel
- Sending and receiving chat messages
- Updating the UI
const socket = io();
const roomInput = document.getElementById("roomInput");
const connectButton = document.getElementById("connectButton");
const messageInput = document.getElementById("messageInput");
const sendButton = document.getElementById("sendButton");
const messages = document.getElementById("messages");
const emptyState = document.getElementById("emptyState");
const statusText = document.getElementById("statusText");
const statusBadge = document.getElementById("statusBadge");
let roomId;
let peerConnection;
let dataChannel;
let isInitiator = false;
let pendingCandidates = [];
const peerConfiguration = {
iceServers: [
{
urls: "stun:stun.l.google.com:19302"
}
]
};
connectButton.addEventListener("click", () => {
roomId = roomInput.value.trim();
if (!roomId) {
alert("Please enter a room name.");
return;
}
socket.emit("join-room", roomId);
connectButton.disabled = true;
roomInput.disabled = true;
updateStatus("Joining room...", "waiting");
});
sendButton.addEventListener("click", sendMessage);
messageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
});
socket.on("room-joined", ({ roomId, isInitiator: initiator }) => {
isInitiator = initiator;
createPeerConnection();
if (isInitiator) {
updateStatus(`Room "${roomId}" created. Waiting for peer...`, "waiting");
} else {
updateStatus(`Joined room "${roomId}". Connecting...`, "waiting");
}
});
socket.on("peer-joined", async () => {
updateStatus("Peer joined. Creating offer...", "waiting");
if (!peerConnection) {
createPeerConnection();
}
createDataChannel();
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
sendSignal({
description: peerConnection.localDescription
});
});
socket.on("signal", async (data) => {
if (!peerConnection) {
createPeerConnection();
}
if (data.description) {
await peerConnection.setRemoteDescription(data.description);
await addPendingCandidates();
if (data.description.type === "offer") {
updateStatus("Offer received. Creating answer...", "waiting");
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
sendSignal({
description: peerConnection.localDescription
});
}
}
if (data.candidate) {
const candidate = data.candidate;
if (peerConnection.remoteDescription) {
await peerConnection.addIceCandidate(candidate);
} else {
pendingCandidates.push(candidate);
}
}
});
socket.on("room-full", (roomId) => {
updateStatus(`Room "${roomId}" is full. Try another room.`, "error");
connectButton.disabled = false;
roomInput.disabled = false;
});
socket.on("peer-left", () => {
updateStatus("The other peer left the room.", "error");
closeConnection();
});
function createPeerConnection() {
peerConnection = new RTCPeerConnection(peerConfiguration);
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
sendSignal({
candidate: event.candidate
});
}
};
peerConnection.onconnectionstatechange = () => {
const state = peerConnection.connectionState;
if (state === "connected") {
updateStatus("Connected. Data channel ready soon...", "connected");
}
if (state === "disconnected" || state === "failed" || state === "closed") {
updateStatus(`Connection ${state}.`, "error");
disableChat();
}
};
peerConnection.ondatachannel = (event) => {
dataChannel = event.channel;
setupDataChannel();
};
}
function createDataChannel() {
dataChannel = peerConnection.createDataChannel("chat");
setupDataChannel();
}
function setupDataChannel() {
dataChannel.onopen = () => {
updateStatus("Connected. You can chat now.", "connected");
enableChat();
};
dataChannel.onmessage = (event) => {
addMessage({
sender: "Peer",
text: event.data,
type: "remote"
});
};
dataChannel.onclose = () => {
updateStatus("Data channel closed.", "error");
disableChat();
};
dataChannel.onerror = (error) => {
console.error("Data channel error:", error);
updateStatus("Data channel error.", "error");
};
}
function sendMessage() {
const message = messageInput.value.trim();
if (!message) {
return;
}
if (!dataChannel || dataChannel.readyState !== "open") {
alert("The data channel is not open yet.");
return;
}
dataChannel.send(message);
addMessage({
sender: "You",
text: message,
type: "local"
});
messageInput.value = "";
}
function sendSignal(data) {
socket.emit("signal", {
roomId,
data
});
}
async function addPendingCandidates() {
for (const candidate of pendingCandidates) {
await peerConnection.addIceCandidate(candidate);
}
pendingCandidates = [];
}
function addMessage({ sender, text, type }) {
if (emptyState) {
emptyState.remove();
}
const row = document.createElement("div");
row.className = `message-row ${type}`;
const bubble = document.createElement("div");
bubble.className = "message-bubble";
const messageText = document.createElement("div");
messageText.textContent = text;
const meta = document.createElement("div");
meta.className = "message-meta";
meta.textContent = `${sender} • ${formatTime(new Date())}`;
bubble.appendChild(messageText);
bubble.appendChild(meta);
row.appendChild(bubble);
messages.appendChild(row);
messages.scrollTop = messages.scrollHeight;
}
function updateStatus(message, state = "idle") {
statusText.textContent = message;
statusBadge.dataset.state = state;
}
function enableChat() {
messageInput.disabled = false;
sendButton.disabled = false;
messageInput.focus();
}
function disableChat() {
messageInput.disabled = true;
sendButton.disabled = true;
}
function closeConnection() {
if (dataChannel) {
dataChannel.close();
dataChannel = null;
}
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
disableChat();
}
function formatTime(date) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit"
});
}
Client Code Explanation
When the user clicks the Connect button, the client sends a join-room event to the server:
socket.emit("join-room", roomId);
The server checks how many users are already in that room.
If the room is empty, the user becomes the first peer.
If the room already has one user, the second user joins and the WebRTC connection process starts.
The first peer creates a WebRTC offer:
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
The offer is sent to the second peer through Socket.IO.
The second peer receives the offer, sets it as the remote description, and creates an answer:
await peerConnection.setRemoteDescription(data.description);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
The answer is then sent back to the first peer.
Both clients also exchange ICE candidates:
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
sendSignal({
candidate: event.candidate
});
}
};
ICE candidates help WebRTC find the best possible network path between the two browsers.
When the data channel opens, the chat input becomes active:
dataChannel.onopen = () => {
updateStatus("Connected. You can chat now.", "connected");
enableChat();
};
After that, messages are sent directly through the data channel:
dataChannel.send(message);
The server is no longer responsible for sending chat messages.
Run the Project
Start the development server:
npm run dev
Open the app in your browser:
http://localhost:3000
Now open the same URL in another browser tab.
Use the same room name in both tabs, for example:
demo-room
Click Connect in both tabs.
Once the connection is ready, the status badge will change and the message box will become active.
Now write a message in one tab and send it. You should see the message appear in the other tab.
Congratulations. You just built a peer-to-peer chat app with WebRTC.
A Note About STUN and TURN Servers
In this tutorial, we used this STUN server:
const peerConfiguration = {
iceServers: [
{
urls: "stun:stun.l.google.com:19302"
}
]
};
A STUN server helps the browser discover network information that may be needed to establish a peer-to-peer connection.
For local testing, this is usually enough.
However, in real-world applications, a STUN server alone is not always enough. Some users may be behind strict NATs, firewalls, or corporate networks.
In those situations, the peer-to-peer connection may fail.
For production apps, you should use a TURN server as well. A TURN server can relay traffic when a direct connection is not possible.
A production configuration may look like this:
const peerConfiguration = {
iceServers: [
{
urls: "stun:your-stun-server.com"
},
{
urls: "turn:your-turn-server.com",
username: "temporary-username",
credential: "temporary-password"
}
]
};
Do not hard-code permanent TURN credentials in public frontend code. In a real app, you should generate temporary TURN credentials on the backend.
Things to Improve Before Production
This project is intentionally simple, but it gives you a strong starting point.
Before using this in a real app, you should improve a few things.
1. Use HTTPS
For production, your app should run over HTTPS.
Localhost is fine while developing, but real WebRTC apps should be served securely.
2. Add Authentication
Right now, anyone who knows the room name can join.
In a real app, you should add authentication and private room access.
3. Add Better Reconnection Handling
If a user refreshes the page or loses their internet connection, this demo does not fully restore the connection.
A production app should handle reconnecting, leaving rooms, and creating a new offer when needed.
4. Add Usernames
Currently, messages are shown as either “You” or “Peer”.
You could improve the app by allowing each user to enter a username before joining the room.
5. Add Typing Indicators
Because we already have a data channel, you could send small events such as:
{
"type": "typing"
}
Then the other peer could see:
Peer is typing...
6. Add File Sharing
WebRTC data channels can send more than text.
You could extend this project to send files directly between two browsers.
7. Add TURN Support
A TURN server makes your app more reliable across difficult networks.
Without TURN, some users may not be able to connect.
Common Problems
The Send Button Is Disabled
This usually means the WebRTC data channel is not open yet.
Make sure both browser tabs joined the same room.
The Room Is Full
This demo only allows two clients per room.
Use another room name or refresh both tabs.
It Works in Two Tabs but Not on Two Devices
Make sure both devices can access the server.
If you are testing on your local network, you may need to use your computer’s local IP address instead of localhost.
For example:
http://192.168.1.10:3000
It Still Does Not Connect on Different Networks
You may need a TURN server.
Some networks block direct peer-to-peer connections, and TURN helps by relaying the traffic.
Final Thoughts
WebRTC can feel confusing at first because it has many moving parts.
You have to understand:
- Peer connections
- Signaling
- Offers
- Answers
- ICE candidates
- STUN servers
- TURN servers
- Data channels
But the basic idea is simple.
The server helps two clients discover each other.
The clients exchange connection information.
WebRTC creates the peer-to-peer connection.
The data channel sends messages directly between the browsers.
Once you understand this flow, you can build much more interesting projects, such as file-sharing apps, multiplayer games, video chat apps, collaborative tools, and real-time dashboards.
This tutorial gives you the foundation.
From here, you can make the app your own.