A WebRTC signaling server is the matchmaker that lets two browsers find each other and exchange the connection details (SDP offers/answers + ICE candidates) needed to open a direct peer-to-peer link — it relays those handshake messages but never touches your audio or video, which flow browser-to-browser once the handshake completes. WebRTC deliberately leaves how you move those messages up to you; this guide shows the two real paths: build your own minimal signaling server in Node.js (runnable, below), or skip it entirely with free managed signaling and no server to run.
That fork is the whole article. If you want to understand the moving parts and own the infrastructure, the build path is a ~40-line Node + ws relay plus a raw RTCPeerConnection browser client — copy-paste-able and tested. If you'd rather not run, scale, secure, and reconnect a WebSocket server forever, the buy path connects to free managed signaling (wss://rms.metered.ca/v1) with one import and a publishable key. We'll build the small one first so the managed one isn't a black box.
Companion tutorials: once you have signaling working, the WebRTC video-call tutorial builds the full 1:1 call on top of it, and the WebRTC reconnect tutorial shows how to make a call survive a network drop — the single hardest thing the minimal server below ignores.
What a WebRTC signaling server does
WebRTC gives two browsers a way to talk directly — peer-to-peer audio, video, or data — without routing every packet through your servers. But before that direct link can exist, the two peers have to agree on a pile of details neither of them knows about the other: codecs, encryption keys, and the network addresses (host, reflexive, relayed) at which each can be reached. Discovering and exchanging that information is signaling, and the thing that carries those messages between the two peers is a signaling server.
Concretely, a WebRTC signaling server does three jobs:
- Peer discovery — it's how peer A learns that peer B exists and wants to connect (a room, a call ID, a channel). Browsers have no way to find each other on the open internet; the signaling server is the rendezvous point.
- Relays the SDP offer/answer — each side produces a Session Description Protocol (SDP) blob describing what it can send/receive (codecs, resolutions, encryption fingerprints). One peer sends an offer, the other replies with an answer. The signaling server just passes these between them.
- Relays ICE candidates — as each browser discovers the network paths it can be reached on (via STUN, and TURN when needed), it emits ICE candidates. The signaling server forwards each candidate to the other peer so the two can find a route that works.
Here is the part people miss: the signaling server never touches your media. Once the SDP exchange and ICE negotiation finish, the audio/video/data flows directly between the two browsers (or through a TURN relay if a direct path is impossible — but never through the signaling server). The signaling server's whole job is the handshake. After the call is connected, it can disconnect and the call keeps running.
That's why a signaling server can be tiny: it's a message relay, not a media server. It moves a few kilobytes of JSON at call setup and then gets out of the way.
How WebRTC signaling works (the offer/answer/ICE dance)
The signaling sequence for a 1:1 connection is always the same shape, regardless of what transport you pick:
- Both peers connect to the signaling server and join the same room.
- One peer creates an offer (
pc.createOffer()→setLocalDescription) and sends the SDP to the other peer through the server. - The other peer applies it (
setRemoteDescription), creates an answer (createAnswer()→setLocalDescription), and sends that SDP back through the server. -
In parallel, each peer's
RTCPeerConnectionfiresicecandidateevents as it discovers network paths. Each candidate is relayed to the other peer, which adds it withaddIceCandidate. (This is trickle ICE — candidates flow continuously instead of waiting for a complete list.) - ICE picks a working candidate pair, the connection goes to
connected, and media flows directly between the browsers. Signaling's job is done.
WebRTC doesn't define the transport — WebSocket is the common pick
Crucially, the WebRTC spec does not say how signaling messages travel. It standardizes the content (SDP, ICE candidates) and the browser API (RTCPeerConnection), but the channel that moves those messages is entirely your choice. You could relay them over:
- WebSocket — by far the most common, because signaling is inherently bidirectional and low-latency (the server must push B's offer to A the instant it arrives). This is what we'll build.
-
HTTP long-polling / SSE /
fetch— workable, clunkier for the server-push direction. - Anything else — even a shared database or a copy-paste of the SDP by hand works for a demo. The browser doesn't care; it just needs the other peer's SDP and candidates to arrive.
Because WebSocket is the natural fit, the canonical "build a signaling server" task is really "stand up a small WebSocket relay." Let's do exactly that.
Build a minimal WebRTC signaling server (Node.js + ws)
This is the DIY path: raw WebRTC + a raw WebSocket relay. No SDK. The goal is the smallest thing that genuinely connects two tabs — one room, two peers — so you can see every moving part. Two files: a Node server (signaling-server.js) and a browser page (index.html).
Prerequisites
- Node.js 18+ and npm.
- One npm package for the server:
ws(the de-facto Node WebSocket library). - A modern browser: Chrome 90+ / Firefox 90+ / Safari 15+.
-
getUserMedianeeds a secure context — HTTPS orlocalhost. Serve the page; don't open it asfile://.
1. The signaling server (signaling-server.js)
The entire server is a WebSocket relay: it accepts up to two peers into one room and forwards each message it receives to the other peer. It never parses the SDP or ICE inside — it just moves bytes. It also tells each peer whether it's the "polite" one, which the client uses for perfect negotiation (so both tabs can run identical code without their offers colliding), and it sends a one-word ready nudge to the first peer the moment the second one joins — so neither side starts the offer/answer dance until there's actually someone on the other end.
// signaling-server.js — a minimal WebRTC signaling server (Node + ws).
// It relays signaling messages between the two peers in one room, and tells each
// peer whether it is the "polite" one (for perfect negotiation). It NEVER sees your
// audio/video — media flows peer-to-peer once ICE finishes.
import { WebSocketServer } from "ws";
const PORT = 8080;
const wss = new WebSocketServer({ port: PORT });
const room = new Set(); // one room, at most two peers — enough to prove the concept
wss.on("connection", (socket) => {
if (room.size >= 2) {
socket.close(1013, "room full"); // 1013 = "try again later"
return;
}
// First peer in is "impolite", second is "polite" (perfect-negotiation tie-break).
const polite = room.size === 1;
room.add(socket);
socket.send(JSON.stringify({ type: "welcome", polite }));
console.log(`peer connected as ${polite ? "polite" : "impolite"} (${room.size}/2)`);
// Once BOTH peers are present, tell the peer that was already waiting it can start
// negotiating. Without this, the first peer would offer into an empty room (that offer
// is lost) and then ignore the second peer's offer as a "collision" — a deadlock.
if (room.size === 2) {
for (const peer of room) {
if (peer !== socket && peer.readyState === peer.OPEN) {
peer.send(JSON.stringify({ type: "ready" }));
}
}
}
// Relay every other message to the OTHER peer. The server doesn't parse the SDP or
// ICE inside — offer, answer, or candidate, it just forwards the bytes.
socket.on("message", (data, isBinary) => {
for (const peer of room) {
if (peer !== socket && peer.readyState === peer.OPEN) {
peer.send(data, { binary: isBinary });
}
}
});
socket.on("close", () => {
room.delete(socket);
console.log(`peer disconnected (${room.size}/2)`);
});
});
console.log(`Signaling server listening on ws://localhost:${PORT}`);
That's the whole signaling server. Notice what's not there: no SDP parsing, no media handling, no understanding of WebRTC at all. To this server, an offer, an answer, and an ICE candidate are identical — opaque JSON it forwards to the one other peer in the room.
2. The browser client (index.html)
The client is raw WebRTC: a single RTCPeerConnection, with the offer/answer/ICE messages sent over the WebSocket above. It uses the standard perfect negotiation pattern so both tabs can run the exact same code — whichever the server marked "polite" yields if both happen to offer at once. Two ordering details matter for it to actually connect: it grabs the camera before wiring up the socket (so the only await happens first and no early welcome/offer message is missed), and it doesn't add its tracks — which is what kicks off the offer — until it knows the other peer is present (via welcome's polite flag or the server's ready nudge), so it never offers into an empty room.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Minimal WebRTC signaling — DIY</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
video { width: 320px; background: #000; border-radius: 8px; margin-right: 1rem; }
</style>
</head>
<body>
<h1>Minimal WebRTC signaling (raw RTCPeerConnection + ws)</h1>
<video id="local" autoplay playsinline muted></video>
<video id="remote" autoplay playsinline></video>
<script type="module">
// client — runs in the BROWSER. Raw RTCPeerConnection + the ws relay.
// Canonical "perfect negotiation" pattern, trimmed to the minimum:
// both tabs run identical code; the server told us which one is "polite".
const SIGNALING_URL = "ws://localhost:8080";
// Get the camera FIRST. This is the only `await` in the script, so doing it up front
// means every handler below is registered synchronously — no early signaling message
// (the "welcome", or the very first offer) can arrive before we're listening for it.
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
document.getElementById("local").srcObject = localStream;
// Public STUN only. Behind a real / symmetric NAT you ALSO need TURN here:
// iceServers: [{ urls: "stun:..." }, { urls: "turn:...", username, credential }]
// Delivering those TURN credentials is one of the jobs a managed signaling server does for you.
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
let polite = false; // set from the server's "welcome"
let makingOffer = false;
let ignoreOffer = false;
// Remote media shows up here once the connection is live.
pc.ontrack = ({ streams: [stream] }) => {
document.getElementById("remote").srcObject = stream;
};
// Trickle ICE: send each local candidate over the relay as we discover it.
pc.onicecandidate = ({ candidate }) => {
if (candidate) send({ type: "ice", candidate });
};
// Whenever we need to (re)negotiate, make an offer. The "polite" peer backs off
// if both sides offer at once, so this same handler is safe in both tabs.
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription(); // implicit createOffer()
send({ type: "description", description: pc.localDescription });
} finally {
makingOffer = false;
}
};
// Add our tracks (which fires `negotiationneeded` and kicks off the offer) only once
// BOTH peers are in the room — so we never offer into an empty room.
let started = false;
function start() {
if (started) return;
started = true;
for (const track of localStream.getTracks()) pc.addTrack(track, localStream);
}
const ws = new WebSocket(SIGNALING_URL);
const send = (msg) => ws.send(JSON.stringify(msg));
ws.onmessage = async ({ data }) => {
const msg = JSON.parse(data);
if (msg.type === "welcome") {
polite = msg.polite;
// If we're the polite (second) peer, the other peer is already here — safe to
// negotiate now. The impolite (first) peer instead waits for "ready" below.
if (polite) start();
return;
}
if (msg.type === "ready") {
// We're the first peer and a second just joined — now both are present.
start();
return;
}
if (msg.type === "description") {
const description = msg.description;
const offerCollision =
description.type === "offer" &&
(makingOffer || pc.signalingState !== "stable");
ignoreOffer = !polite && offerCollision; // impolite peer ignores the colliding offer
if (ignoreOffer) return;
await pc.setRemoteDescription(description);
if (description.type === "offer") {
await pc.setLocalDescription(); // implicit createAnswer()
send({ type: "description", description: pc.localDescription });
}
} else if (msg.type === "ice") {
try {
await pc.addIceCandidate(msg.candidate);
} catch (err) {
if (!ignoreOffer) throw err; // candidates for an ignored offer are expected to fail
}
}
};
</script>
</body>
</html>
Run it
# 1. New folder, install the one dependency:
npm init -y
npm pkg set type=module # so the server can use `import`
npm install ws
# 2. Start the signaling server:
node signaling-server.js
# -> Signaling server listening on ws://localhost:8080
# 3. In another terminal, serve index.html over http (NOT file://):
npx serve . # or: python3 -m http.server 8000
Now open the served page in two browser tabs (e.g. http://localhost:3000 from serve, or http://localhost:8000). The first tab shows your camera; when the second tab loads, the two tabs run the offer/answer/ICE handshake through your server, and each tab's second video tile fills with the other tab's camera. In the server terminal you'll see peer connected as impolite (1/2) then peer connected as polite (2/2). You just built a working WebRTC signaling server.
And here are the two tabs once the handshake completes — each tab's second tile is filled by the other tab's camera, which is the proof the call connected peer-to-peer:
Why two tabs work locally but a real call may not: on one machine, both peers are on
localhost, so STUN alone finds a direct path. Put the two peers on different real networks (especially behind symmetric NAT or a corporate firewall) and a direct path often doesn't exist — you'll need TURN to relay the media, and your signaling server has to deliver TURN credentials to each client. The minimal server above does none of that. More on this next.
Get the complete app
Don't want to stitch the two snippets together by hand? The whole project — signaling-server.js, index.html, a package.json, and a README — is one gist you can clone and run or fork it:
Minimal WebRTC signaling server (Node + ws)
Companion code for the tutorial WebRTC Signaling Server: How It Works, Build One, or Skip It.
📖 Full tutorial:
<article-url-pending>— replace with the published dev.to URL once the article is live.
Two files, ~40 lines of server: a WebSocket relay (signaling-server.js) that forwards SDP
offer/answer + ICE candidates between two peers in one room, plus a raw RTCPeerConnection
browser client (index.html) using the standard perfect-negotiation
pattern. The server never touches your media — audio/video flows peer-to-peer once ICE finishes.
Run it
# 1. Install the one dependency:
npm install
# 2. Start the signaling server:
npm start
# -> Signaling server listening on ws://localhost:8080
# 3. In another terminal, serve index.html over http (NOT file://):
npm run serve # serves on http://localhost:3000
# or: python3 -m http.server 8000
Open the served page in two browser tabs…
git clone https://github.com/jamesbordane57/webrtc-signaling-server-demo.git
cd webrtc-signaling-server-demo
npm install
npm start # signaling server on ws://localhost:8080
# then, in a second terminal:
npx serve . # serve index.html — open http://localhost:3000 in two tabs
What this minimal version does NOT handle
The ~40-line relay above proves the concept, but it is nowhere near production. Here's the gap — i.e. the real cost of the DIY path — roughly in the order it'll bite you:
- More than two peers / real rooms. It's a single hard-coded room capped at two sockets. Real apps need room creation/joining, room IDs, capacity, and routing a message to the right peers in the right room (not just "the other socket").
-
Reconnection. WebSockets drop — Wi-Fi blips, laptop sleep, cellular handoff. This server has no reconnect logic, no backoff, no session resumption. When the socket dies mid-call, signaling is simply gone, and the raw
RTCPeerConnectionwon't recover the media path on its own either (no ICE restart). Hand-rolling resilient reconnection is the single hardest part of DIY signaling. - Authentication & authorization. Anyone who can reach the WebSocket can join any room and receive its signaling traffic. There's no auth, no per-room access control, no rate limiting, no abuse protection.
- TURN credential delivery. As noted above, real-world calls need TURN, and TURN needs short-lived credentials delivered to each client securely (you don't hard-code TURN secrets in client JS). That's a backend responsibility your signaling layer normally owns — and it's entirely absent here.
- Presence. Who's online? Who just left? Who's in this room right now? There's no roster, no join/leave events surfaced to the app beyond the two console logs.
-
Scale & ops. One Node process, in-memory room state, no horizontal scaling, no health checks, no metrics, no deployment story. Two processes behind a load balancer immediately breaks the in-memory
roomSet. - Wire-format hardening. No message validation, no max-size limits, no protection against malformed frames.
None of these are exotic — they're table stakes for a signaling server you'd put real users on. Building and maintaining them is the actual cost of "just build a signaling server." Which is the whole reason the second path exists.
Or skip it: free managed signaling, zero server to run
Here's the path the search results barely cover: you don't have to run a signaling server at all. Managed signaling means the WebSocket relay, rooms, reconnection, auth, and TURN credential delivery are operated for you — you connect a client and skip every gap from the previous section.
Most hosted real-time options bundle this inside a broader CPaaS (Communications-Platform-as-a-Service) product, and the genuinely free, "just point at our endpoint" tier is rare. One that's free for prototypes and hobby work is @metered-ca/peer (an MIT-licensed, zero-dependency JS/TS library, ~13 KB gzipped with WebRTC) talking to Metered's managed signaling endpoint at wss://rms.metered.ca/v1. There's no server for you to deploy, scale, or keep alive.
Here's the entire signaling+call client — the managed equivalent of everything above, in one HTML file. Both tabs run identical code; joining the same channel is the handshake.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Managed WebRTC signaling — @metered-ca/peer</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
video { width: 320px; background: #000; border-radius: 8px; margin-right: 1rem; }
</style>
</head>
<body>
<h1>Managed WebRTC signaling (no server to run)</h1>
<p id="status">idle</p>
<video id="local" autoplay playsinline muted></video>
<video id="remote" autoplay playsinline></video>
<script type="module">
import { MeteredPeer } from "https://esm.sh/@metered-ca/peer@1.0.7";
const CHANNEL = "demo-room";
const PK = "pk_live_REPLACE_ME"; // <-- your publishable key from metered.ca
const statusEl = document.getElementById("status");
// 1. Capture camera + mic (HTTPS or localhost, same as raw WebRTC).
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
document.getElementById("local").srcObject = localStream;
// 2. One peer, one channel — publishable-key auth, no token server.
const peer = new MeteredPeer({ apiKey: PK });
// 3. When another peer joins, listen for THEIR media on the remote peer object.
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream }) => {
document.getElementById("remote").srcObject = stream;
});
// Built-in reconnect signal — read `to` for the new state.
remote.on("state-change", ({ from, to }) => {
statusEl.textContent = `${remote.id}: ${to}`; // e.g. "reconnecting" -> "connected"
});
});
// 4. Publish our camera to everyone in the channel (no per-target call).
peer.addStream(localStream, { role: "camera" });
// 5. Join — this is what actually connects. Managed signaling handles SDP/ICE,
// perfect negotiation, TURN credential delivery, and reconnection for you.
await peer.join(CHANNEL);
statusEl.textContent = "joined " + CHANNEL;
</script>
</body>
</html>
To run it: grab a free publishable key (pk_live_…) by signing up at metered.ca, paste it in for PK, serve the file over localhost (same as before — getUserMedia needs a secure context), and open two tabs. There is no node process, no ws, no signaling-server.js — the managed endpoint is the signaling server.
What you got "for free" relative to the DIY build:
-
No server to deploy or maintain. The signaling relay, rooms, and scaling are operated for you at
wss://rms.metered.ca/v1. - Reconnection is built in. The SDK auto-recovers from WebSocket drops and runs an ICE-restart ladder, preserving the same remote-peer identity across the blip — the hardest DIY gap, handled. (See the reconnect companion tutorial.)
-
Channel-based peer discovery + presence.
peer.join(channel)discovers peers and firespeer-joined/peer-left— no manual roster. - TURN credential delivery. Metered can auto-inject TURN credentials into the connection, and its Open Relay project provides free TURN bandwidth for prototypes — so calls that need a relay (symmetric NAT, corporate firewalls) work without you wiring TURN by hand.
Managed signaling gives you two ways to authenticate, mirroring the "start simple, harden later" path:
-
Publishable key (
pk_live_…) — what's used above. Zero backend; the key goes straight in the browser. Each connection gets a random peer ID. Ideal for prototypes, static sites, and public demo channels. -
tokenProvider(JWT) — for production. Your backend mints a short-lived HS256 JWT (signed with a secret key) and the SDK fetches it on connect and on every reconnect. This gives you stable per-user peer IDs, peer-visible metadata, and embedded TURN credentials. Swap the constructor:
// Production: your backend mints a JWT; the SDK refreshes it automatically on reconnect.
const peer = new MeteredPeer({ tokenProvider: async () => fetchJwtFromYourBackend() });
The rest of the call code is identical — only the auth line changes.
Build vs. buy: when to self-host signaling, when to use managed
Neither path is universally right. Honest framing:
Build your own (raw ws + RTCPeerConnection) |
Managed signaling (@metered-ca/peer) |
|
|---|---|---|
| Best when | You need full control of the wire, custom routing/auth, on-prem/air-gapped deploys, or you're learning WebRTC end-to-end | You want a working call fast and don't want to operate signaling infra |
| You operate | The WebSocket server, rooms, reconnection, auth, TURN delivery, scaling, monitoring | Nothing — the endpoint is managed |
| Reconnection | You build the ladder (WS backoff + ICE restart + media re-attach) | Built in |
| TURN credentials | You deliver them yourself | Auto-injected; free Open Relay tier for prototypes |
| Self-hosting | Yes — it's your server | No — connects only to wss://rms.metered.ca/v1 (the trade for zero setup) |
| Cost shape | Your server + bandwidth + ops time | Free tier for prototypes/hobby; usage-based beyond |
| Time to first call | Hours-to-days (production-grade) | Minutes |
The honest trade with managed signaling is self-hosting: @metered-ca/peer connects only to Metered's endpoint, so if you must run signaling on your own infrastructure (regulatory, air-gapped, full-control mandates), build it. For most teams shipping a product — where signaling is plumbing, not the point — managed wins on time-to-call and on never having to own reconnection.
A common middle path: build the minimal relay to learn, then adopt managed signaling for production so you're not maintaining reconnection, presence, and TURN delivery forever.
FAQ
Is a WebRTC signaling server required?
Yes — WebRTC has no built-in peer discovery, so two browsers can't find each other or exchange SDP/ICE without some signaling channel between them. What's not required is that you build it: a managed signaling endpoint satisfies the requirement with no server of your own.
Can you do WebRTC without a signaling server?
Only in the trivial sense that "signaling" can be anything that moves the SDP and ICE candidates between peers — you could copy-paste them by hand for a demo, or relay them over an existing channel. For any real app you need a signaling mechanism; you just don't have to run one if you use managed signaling.
Is there a free WebRTC signaling server?
Free self-host options exist (you run them — the Node + ws relay in this guide is one, and there are open-source projects on GitHub). Free managed signaling — where someone else runs it — is rarer; @metered-ca/peer offers a free tier on wss://rms.metered.ca/v1 via a publishable key, with no server for you to operate.
Is the signaling server free with @metered-ca/peer?
The managed signaling endpoint has a free tier for prototypes and hobby work (publishable-key auth, no credit card to start), and the SDK itself is MIT-licensed and free. Usage-based pricing applies beyond the free tier; check metered.ca for current limits.
Is there an open-source WebRTC signaling server?
Yes — many. Because a signaling server is just a message relay, open-source examples exist for nearly every stack (the ws-based one above is ~40 lines; there are fuller Socket.IO, Node, Go, and Rust projects on GitHub). Note the distinction from this guide's managed option: the @metered-ca/peer client SDK is open source (MIT), while the managed signaling backend it connects to is operated by Metered, not self-hosted.
How do I build a WebRTC signaling server in Node.js?
Stand up a WebSocket server (the ws package is the standard choice) that relays each peer's SDP offer/answer and ICE candidates to the other peer(s) in a room — exactly the signaling-server.js above. WebRTC doesn't mandate the transport, but WebSocket fits because signaling is bidirectional and latency-sensitive.
Can I use Socket.IO for WebRTC signaling?
Yes — Socket.IO is a popular choice because its rooms API maps cleanly onto call rooms. The mechanics are identical to the raw-ws version here: relay offer / answer / ice events between peers; Socket.IO just adds rooms, auto-reconnect of the socket, and fallbacks on top. (It reconnects the WebSocket, but you still own WebRTC-level ICE restart and media re-attach.)
Can I build a signaling server in Python / C# / PHP / Go?
Absolutely — the signaling server is language-agnostic because it only moves SDP and ICE JSON between peers. Python (websockets/aiohttp), C# (SignalR/ASP.NET WebSockets), PHP (Ratchet), and Go (gorilla/websocket) are all common. The browser side is always JavaScript (RTCPeerConnection), but the relay can be anything that speaks WebSocket.
Is there a public WebRTC signaling server I can point at?
Public/managed endpoints exist — that's exactly what managed signaling like wss://rms.metered.ca/v1 is (you authenticate with a key rather than hosting it). Avoid pointing production traffic at random unauthenticated public relays: signaling carries connection metadata and, without auth, anyone can join your rooms.
What's the difference between a signaling server and a TURN server?
Different jobs. The signaling server relays the handshake (SDP + ICE candidates) so peers can find each other; it never carries media. A TURN server relays the media itself when a direct peer-to-peer path is impossible (symmetric NAT, restrictive firewalls). You often need both: signaling to set up the call, TURN as a media fallback. Managed signaling typically also delivers the TURN credentials to clients, which DIY signaling leaves to you.
Recipe (for skimmers)
Build (DIY): a signaling server is a WebSocket relay. Run a Node ws server that forwards each peer's SDP offer/answer + ICE candidates to the other peer in a room (~40 lines, above); the browser does the rest with raw RTCPeerConnection. It never touches media. The catch: you then own rooms, reconnection, auth, TURN delivery, presence, and scale.
Buy (managed): skip the server. import { MeteredPeer } from "https://esm.sh/@metered-ca/peer@1.0.7", new MeteredPeer({ apiKey: "pk_live_…" }), addStream(localStream), await peer.join("room") — both tabs run identical code, and wss://rms.metered.ca/v1 handles signaling, reconnection, presence, and TURN credential delivery. Zero backend for prototypes; swap apiKey for tokenProvider (JWT) in production.
The fork: build it to learn or to self-host; use managed to ship.
Last reviewed: 2026-06-04.
Verification: BUILD code (Node + ws server + raw RTCPeerConnection client) run end-to-end in two real Chromium tabs on 2026-06-04 — both peers reached connectionState: "connected" and each tab's remote video received the other's stream (640×480 both ways); the server boots, relays to the other peer only (no echo), and rejects a third peer (close 1013). BUY code: every @metered-ca/peer API verified against the live SDK docs (metered.ca/docs/llms-realtime-messaging-sdk.txt, re-fetched 2026-06-03) — new MeteredPeer({ apiKey })/tokenProvider, join, addStream, peer-joined { peer }, remote.id, state-change { from, to }, stream-added { stream } (no remote.streams array). CDN pin @metered-ca/peer@1.0.7 resolves to a real ESM module on esm.sh (HTTP 200); 1.0.7 confirmed latest on npm (MIT, zero runtime deps, ~13 KB gzipped with WebRTC). Signaling endpoint wss://rms.metered.ca/v1.





Top comments (1)
Thank you for reading. I hope you like the article