Skip to main content

Guide

simple-peer vs PeerJS vs mediasoup 2026

Compare simple-peer, PeerJS, and mediasoup for WebRTC in Node.js. Peer-to-peer vs SFU architecture, video/audio streaming, data channels, scalability, and.

·PkgPulse Team·
0

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

Featuresimple-peerPeerJSmediasoup
ArchitectureP2PP2P + signalingSFU server
SignalingBring your ownBuilt-in serverBring 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
ComplexityLowLow-MediumHigh

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

TURN Server Requirements in Production

WebRTC's peer-to-peer promise depends on ICE (Interactive Connectivity Establishment), which attempts to find a direct path between peers through STUN (Session Traversal Utilities for NAT). STUN works for roughly 80% of connections — peers behind typical home routers can connect directly after the STUN server reveals their public IP and port. The remaining 20% of connections, typically users behind symmetric NATs or enterprise firewalls, require a TURN (Traversal Using Relays around NAT) server that relays media data. TURN servers are bandwidth-intensive — all media flows through them, not just signaling. For a video call application with 1,000 concurrent users, a significant fraction will require TURN relay, which means substantial server bandwidth costs. Self-hosting TURN servers using coturn is possible but requires ongoing maintenance and capacity planning. Commercial TURN services (Twilio's TURN, Xirsys) charge per gigabyte of relayed data, which can become expensive for high-traffic applications. All three libraries (simple-peer, PeerJS, mediasoup) require you to configure your own TURN server credentials — none of them provide TURN infrastructure.

Scalability Limits of P2P Architecture

The O(n²) bandwidth problem in P2P architecture becomes concrete quickly. In a four-person video call with 720p video at roughly 2 Mbps per stream, each participant is sending three streams and receiving three streams — six streams at 2 Mbps each means 12 Mbps per participant, or 48 Mbps total for the four-person call. Add a fifth participant and you need four upload streams and four download streams per person — the bandwidth doubles with a single new participant. Most broadband connections have asymmetric bandwidth, with upload speeds significantly lower than download speeds. This means the upload bottleneck appears earlier than the download limit, and applications built on simple-peer or PeerJS typically break down in practice between four and six participants before the download limit is ever hit. mediasoup's SFU architecture fixes this by requiring each participant to upload exactly one stream (to the server) regardless of how many others are in the call. The server handles distribution, making bandwidth consumption linear rather than quadratic.

TypeScript Integration and Type Safety

TypeScript coverage across these libraries ranges from excellent to adequate. mediasoup is written in TypeScript and ships first-class types — the mediasoup.types namespace includes precise interfaces for every transport, producer, consumer, and router type, making the complex mediasoup server API navigable in an IDE with full autocomplete. simple-peer ships community-maintained @types/simple-peer types that cover the main API surface but occasionally lag behind the library for newer options. PeerJS includes bundled types and the API is simple enough that type coverage is effectively complete. For building a production WebRTC application, mediasoup's strong typing is particularly valuable because the transport/producer/consumer lifecycle involves many asynchronous operations with specific parameter shapes — TypeScript catching a missing field or wrong type in a produce() call is much better than debugging a cryptic WebRTC error at runtime.

Self-Hosting vs. Managed WebRTC Services

The build-vs-buy question is particularly important for WebRTC. Building on top of mediasoup gives maximum control — you own the signaling protocol, the room management logic, and the media routing — but requires a dedicated media server that scales with concurrent rooms. A mediasoup server handling 100 concurrent group calls (with 10 participants each) needs meaningful CPU and network capacity; WebRTC media processing is CPU-intensive. Managed alternatives like LiveKit are worth serious evaluation: LiveKit is open-source (Apache 2.0), self-hostable via Docker, and provides the full mediasoup-equivalent feature set with a significantly higher-level API. The LiveKit client SDK abstracts ICE negotiation, transport management, and producer/consumer lifecycle into a simple Room abstraction, reducing the amount of WebRTC expertise required to build a working product. For teams without dedicated infrastructure engineers, LiveKit or Daily.co typically result in shipping faster with lower operational risk than self-managed mediasoup.

Signaling Architecture and Implementation Patterns

WebRTC's greatest architectural complexity is the signaling layer — the out-of-band channel used to exchange ICE candidates and session description protocol (SDP) offers and answers between peers before the direct connection is established. None of these libraries provide signaling themselves (PeerJS provides a signaling server, but you still need to deliver signaling messages between clients). Signaling is typically implemented with WebSockets, but any bidirectional channel works — Server-Sent Events, HTTP polling, or even copy-pasted strings for demos. The signaling protocol must exchange at minimum: the offer SDP, the answer SDP, and the ICE candidates from both sides. simple-peer fires a signal event with data that must be delivered to the remote peer through your signaling channel. The remote peer receives the signal data and calls peer.signal(data) to incorporate it. For production signaling, using a WebSocket server with room management (Socket.IO or plain ws) is the most common approach, and the signaling server must handle cases like one peer being ready before the other, network disconnections during signaling, and reconnection after failed ICE negotiation.

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.

Compare real-time and networking packages on PkgPulse →

See also: SSE vs WebSocket vs Long Polling and pm2 vs node:cluster vs tsx watch, better-sqlite3 vs libsql vs sql.js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.