Follow Us

Our Blog

Home | Our Blog
Backend 12 min read

Scaling WebRTC Classrooms to 50+ Concurrent Users with Socket.io

Abdullah Khaled

Abdullah Khaled

Jun 18, 2026
0 Comments
Scaling WebRTC Classrooms to 50+ Concurrent Users with Socket.io

In 2023 I built the real-time classroom layer for Testat at IT-Plus — Node.js and Socket.io on the server, WebRTC in the browser, Angular in front of it. The number I can defend is this: a single live session sustained 50+ concurrent users. It is the only benchmark in this article. Everything else here is architecture and arithmetic — and the arithmetic decides whether you reach fifty or stall at eight.

A classroom is not a two-person call

Almost every WebRTC tutorial teaches the same shape: two browsers, one RTCPeerConnection each, a scrap of signalling glue, done. It works, it demos beautifully, and it quietly teaches you a topology that cannot carry a classroom.

The reason is that a classroom is asymmetric. One person presents for most of the hour; everyone else watches and occasionally unmutes to ask a question. A peer-to-peer call is symmetric by construction: every participant sends to every other participant. Build a classroom on a symmetric topology and you pay the full cost of a fifty-way conference to deliver what is functionally a broadcast.

The arithmetic that kills mesh

In a full mesh, every participant holds a peer connection to every other participant. For n participants that is n(n−1)/2 connections in the room and n−1 connections per browser. That is not a claim about my project; it is a property of complete graphs.

  • 4 participants: 6 connections in the room, 3 per browser.
  • 10 participants: 45 connections, 9 per browser.
  • 50 participants: 1,225 connections, 49 per browser.

Now attach bandwidth. Take a modest 500 kbps video stream. In a mesh a participant does not send it once — it sends a separately encoded copy to each peer. At fifty participants every browser uploads roughly 24.5 Mbps while decoding forty-nine inbound streams. Home uplinks across Egypt and the Gulf are often a fraction of that, and a laptop running forty-nine video decoders has nothing left for the tab the student is taking notes in.

Mesh does not degrade gracefully as you add people. It falls off a cliff, and it falls off it at the weakest uplink in the room.

The practical ceiling for mesh is a small group. Past that, the server has to be in the media path.

Mesh, SFU, MCU — what you are actually trading

Mesh

No media server, therefore no media server bill and nothing extra to operate. End-to-end encrypted by default, because nothing terminates the stream in the middle. The lowest latency available, because packets take the shortest path. And a cost curve that is quadratic in the number of participants. Right for 1:1 and small groups, wrong for anything shaped like a lecture.

SFU — Selective Forwarding Unit

Each participant sends one upstream to the server. The server does not decode it; it reads the RTP headers and forwards packets to whoever subscribed. Publisher upload becomes constant regardless of audience size, and the cost moves to server egress — where it is linear and, critically, where you control it. For a class of fifty with one presenter at 500 kbps that is a single 500 kbps ingress and about 24.5 Mbps egress: an unremarkable amount of traffic for one properly provisioned box. This is the topology that makes classroom-scale WebRTC possible at all.

MCU — Multipoint Control Unit

The server decodes every incoming stream, composites them into one picture, re-encodes and sends a single stream to each client. Clients get the cheapest possible job — one decode. You get the most expensive possible server, because transcoding is CPU-bound and does not cache. An MCU earns its keep when clients are weak, when you must record or restream to RTMP/HLS, or when you need a fixed composed layout. It is the wrong default: you are buying server CPU to save client CPU you probably already had.

An SFU also gives you something an MCU structurally cannot: simulcast. The publisher encodes the same camera at two or three resolutions and sends all of them; the server picks a layer per subscriber from that subscriber's measured bandwidth. The student on hotel Wi-Fi gets 180p, the one on fibre gets 720p, and the publisher's uplink cost rises by roughly a third rather than by a factor of fifty.

What Socket.io is actually doing — and what it is not

This is the distinction people get wrong most often, so it is worth being blunt: Socket.io never touches the media.

Media travels over SRTP, usually on UDP, negotiated directly between endpoints — or between endpoint and SFU. Socket.io carries the signalling plane over a WebSocket: session descriptions, ICE candidates, room membership, who is in this class, the teacher muted you, the whiteboard moved to slide 12. A few kilobytes per participant per session. The signalling tier and the media tier fail for completely different reasons and are sized completely differently.

Here is a signalling server close to what I would write today — Node with ES modules, Socket.io v4, and the Redis adapter so the tier scales horizontally behind a load balancer:

import { createServer } from 'node:http';
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const httpServer = createServer();

const io = new Server(httpServer, {
  cors: { origin: process.env.CLASSROOM_ORIGIN, credentials: true },
  // A student who closed the tab should leave the roster in seconds, not in a minute.
  pingInterval: 20_000,
  pingTimeout: 25_000,
});

const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
io.adapter(createAdapter(pub, sub));

// Authenticate before the connection is accepted, not after.
io.use(async (socket, next) => {
  const { token, roomId } = socket.handshake.auth;
  const session = await verifyClassroomToken(token, roomId);

  if (!session) {
    return next(new Error('unauthorized'));
  }

  socket.data.userId = session.userId;
  socket.data.roomId = roomId;
  socket.data.role = session.role; // 'teacher' | 'student'
  next();
});

io.on('connection', (socket) => {
  const { roomId, userId, role } = socket.data;

  // Two rooms per socket: the classroom for broadcasts, a private
  // mailbox so directed signalling works across Node processes.
  socket.join([roomId, `user:${userId}`]);
  socket.to(roomId).emit('peer:joined', { userId, role });

  socket.on('roster:get', async (ack) => {
    const sockets = await io.in(roomId).fetchSockets();
    ack(sockets.map((s) => ({ userId: s.data.userId, role: s.data.role })));
  });

  // The server is a relay, not a broker. It never parses SDP.
  socket.on('signal', ({ to, description, candidate }) => {
    io.to(`user:${to}`).emit('signal', { from: userId, description, candidate });
  });

  socket.on('disconnect', (reason) => {
    socket.to(roomId).emit('peer:left', { userId, reason });
  });
});

httpServer.listen(3000);

Three details in there matter more than they look.

  • Authentication runs in middleware, before the connection is accepted. A signalling socket that has not proved which classroom it belongs to is a signalling socket that can enumerate other people's rooms.
  • Two rooms per socket. The room id for broadcast events, user:<id> as a private mailbox for directed signalling. With the Redis adapter this keeps working when two peers are attached to different Node processes.
  • The server never parses SDP. It relays opaque blobs. The moment a signalling server starts rewriting SDP, it owns every codec negotiation bug in every browser version your students happen to run.

The browser side: negotiate once, and negotiate politely

The classic WebRTC race is glare: both sides create an offer at the same instant and each rejects the other's. The perfect-negotiation pattern removes that entire category of bug by making one side polite — the polite peer rolls back its own offer on collision, the impolite peer ignores the incoming one. In a classroom the role assignment writes itself: the presenter is impolite, students are polite.

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    {
      urls: [
        'turn:turn.example.com:3478?transport=udp',
        'turns:turn.example.com:5349?transport=tcp',
      ],
      username: turnCreds.username,
      credential: turnCreds.credential,
    },
  ],
  iceCandidatePoolSize: 2,
  bundlePolicy: 'max-bundle',
});

// Direction is declared up front so the SDP is stable from the first offer.
if (role === 'teacher') {
  const media = await navigator.mediaDevices.getUserMedia({
    video: { width: 1280, height: 720 },
    audio: { echoCancellation: true },
  });
  media.getTracks().forEach((track) => pc.addTrack(track, media));
} else {
  pc.addTransceiver('video', { direction: 'recvonly' });
  pc.addTransceiver('audio', { direction: 'recvonly' });
}

const polite = role === 'student';
let makingOffer = false;
let ignoreOffer = false;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();
    socket.emit('signal', { to: peerId, description: pc.localDescription });
  } finally {
    makingOffer = false;
  }
};

pc.onicecandidate = ({ candidate }) => {
  if (candidate) {
    socket.emit('signal', { to: peerId, candidate });
  }
};

socket.on('signal', async ({ from, description, candidate }) => {
  try {
    if (description) {
      const collision =
        description.type === 'offer' &&
        (makingOffer || pc.signalingState !== 'stable');

      ignoreOffer = !polite && collision;
      if (ignoreOffer) return;

      await pc.setRemoteDescription(description);

      if (description.type === 'offer') {
        await pc.setLocalDescription();
        socket.emit('signal', { to: from, description: pc.localDescription });
      }
    } else if (candidate) {
      try {
        await pc.addIceCandidate(candidate);
      } catch (err) {
        if (!ignoreOffer) throw err;
      }
    }
  } catch (err) {
    console.error('negotiation failed', err);
  }
});

Note the transceiver directions: students declare recvonly up front instead of waiting for remote tracks to appear. Stable SDP from the first offer means fewer renegotiations, and fewer chances for the negotiation state machine to wedge itself.

ICE, STUN, and the TURN bill you cannot avoid

WebRTC finds a path with ICE. STUN is cheap: the client asks a public server what its address looks like from outside, gets an answer, and offers that candidate. For most home networks this is enough and media flows directly.

Then there are the networks where it is not enough — symmetric NAT, corporate firewalls that only permit 443 outbound, carrier-grade NAT on mobile, university labs that block UDP wholesale. For those clients ICE falls back to TURN, and TURN is a relay: every byte of media passes through your server. STUN costs nothing measurable; TURN costs bandwidth at the same rate as the media itself. And in a classroom it is rarely one unlucky student — it is whichever share of the cohort sits behind a restrictive network that day, which for corporate training or a school lab can be all of them.

Two consequences worth planning for:

  • Offer turns: over TCP/443 alongside UDP/3478. If you only publish UDP you lose exactly the users who most need the fallback.
  • Never ship static TURN credentials in a frontend bundle. coturn's use-auth-secret mode gives you short-lived credentials derived from an HMAC, minted per session by the server that already authenticated the user.
import { createHmac } from 'node:crypto';

/**
 * coturn `use-auth-secret`: the username is an expiry timestamp,
 * the password is its HMAC. No shared secret ever reaches the client.
 */
function mintTurnCredentials(userId, ttlSeconds = 3600) {
  const username = `${Math.floor(Date.now() / 1000) + ttlSeconds}:${userId}`;

  const credential = createHmac('sha1', process.env.TURN_STATIC_SECRET)
    .update(username)
    .digest('base64');

  return { username, credential, ttl: ttlSeconds };
}

Reconnection is the feature, not the edge case

An hour-long class on a mobile network will have connection events. A student walks out of Wi-Fi range, a laptop sleeps, a carrier hands the session to another tower. Two independent things can break and each needs its own handling.

The signalling socket. Socket.io reconnects on its own, but reconnection is not resumption — application state can be stale by the time the socket is back. On reconnect, re-fetch the roster instead of trusting events you may have missed while offline.

The media connection. A peer connection whose ICE state reaches failed is not necessarily dead. It can re-gather candidates on the existing connection with restartIce(), which is far cheaper and faster than tearing everything down and rebuilding it.

pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === 'failed') {
    // Re-gather on the existing connection instead of rebuilding it.
    pc.restartIce();
  }
};

socket.io.on('reconnect', async () => {
  // The socket is back; the app's idea of the room may not be.
  const roster = await socket.emitWithAck('roster:get');
  reconcileRoster(roster);

  if (pc.connectionState !== 'connected') {
    pc.restartIce();
  }
});

Where the bottleneck actually sits

When a session degrades, the reflex is to add CPU to the signalling server. It is almost never the signalling server. In rough order of how often it is the real cause:

  • The publisher's uplink. One presenter on a bad connection degrades the room for everyone, because everyone is downstream of that single upstream. Highest-leverage thing to monitor, by a distance.
  • Server egress and packet rate. An SFU is a network appliance, not a compute appliance. It is bound by bits per second and, just as much, by packets per second — small audio packets at 50 per second per subscriber add up in interrupt handling long before they add up in bandwidth.
  • TURN relay volume. See above. This is the line item that surprises people on the invoice.
  • Client decode. Real in a gallery-view conference, mostly a non-issue in a classroom where a student decodes one or two streams.
  • The signalling tier. Last — and usually only because someone put a database write in the hot path of an ICE candidate relay. Keep signalling handlers free of synchronous I/O and this tier will out-scale everything else by an order of magnitude.

Instrument the media path, not the process. RTCPeerConnection.getStats() reports round-trip time, jitter, packets lost and the selected candidate pair for each client. Sampling that every few seconds and shipping it back over the signalling socket you already have is the cheapest observability you will ever add, and it tells you which of the five causes above you are actually looking at.

What 50+ actually means

To be precise about the claim in the first paragraph: 50+ concurrent users is what a single Testat live session sustained in production in 2023, with live streaming, on a Node.js and Socket.io signalling layer with an Angular client. It is a per-session figure that held under real use.

It is not a load-test ceiling, not a per-server room count, and not a promise about a different workload. The moment the shape of the session changes — every student on camera, recording switched on, breakout rooms — the arithmetic changes with it, and the honest answer becomes "measure it again".

Which is the real lesson. Real-time scale is not a tuning exercise you get to at the end; it is a topology decision you make at the start, and the arithmetic tells you the answer before you write a line of code.

Comments (0)

Login to comment

To post a comment, you must be logged in. Please login. Login