Skip to main content

simple-peer vs PeerJS vs mediasoup: WebRTC Libraries in Node.js (2026)

·PkgPulse Team

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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.