Web · How-to

How to broadcast live video to a room with Socket.io

DistressAlarmSystem started as a simple idea: when someone presses a panic button in the browser, people watching a shared room should see what their camera sees, right now, without installing anything. I built it with Node, Socket.io rooms and the browser's getUserMedia API. This post is a how-to for the core mechanism, which is broadcasting live video frames from one browser to everyone else in a room over a WebSocket.

A caveat before the code. Sending video frames over Socket.io is not how you would build a production video call. WebRTC does that with far better quality and latency. But WebRTC needs signalling, STUN and TURN servers, and peer-to-peer connection handling, which is a lot of machinery for a prototype. Pushing JPEG frames through a socket is trivially simple, works through any firewall your website works through, and is good enough for a low-frame-rate alarm feed. That trade-off is the point of this approach.

What you will end up with

Step 1: the server and rooms

Socket.io rooms are server-side groups of sockets. A socket joins a room by name, and emitting to a room sends to every socket in it. That is all the routing this system needs. The broadcaster and its viewers share a room id, and the server relays frames and alarms from one to the others without caring about the payload.

server.js
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");

const app = express();
const server = http.createServer(app);
const io = new Server(server, { maxHttpBufferSize: 2e6 }); // allow ~2 MB frames

app.use(express.static("public"));

io.on("connection", (socket) => {
  socket.on("join", ({ room, role }) => {
    socket.join(room);
    socket.data.room = room;
    socket.data.role = role;
    socket.to(room).emit("peer-joined", { id: socket.id, role });
  });

  // relay a frame from the broadcaster to everyone else in the room
  socket.on("frame", (dataUrl) => {
    if (socket.data.role !== "broadcaster") return;
    socket.to(socket.data.room).volatile.emit("frame", dataUrl);
  });

  socket.on("alarm", (info) => {
    io.to(socket.data.room).emit("alarm", { ...info, at: Date.now() });
  });

  socket.on("disconnect", () => {
    if (socket.data.room) socket.to(socket.data.room).emit("peer-left", socket.id);
  });
});

server.listen(3000, () => console.log("listening on :3000"));

Two details matter here. socket.to(room) emits to everyone in the room except the sender, so the broadcaster does not receive its own frames. And frames are sent with volatile, which tells Socket.io to drop the message if the client's connection is not ready instead of queueing it. For video you always want the newest frame, never a backlog of stale ones.

Step 2: the broadcaster

The broadcaster asks for the camera with getUserMedia, attaches the stream to a hidden <video> element, and then on a timer draws the current video frame onto a canvas and exports it as a JPEG data URL. That string is the payload. Resolution and JPEG quality are the two knobs that control bandwidth; a 320 by 240 frame at quality 0.5 is small enough to send several times a second over a normal connection.

public/broadcast.js
const socket = io();
const room = new URLSearchParams(location.search).get("room") || "default";
const video = document.getElementById("cam");
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const FPS = 5;

socket.emit("join", { room, role: "broadcaster" });

async function start() {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: { width: 320, height: 240, facingMode: "environment" },
    audio: false
  });
  video.srcObject = stream;
  await video.play();
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;

  setInterval(() => {
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    const frame = canvas.toDataURL("image/jpeg", 0.5);
    socket.volatile.emit("frame", frame);
  }, 1000 / FPS);
}

document.getElementById("panic").addEventListener("click", () => {
  navigator.geolocation.getCurrentPosition(
    (pos) => socket.emit("alarm", { lat: pos.coords.latitude, lng: pos.coords.longitude }),
    () => socket.emit("alarm", {})
  );
});

start().catch((err) => alert("Camera access failed: " + err.message));

getUserMedia only works on a secure origin, so you need HTTPS or localhost, and the browser will prompt for camera permission. On a phone, facingMode: "environment" asks for the rear camera, which is what you want for a distress feed. The panic button attaches the current location if the user allows it, and sends the alarm either way.

Step 3: the viewer

The viewer is the simplest part. Join the room as a viewer, and whenever a frame arrives, set it as the src of an image element. The browser decodes the JPEG and paints it. Because frames are complete images rather than a video stream, a viewer that joins late sees the next frame immediately with no keyframe wait.

public/view.js
const socket = io();
const room = new URLSearchParams(location.search).get("room") || "default";
const img = document.getElementById("feed");
const banner = document.getElementById("banner");
const siren = new Audio("/siren.mp3");

socket.emit("join", { room, role: "viewer" });

socket.on("frame", (dataUrl) => { img.src = dataUrl; });

socket.on("alarm", ({ lat, lng, at }) => {
  banner.hidden = false;
  banner.textContent = "ALARM at " + new Date(at).toLocaleTimeString()
    + (lat ? ` (${lat.toFixed(4)}, ${lng.toFixed(4)})` : "");
  siren.play().catch(() => {}); // autoplay may be blocked until the user interacts
});

socket.on("peer-left", () => { banner.textContent = "Broadcaster disconnected"; banner.hidden = false; });

Step 4: run it

  1. npm init -y && npm install express socket.io, then put server.js at the root and the HTML and scripts under public/.
  2. node server.js and open http://localhost:3000/broadcast.html?room=test in one tab. Allow the camera.
  3. Open http://localhost:3000/view.html?room=test in another tab or another device on the same network. Frames should appear within a second.
  4. Press the panic button on the broadcaster. Every viewer in the room should show the banner.

To test from a phone you need HTTPS. The quickest route during development is a tunnelling tool that gives you a temporary HTTPS URL pointing at port 3000.

Tuning and limits

Bandwidth per viewer is roughly frame size times frames per second, and the server relays a copy to each viewer, so cost scales with viewers times frame rate. Lower the resolution or the JPEG quality before you lower the frame rate; a blurry feed at 5 fps reads as live, a sharp one at 1 fps reads as a slideshow. If you need many viewers, put a cap on the room, or move to WebRTC with an SFU, which is the right architecture at that scale.

Sending a base64 data URL is about a third larger than sending the raw bytes. Socket.io supports binary payloads, so switching to canvas.toBlob and emitting the ArrayBuffer is a straightforward optimisation once the basic flow works.

One more practical note: a room id in the URL is a shared secret, not authentication. For the prototype that was acceptable, because the ids were long random strings handed out by the alarm owner. For anything real, issue short-lived tokens on join and have the server check them before calling socket.join.

What I learned

Source on GitHub >