el-Based Discovery:** Instead of exchanging peer IDs, clients join a named channel. The SDK handles peer discovery and negotiation. This simplifies the handshake and supports dynamic participant counts without ID management.
- Symmetric Code: Both participants run identical logic. There is no distinction between "caller" and "callee." This reduces code complexity and eliminates race conditions associated with role assignment.
- Stream Pre-Registration: Media streams are registered with the SDK before joining the channel. This ensures that negotiation begins immediately upon connection, reducing time-to-video.
- Encapsulated Session Management: The implementation uses a
RoomSession class to encapsulate state, event listeners, and media handling. This pattern improves maintainability and testability compared to inline scripts.
Implementation
The following TypeScript example demonstrates a production-ready pattern. It includes a RoomSession class that manages the lifecycle, handles events, and renders media.
Prerequisites:
- Node 18+ and npm.
- A publishable key (
pk_live_...) from the Metered dashboard.
- Modern browser (Chrome 90+, Firefox 90+, Safari 15+).
Code Structure:
// session.ts
import { MeteredPeer, PeerJoinedEvent, StreamAddedEvent, StateChangeEvent } from "@metered-ca/peer";
export interface SessionConfig {
apiKey: string;
roomId: string;
localVideo: HTMLVideoElement;
remoteVideo: HTMLVideoElement;
statusCallback: (status: string) => void;
}
export class RoomSession {
private peer: MeteredPeer;
private localStream: MediaStream | null = null;
private isJoined: boolean = false;
constructor(private config: SessionConfig) {
this.peer = new MeteredPeer({ apiKey: config.apiKey });
this.initializeListeners();
}
private initializeListeners(): void {
// Top-level event: A new peer enters the channel
this.peer.on("peer-joined", ({ peer: remotePeer }) => {
// Per-peer event: Remote peer publishes media
remotePeer.on("stream-added", ({ stream }: StreamAddedEvent) => {
this.config.remoteVideo.srcObject = stream;
this.config.statusCallback("Connected to peer");
});
// Per-peer event: Network state changes
remotePeer.on("state-change", ({ to }: StateChangeEvent) => {
this.config.statusCallback(`Connection state: ${to}`);
});
});
}
async start(): Promise<void> {
try {
// 1. Capture local media
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
// Render local video with echo prevention
this.config.localVideo.srcObject = this.localStream;
this.config.localVideo.muted = true;
// 2. Register stream with SDK before joining
this.peer.addStream(this.localStream, { role: "camera" });
// 3. Join the channel to trigger negotiation
await this.peer.join(this.config.roomId);
this.isJoined = true;
this.config.statusCallback(`Joined room: ${this.config.roomId}`);
} catch (error) {
console.error("Session start failed:", error);
this.config.statusCallback("Failed to start session");
}
}
stop(): void {
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
}
this.config.localVideo.srcObject = null;
this.config.remoteVideo.srcObject = null;
this.isJoined = false;
}
}
Usage Example:
// app.ts
import { RoomSession } from "./session";
const config = {
apiKey: "pk_live_YOUR_KEY",
roomId: "project-alpha-room",
localVideo: document.getElementById("local-feed") as HTMLVideoElement,
remoteVideo: document.getElementById("remote-feed") as HTMLVideoElement,
statusCallback: (msg: string) => {
const el = document.getElementById("status") as HTMLParagraphElement;
el.textContent = msg;
},
};
const session = new RoomSession(config);
document.getElementById("join-btn")?.addEventListener("click", () => {
session.start();
});
Rationale
RoomSession Class: Encapsulating the logic in a class isolates the WebRTC SDK interactions from the UI layer. This makes it easier to integrate with frameworks like React or Vue later, as the session can be managed via hooks or lifecycle methods.
- Event Scoping: The
peer-joined event provides a remotePeer object. Stream and state events are attached to this remote peer, not the top-level peer. This distinction is critical; attaching stream-added to the top-level peer would result in missed events or incorrect stream routing.
addStream Before join: Registering the stream prior to joining ensures that the SDP negotiation includes the media tracks immediately. This reduces latency and prevents scenarios where the connection is established but media negotiation is delayed.
muted Attribute: The local video element must be muted to prevent audio feedback loops. This is a common oversight that causes echo in 1:1 calls.
Pitfall Guide
Production WebRTC implementations often fail due to subtle environmental and configuration issues. The following pitfalls address common mistakes and their remedies.
-
The file:// Protocol Trap
- Explanation:
getUserMedia requires a secure context. Browsers block camera access when the page is loaded via file:// or non-HTTPS HTTP.
- Fix: Always serve the application via
localhost or HTTPS. Use a static server like npx serve during development. Never open HTML files directly from the filesystem.
-
Mobile Fullscreen Hijack
- Explanation: On iOS Safari, video elements may force fullscreen playback, disrupting the UI layout and user experience.
- Fix: Add the
playsinline attribute to all <video> tags. This instructs mobile browsers to play video inline within the page layout.
-
Event Scope Confusion
- Explanation: Developers often attach
stream-added listeners to the top-level MeteredPeer instance. This is incorrect; stream events are emitted by the remote peer object.
- Fix: Listen for
peer-joined on the top-level peer, then attach stream-added to the remotePeer object provided in the event payload.
-
Ignoring Connection State
- Explanation: Network drops can occur without warning. Failing to monitor connection state leaves users unaware of connectivity issues.
- Fix: Attach a
state-change listener to remote peers. Use this to update UI indicators and log connectivity metrics. The SDK handles reconnection automatically, but visibility is essential for debugging.
-
Hardcoded Secrets in Production
- Explanation: While publishable keys are designed for client-side use, hardcoding them in source control can lead to abuse or quota exhaustion.
- Fix: Use environment variables or a build-time configuration system to inject the API key. Ensure keys are rotated if compromised.
-
Stream Lifecycle Neglect
- Explanation: When a peer leaves, the remote video element may retain the old stream, causing confusion or memory leaks.
- Fix: Listen for
peer-left events and clear the srcObject of the remote video element. Stop local tracks when the session ends to release hardware resources.
-
Missing TURN Fallback
- Explanation: Calls may work on local networks but fail in production due to NAT restrictions.
- Fix: Ensure the SDK is configured with TURN credentials. The
@metered-ca/peer SDK includes TURN by default, but verify that the free tier limits align with your usage requirements.
Production Bundle
This section provides actionable resources for deploying and scaling WebRTC applications.
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Rapid Prototyping | Managed Channel SDK | Zero infrastructure; fast iteration; includes TURN and reconnection. | Free tier available; pay-as-you-go scaling. |
| Enterprise Compliance | Self-Hosted Signaling | Full control over data residency and signaling logic. | High infrastructure and maintenance costs. |
| Large Group Calls | SFU Architecture | Selective Forwarding Unit optimizes bandwidth for many participants. | Higher complexity; requires media server infrastructure. |
| Peer-to-Peer 1:1 | Channel-Based SDK | Simplifies discovery; symmetric code; no caller/callee logic. | Low cost; scales with usage. |
Configuration Template
Use the following template to configure your build environment and session parameters.
# .env
METERED_API_KEY=pk_live_YOUR_PUBLISHABLE_KEY
ROOM_ID=production-room-01
// config.ts
export const AppConfig = {
apiKey: process.env.METERED_API_KEY || "pk_live_DEFAULT",
roomId: process.env.ROOM_ID || "default-room",
videoConstraints: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
},
};
Quick Start Guide
- Obtain API Key: Sign up at Metered and generate a publishable key (
pk_live_...).
- Install SDK: Run
npm install @metered-ca/peer in your project directory.
- Implement Session: Create a
RoomSession class as shown in the Core Solution.
- Serve Application: Run
npx serve . to start a local server.
- Test Call: Open two browser tabs, navigate to
http://localhost:3000, and click "Join" in each tab. Verify that both tabs display local and remote video streams.
This guide provides a foundation for building resilient, zero-infrastructure WebRTC applications. By leveraging managed signaling and channel-based discovery, teams can reduce complexity and focus on delivering high-quality real-time experiences.