Create a Peer-to-Peer Chat App with WebRTC

Created by eneaslari 19/6/2026

coding

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:

  1. The first user opens the app and joins a room.
  2. The second user opens the same app and joins the same room.
  3. The server helps both users exchange WebRTC signaling data.
  4. A peer-to-peer connection is created.
  5. 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.

More to read


The Pomodoro Technique: How I Learned to Focus as a Programmer, One Tomato at a Time
19/6/2026

A personal and practical guide to using the Pomodoro Technique as a programmer. This article explains how working in short, focused sessions can help reduce distractions, improve concentration, make complex coding tasks feel manageable, and turn productivity into a simple, motivating habit.

Building Forward, Then Looking Back: A Day of Redesign, Recovery, and Hard Lessons
28/3/2026

A personal update on a day that started with redesigning LarixGames and thinking about how to present and market the games better, and ended with discovering a database attack that changed how I think about the project’s future.

How to Back Up Docker Volumes Automatically on a Server
27/3/2026

A detailed guide to setting up automatic Docker volume backups with shell scripts and cron, including safer MongoDB backups with mongodump, backup rotation, restore steps, and practical advice for protecting both database and uploaded files.

Building LarixGames: A Day of Fixes, Design, and Fresh Starts
27/3/2026

A personal update on the work behind LarixGames, from redesigning the sites and game library to recovering the server, restoring admin access, and shaping the project into something more focused and real.