simple-peer vs PeerJS vs mediasoup: WebRTC Libraries in Node.js (2026)
TL;DR
simple-peer is the easiest WebRTC library — wraps the browser's RTCPeerConnection in a clean Node.js-style EventEmitter API, perfect for simple P2P connections. PeerJS adds a signaling server on top — you get a managed peer ID system and a hosted or self-hosted signaling service, making P2P connections near-zero config. mediasoup is the production-grade SFU (Selective Forwarding Unit) — handles group calls, recording, and thousands of concurrent streams but requires significant server infrastructure. For simple P2P chat or file transfer: PeerJS. For direct P2P without signaling server: simple-peer. For scalable multi-party video conferencing: mediasoup.
Key Takeaways
- simple-peer: ~400K weekly downloads — thin RTCPeerConnection wrapper, bring-your-own signaling, 25KB
- peerjs: ~250K weekly downloads — includes signaling server, peer IDs, zero-config P2P
- mediasoup: ~50K weekly downloads — SFU architecture, group calls, production conferencing infrastructure
- P2P (simple-peer, PeerJS) doesn't scale past ~4-6 participants — every peer sends to every other peer
- mediasoup requires a Node.js media server — clients send one stream, server routes it
- For most apps needing group video, use a hosted SFU (Daily.co, Livekit, Agora) instead of self-hosting mediasoup
Architecture Overview
P2P (simple-peer, PeerJS):
Browser A ←──────────────────────→ Browser B
(each peer sends to every other peer — O(n²) bandwidth)
SFU (mediasoup):
Browser A ──→ mediasoup server ──→ Browser B
Browser B ──→ mediasoup server ──→ Browser A
Browser C ──→ mediasoup server ──→ Browser A, B
(each peer sends one stream, server routes — O(n) bandwidth)
simple-peer
simple-peer — minimal, Node.js-style WebRTC wrapper:
Basic P2P connection
import SimplePeer from "simple-peer"
// Initiator (caller) side:
const peer = new SimplePeer({
initiator: true,
trickle: false, // Wait for complete signal before exchanging
})
// Signal event fires with the offer/answer to send to the other peer:
peer.on("signal", (data) => {
// Send this to the remote peer via your signaling channel
// (WebSocket, HTTP, copy-paste — whatever works)
socket.emit("signal", { to: remoteUserId, data })
})
// When remote peer sends their signal back:
socket.on("signal", ({ from, data }) => {
peer.signal(data) // Feed the answer/ICE candidates
})
// Connected!
peer.on("connect", () => {
console.log("P2P connection established")
peer.send("Hello from simple-peer!")
})
// Receive data:
peer.on("data", (data) => {
console.log("Received:", data.toString())
})
peer.on("error", (err) => console.error("Peer error:", err))
peer.on("close", () => console.log("Connection closed"))
Video/audio streaming
import SimplePeer from "simple-peer"
// Get local media stream:
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720 },
audio: true,
})
// Create peer with stream:
const peer = new SimplePeer({
initiator: true,
stream, // Attach local stream
trickle: true, // Send ICE candidates as they're discovered (faster connection)
config: {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" }, // Google STUN server
{
urls: "turn:your-turn-server.com:3478",
username: "user",
credential: "pass",
},
],
},
})
// When remote peer's stream arrives:
peer.on("stream", (remoteStream) => {
const video = document.getElementById("remote-video") as HTMLVideoElement
video.srcObject = remoteStream
video.play()
})
// Signal exchange (same as above):
peer.on("signal", (data) => socket.emit("signal", data))
socket.on("signal", (data) => peer.signal(data))
File transfer via data channel
import SimplePeer from "simple-peer"
async function sendFile(peer: SimplePeer.Instance, file: File) {
const CHUNK_SIZE = 16384 // 16KB chunks
peer.on("connect", async () => {
// Send file metadata first:
peer.send(JSON.stringify({
type: "file-meta",
name: file.name,
size: file.size,
type: file.type,
}))
// Send file in chunks:
const buffer = await file.arrayBuffer()
let offset = 0
while (offset < buffer.byteLength) {
const chunk = buffer.slice(offset, offset + CHUNK_SIZE)
peer.send(chunk)
offset += CHUNK_SIZE
// Backpressure — wait if buffer is full:
if ((peer as any)._channel?.bufferedAmount > 65536) {
await new Promise((r) => setTimeout(r, 100))
}
}
peer.send(JSON.stringify({ type: "file-end" }))
})
}
PeerJS
PeerJS — P2P made simple with a managed signaling server:
Client-side connection
import Peer from "peerjs"
// Connect to PeerJS cloud server (free, limited) or self-hosted PeerServer:
const peer = new Peer({
host: "your-peerserver.com",
port: 9000,
path: "/peerjs",
// Or use the hosted cloud server:
// (no config needed — connects to cloud by default)
})
// Your peer ID (generated or specify your own):
peer.on("open", (id) => {
console.log("My peer ID:", id) // Share this with others to connect
// e.g., "a1b2c3d4-e5f6-..."
})
// Incoming connection from another peer:
peer.on("connection", (conn) => {
conn.on("open", () => {
conn.send("Hello!")
})
conn.on("data", (data) => {
console.log("Received:", data)
})
})
// Connect to another peer (you know their ID):
const conn = peer.connect("their-peer-id")
conn.on("open", () => {
conn.send({ message: "Hey there!", timestamp: Date.now() })
})
conn.on("data", (data) => {
console.log("Data from remote:", data)
})
Video call with PeerJS
import Peer from "peerjs"
const myPeer = new Peer()
// Answer incoming calls:
myPeer.on("call", (call) => {
// Get local stream and answer:
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then((stream) => {
call.answer(stream)
call.on("stream", (remoteStream) => {
showVideo(remoteStream)
})
})
})
// Call another peer:
async function callPeer(remotePeerId: string) {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
})
const call = myPeer.call(remotePeerId, stream)
call.on("stream", (remoteStream) => {
showVideo(remoteStream)
})
call.on("close", () => {
console.log("Call ended")
})
}
function showVideo(stream: MediaStream) {
const video = document.createElement("video")
video.srcObject = stream
video.autoplay = true
document.body.appendChild(video)
}
Self-hosted PeerServer
// server.ts — run your own signaling server:
import { PeerServer } from "peer"
const peerServer = PeerServer({
port: 9000,
path: "/peerjs",
// Custom ID generation:
generateClientId: () => crypto.randomUUID(),
})
peerServer.on("connection", (client) => {
console.log("Peer connected:", client.getId())
})
peerServer.on("disconnect", (client) => {
console.log("Peer disconnected:", client.getId())
})
// Or add to existing Express app:
import express from "express"
import { ExpressPeerServer } from "peer"
const app = express()
const server = app.listen(9000)
const peerServerMiddleware = ExpressPeerServer(server, {
path: "/peerjs",
})
app.use("/peerjs", peerServerMiddleware)
mediasoup
mediasoup — the Node.js SFU for scalable group video/audio:
Server setup
import * as mediasoup from "mediasoup"
// Create a Worker (handles multiple Routers — one per CPU core recommended):
const worker = await mediasoup.createWorker({
rtcMinPort: 10000,
rtcMaxPort: 10100,
logLevel: "warn",
})
worker.on("died", () => {
console.error("mediasoup Worker died — exiting")
process.exit(1)
})
// Create a Router (a media routing entity — one per room):
const router = await worker.createRouter({
mediaCodecs: [
{
kind: "audio",
mimeType: "audio/opus",
clockRate: 48000,
channels: 2,
},
{
kind: "video",
mimeType: "video/VP8",
clockRate: 90000,
},
{
kind: "video",
mimeType: "video/H264",
clockRate: 90000,
parameters: {
"packetization-mode": 1,
"profile-level-id": "42e01f",
},
},
],
})
// Create a WebRTC transport for a client:
async function createTransport(router: mediasoup.types.Router) {
const transport = await router.createWebRtcTransport({
listenIps: [{ ip: "0.0.0.0", announcedIp: process.env.PUBLIC_IP }],
enableUdp: true,
enableTcp: true,
preferUdp: true,
})
return transport
}
Producer/consumer flow
// Client connects and creates send transport:
const sendTransport = await createTransport(router)
// Client signal: "I want to produce video"
async function handleProduce(transport: mediasoup.types.WebRtcTransport, producerOptions: any) {
const producer = await transport.produce({
kind: producerOptions.kind, // "audio" or "video"
rtpParameters: producerOptions.rtpParameters,
})
console.log(`Producer created: ${producer.id}`)
producer.on("transportclose", () => producer.close())
producer.on("score", (score) => {
// Monitor quality: [{ ssrc, score (0-10), rid }]
console.log("Producer score:", score)
})
return producer.id
}
// Another client wants to receive that producer's stream:
async function handleConsume(
router: mediasoup.types.Router,
recvTransport: mediasoup.types.WebRtcTransport,
producerId: string,
rtpCapabilities: mediasoup.types.RtpCapabilities
) {
// Check if client can consume this producer:
if (!router.canConsume({ producerId, rtpCapabilities })) {
throw new Error("Client cannot consume this producer")
}
const consumer = await recvTransport.consume({
producerId,
rtpCapabilities,
paused: true, // Start paused, resume after client-side setup
})
consumer.on("transportclose", () => consumer.close())
consumer.on("producerclose", () => consumer.close())
// Send consumer params to client so it can receive the stream:
return {
id: consumer.id,
producerId,
kind: consumer.kind,
rtpParameters: consumer.rtpParameters,
}
}
Client-side mediasoup (mediasoup-client)
import { Device } from "mediasoup-client"
// Load router's RTP capabilities:
const device = new Device()
await device.load({ routerRtpCapabilities })
// Create send transport:
const sendTransport = device.createSendTransport(serverTransportParams)
// Handle transport connection:
sendTransport.on("connect", ({ dtlsParameters }, callback, errback) => {
// Signal to server: "connect my transport"
socket.emit("connectTransport", { transportId: sendTransport.id, dtlsParameters }, callback)
})
sendTransport.on("produce", async ({ kind, rtpParameters }, callback) => {
// Signal to server: "I want to produce"
const { id } = await socket.emitWithAck("produce", {
transportId: sendTransport.id,
kind,
rtpParameters,
})
callback({ id })
})
// Produce local video:
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
const videoTrack = stream.getVideoTracks()[0]
const audioTrack = stream.getAudioTracks()[0]
const videoProducer = await sendTransport.produce({ track: videoTrack })
const audioProducer = await sendTransport.produce({ track: audioTrack })
Feature Comparison
| Feature | simple-peer | PeerJS | mediasoup |
|---|---|---|---|
| Architecture | P2P | P2P + signaling | SFU server |
| Signaling | Bring your own | Built-in server | Bring your own |
| Group calls | ❌ (O(n²) bandwidth) | ❌ (O(n²)) | ✅ Scales |
| Server required | ❌ (STUN/TURN only) | ✅ PeerServer | ✅ Media server |
| Video/audio | ✅ | ✅ | ✅ |
| Data channels | ✅ | ✅ | ❌ (use WebSocket) |
| Recording | ❌ | ❌ | ✅ |
| Bundle size | ~25KB | ~50KB | ~2MB (server) |
| TypeScript | ✅ | ✅ | ✅ |
| Complexity | Low | Low-Medium | High |
When to Use Each
Choose simple-peer if:
- Building 1:1 P2P connections (video chat, file transfer, game state sync)
- You already have a signaling channel (WebSocket, WebRTC offers via REST)
- Minimal bundle size matters — 25KB vs heavier alternatives
- You want a clean Node.js EventEmitter API wrapping RTCPeerConnection
Choose PeerJS if:
- You want zero-config P2P without building signaling yourself
- Quick prototyping or small-scale apps (PeerJS cloud is free, limited)
- Self-hosting the signaling server on your own infrastructure
- 1:1 or small group (2-4 peers) with simple API
Choose mediasoup if:
- Group video calls with more than 4-6 participants
- You need server-side recording or transcoding
- Building a video conferencing product (video chat app, live streaming)
- You have DevOps capacity to run a Node.js media server
Consider a hosted SFU instead if:
- You don't want to manage media server infrastructure
- Good alternatives: LiveKit (open source, self-hostable), Daily.co, Agora, Twilio Video
- LiveKit in particular is excellent — open source, mediasoup-like power with much better DX
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on simple-peer v9.x, peerjs v1.x, and mediasoup v3.x.