A deliberately small peer-to-peer video call app built to show how WebRTC signaling actually works: no SDK, no framework, no abstraction layers. Roughly 240 lines of JavaScript across a Socket.IO signaling server and a vanilla browser client.
If you have ever read the WebRTC spec and wanted to see the smallest possible thing that negotiates a call between two browsers, this is that thing.
Scope: one-to-one calls (two peers per room). See Limitations before using this as a starting point for anything real.
Most WebRTC tutorials either hide the negotiation behind a library or bury it in a production app. This repo keeps the interesting part visible:
- Signaling is explicit. Every offer, answer, and ICE candidate crosses a Socket.IO
channel you can read in ~60 lines of
server.js. - No build step. Clone,
npm install,npm start. The browser loads plain scripts. - The console is the documentation. Both server and client log each step of the handshake in order, so you can watch a connection form.
git clone https://github.com/jeebon/minimal-webrtc.git
cd minimal-webrtc
npm install
npm startOpen http://localhost:3000 in two tabs, enter the same room number in both, and allow camera/microphone access.
New to WebRTC? Click Ping the other tab first. If the other tab alerts, the Socket.IO signaling channel works — so if the video call later fails, the problem is the WebRTC handshake, not your connection to the server.
Requires Node.js 14+.
sequenceDiagram
participant A as Peer A (caller)
participant S as Signaling server
participant B as Peer B (callee)
A->>S: create or join (room)
S-->>A: created
Note over A: getUserMedia() → local stream
B->>S: create or join (room)
S-->>B: joined
Note over B: getUserMedia() → local stream
B->>S: ready
S-->>A: ready
Note over A: createOffer() → setLocalDescription()
A->>S: offer (SDP)
S-->>B: offer (SDP)
Note over B: setRemoteDescription() → createAnswer()
B->>S: answer (SDP)
S-->>A: answer (SDP)
A->>S: candidate (ICE)
S-->>B: candidate (ICE)
B->>S: candidate (ICE)
S-->>A: candidate (ICE)
Note over A,B: Media flows directly, peer to peer
The signaling server never touches media. Once ICE negotiation succeeds, audio and video travel directly between browsers; the server's only job is introducing the two peers to each other.
| Event | Direction | Purpose |
|---|---|---|
create or join |
client → server | Request a room by number |
created / joined / full |
server → client | Room assignment result |
ready |
both | Second peer has media and is ready to negotiate |
offer / answer |
both | SDP exchange |
candidate |
both | ICE candidate exchange |
server.js counts sockets in a room and responds accordingly:
- 0 clients →
created(this peer becomes the caller) - 1 client →
joined(this peer becomes the callee, triggersready) - 2+ clients →
full(rejected)
server.js Express static host + Socket.IO signaling (~60 lines)
public/
index.html Two <video> elements and a room-number input
client.js getUserMedia, RTCPeerConnection, and the handshake (~175 lines)
These are intentional — removing them would defeat the point of the repo — but you should know about them before building on it.
Two peers only. This is a single RTCPeerConnection per client. Multi-party calls
need either a full mesh (N−1 connections per peer, practical to about 4 participants)
or an SFU such as mediasoup or
LiveKit.
No TURN server. Only public STUN servers are configured, so connections succeed on
the same network and across many home networks, but fail behind symmetric NAT or
restrictive corporate firewalls. Add TURN credentials in the iceServers block of
client.js — coturn is the standard self-hosted
option.
HTTPS is required off localhost. getUserMedia() only works in a secure context.
localhost is exempt, but testing across devices on your LAN needs TLS — a reverse
proxy or ngrok is the quickest route.
No reconnection, no room cleanup, no auth. A dropped socket is a dropped call, and room numbers are guessable. Fine for a demo, not for production.
No error surfacing in the UI. Failures land in the browser console.
MIT — see LICENSE.
