Build a Livestream App with WebRTC
Add real-time video streaming and chat to your Express app using WebSocket and WebRTC.
Introduction
30 Second Summary
Every time you watch a live stream and type in the chat, two different real-time technologies are working together. One handles the instant text messages flying between everyone in the room. The other pipes live video directly from the broadcaster's camera to your screen.
In this project, you will build a full livestream application with real-time chat. You'll use WebSocket to handle chat messages and user presence, and WebRTC to stream live video from a broadcaster to multiple viewers.
What You'll Build
A livestream app where one user broadcasts their camera and microphone, and all other connected users can watch the stream and chat in real time.
By the end of this project, you'll have:
- A WebSocket server integrated into your existing Express app that authenticates connections using your JWT tokens.
- A real-time chat system where all connected users see messages, join notifications, and viewer counts instantly.
- A WebRTC video stream from one broadcaster to all viewers, with WebSocket handling the signaling to establish peer connections.
- Secret Mission: Add screen sharing so the broadcaster can switch between camera and screen mid-stream without disconnecting viewers.
Are there any prerequisites?
This project builds on an existing Express app with MongoDB and JWT authentication already set up. You should have a working backend with user registration and login before starting.
Set Up the Project
Your existing Express app handles HTTP requests, authentication, and database queries. To add real-time features like live chat and video streaming, you need a WebSocket server running alongside Express on the same port.
In this step, you'll install the WebSocket library, create a folder for your frontend files, and restructure your server startup so Express and WebSocket can share the same HTTP connection.
In this step, get ready to:
- Install the ws WebSocket library.
- Create a public/ directory for frontend files.
- Restructure server.js to support WebSocket integration.
Install the ws package
The ws library is a lightweight WebSocket implementation for Node.js. It has zero dependencies and gives you direct control over the WebSocket protocol.
- Install ws at the pinned version by running this command in your project's root folder:
npm install ws@8.21.0
Why ws instead of Socket.IO?
The ws library implements the raw WebSocket protocol. Socket.IO adds an abstraction layer that hides connection lifecycle, message framing, and heartbeat mechanics. Since this project teaches how WebSocket actually works, ws is the right choice.
- You should see output similar to added 1 package confirming the install succeeded.
Install failed or timed out?
Make sure you're running the command from your project's root folder (the one that contains package.json). If you see a network error, check your internet connection and try again.
Help me debug my npm install error.
Create the public/ directory
Your livestream frontend needs a place to live. Express can serve static files (HTML, CSS, JavaScript) from a designated folder. You'll create that folder now and wire it up in the next substep.
- Create the public directory by running:
mkdir public
- Confirm the folder exists by running:
ls
- You should see public listed alongside your existing project files like package.json and server.js.
Restructure server.js for WebSocket integration
Express normally creates its own HTTP server behind the scenes when you call app.listen(). To attach a WebSocket server, you need access to that HTTP server directly. The fix is to create it yourself with http.createServer(app) and pass it to both Express and WebSocket.
- Open server.js in your editor.
- Replace the contents with the following structure, keeping your existing middleware and routes where the comments indicate:
const express = require('express');
const http = require('http');
const path = require('path');
const { setupWebSocket } = require('./websocket');
const app = express();
// Your existing middleware and routes here
// app.use(express.json());
// app.use('/api/auth', authRoutes);
// etc.
// Serve static frontend files
app.use(express.static(path.join(__dirname, 'public')));
// Create HTTP server from Express app
const server = http.createServer(app);
// Attach WebSocket server
setupWebSocket(server);
// Use server.listen instead of app.listen
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`WebSocket available at ws://localhost:${PORT}/ws`);
});
What does this code do?
The top four lines import everything the server needs. http is a built-in Node.js module for creating HTTP servers. path safely joins file paths across operating systems. setupWebSocket is the function you'll build in the next step.
The express.static middleware tells Express to serve any file inside public/ when a browser requests it. This is where your livestream HTML, CSS, and JavaScript will live.
The critical change is http.createServer(app). This gives you the raw HTTP server object so you can share it with the WebSocket server. Both Express and WebSocket now listen on the same port.
- Keep your existing middleware (express.json(), your auth routes, your MongoDB connection) in place of the commented placeholders.
- Save server.js.
You won't be able to start the server yet. The require('./websocket') line will fail because that file doesn't exist. You'll create it in the next step.
Seeing a MODULE_NOT_FOUND error?
If you tried to run the server, that error is expected. The ./websocket module doesn't exist yet. You'll create websocket.js in Step 2.
If you see a different error, double-check that your import lines match the code above exactly.
Help me fix my server.js error.
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js and that the public/ folder exists in your project root.
ⓧ I'd like to double check the full code
Here is the full server.js at this point:
const express = require('express');
const http = require('http');
const path = require('path');
const { setupWebSocket } = require('./websocket');
const app = express();
// Your existing middleware and routes here
// app.use(express.json());
// app.use('/api/auth', authRoutes);
// etc.
// Serve static frontend files
app.use(express.static(path.join(__dirname, 'public')));
// Create HTTP server from Express app
const server = http.createServer(app);
// Attach WebSocket server
setupWebSocket(server);
// Use server.listen instead of app.listen
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`WebSocket available at ws://localhost:${PORT}/ws`);
});
Your project is wired up and ready for real-time communication. Next up, you'll create websocket.js and build a WebSocket server that authenticates connections using your existing JWT tokens.
Build the WebSocket Server with JWT Auth
Your Express server is running and ready for real-time connections. The next piece is a WebSocket server that clients can connect to for instant, two-way communication.
Unlike regular HTTP requests (which are one-and-done), WebSocket connections stay open. This means the server needs to verify who's connecting before it trusts any messages. You'll reuse the JWT authentication you already have, so only signed-in users can join.
In this step, get ready to:
- Create a WebSocket server module attached to your Express app.
- Authenticate WebSocket connections using your existing JWT tokens.
- Add a heartbeat system to clean up dead connections automatically.
Create the WebSocket server module
The ws library provides WebSocketServer, which attaches to your existing HTTP server so both Express routes and WebSocket connections share the same port.
- Create a new file called websocket.js in the same folder as your server.js.
- Add the following code to set up the WebSocket server with its initial structure:
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
function setupWebSocket(server) {
const wss = new WebSocketServer({ server, path: '/ws' });
let broadcaster = null;
const clients = new Set();
wss.on('connection', (ws, req) => {
// Authentication will go here
clients.add(ws);
});
return wss;
}
// Send a message to all connected clients
function broadcast(wss, data, exclude = null) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client !== exclude && client.readyState === 1) {
client.send(message);
}
});
}
// Send a message to a specific user by their ID
function sendToUser(wss, userId, data) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client.userId === userId && client.readyState === 1) {
client.send(message);
}
});
}
module.exports = { setupWebSocket };
What does this code do?
- new WebSocketServer({ server, path: '/ws' }) attaches the WebSocket server to your Express HTTP server, but only accepts connections on the /ws path. This keeps WebSocket traffic separate from your regular API routes.
- broadcaster tracks which client is currently streaming video. clients is a Set of all connected WebSocket objects.
- broadcast() sends a message to every connected client (optionally excluding one). readyState === 1 checks the connection is open before sending.
- sendToUser() targets a specific user by matching client.userId against the provided ID.
- Save websocket.js.
- Restart your server by running:
node server.js
- You should see both Server running on port 3000 and WebSocket available at ws://localhost:3000/ws in your terminal output.
Server won't start?
- If you see Cannot find module './websocket', make sure websocket.js is in the same directory as server.js.
- If you see setupWebSocket is not a function, check that your module.exports line matches exactly: module.exports = { setupWebSocket };.
Still stuck? Help me debug why my WebSocket server won't start alongside Express.
Add JWT authentication to the connection handler
Right now, anyone can connect to your WebSocket server. You need to verify the JWT token before accepting messages from a client.
- In websocket.js, find the wss.on('connection') handler. Find this line:
// Authentication will go here
clients.add(ws);
- Replace those two lines with the following authentication code:
// Extract token from the WebSocket URL query parameter
const url = new URL(req.url, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
// Reject connections with no token
if (!token) {
ws.close(4001, 'Authentication required');
return;
}
// Verify the JWT token using your existing secret
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
ws.userId = decoded.userId;
ws.username = decoded.username;
} catch (err) {
ws.close(4001, 'Invalid token');
return;
}
clients.add(ws);
How does WebSocket authentication work?
WebSocket connections cannot use HTTP headers the same way REST APIs do. The standard pattern is to pass the JWT token as a query parameter in the connection URL.
The server extracts the token from ?token=xxx in the URL, verifies it with jwt.verify(), then stores the decoded userId and username on the WebSocket object itself. Close code 4001 is in the private-use range (4000-4999), reserved for application-specific errors.
- Save websocket.js.
- Restart your server to confirm it starts without errors.
Seeing a syntax error on restart?
- Check that the clients.add(ws) line is still inside the connection handler (after the try/catch block). It should be the last line before the closing }); of the handler.
- Make sure you removed the comment placeholder line entirely. Leaving a duplicate clients.add(ws) won't crash, but it adds the client before authentication finishes.
Still stuck? Help me fix my JWT authentication code inside the WebSocket connection handler.
Add a heartbeat mechanism to detect stale connections
WebSocket connections can silently die (network drops, laptop sleeps, browser crashes). Without a heartbeat, your server accumulates ghost connections that waste memory and break broadcast logic.
- In websocket.js, find this line inside your setupWebSocket function:
const clients = new Set();
- Directly below it, add the heartbeat interval:
// Heartbeat to detect stale connections
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(interval));
- Now find the clients.add(ws) line inside your connection handler. Add these two lines directly above it:
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
How does the heartbeat work?
Every 30 seconds, the server pings each client and marks it as isAlive = false. If the client responds with a pong before the next cycle, it gets marked as alive again. If it never responds, the server terminates the dead connection.
The wss.on('close') handler clears the interval when the WebSocket server shuts down, preventing a memory leak.
- Save websocket.js and restart your server.
- You should see the same two startup messages with no errors. The heartbeat runs silently in the background.
Server crashes after adding the heartbeat?
- Make sure the setInterval block is between const clients = new Set() and wss.on('connection'). Placing it inside the connection handler creates a new interval per client.
- Check that ws.isAlive = true and the pong handler are inside the wss.on('connection') callback, right before clients.add(ws).
Still stuck? Help me debug the heartbeat mechanism in my WebSocket server.
✔️ Awesome, I've got everything!
Great. Double check you've saved websocket.js and your server restarts cleanly.
ⓧ I'd like to double check the full code
Here's the complete websocket.js at this point:
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
function setupWebSocket(server) {
const wss = new WebSocketServer({ server, path: '/ws' });
let broadcaster = null;
const clients = new Set();
// Heartbeat to detect stale connections
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(interval));
wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
ws.close(4001, 'Authentication required');
return;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
ws.userId = decoded.userId;
ws.username = decoded.username;
} catch (err) {
ws.close(4001, 'Invalid token');
return;
}
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
clients.add(ws);
});
return wss;
}
function broadcast(wss, data, exclude = null) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client !== exclude && client.readyState === 1) {
client.send(message);
}
});
}
function sendToUser(wss, userId, data) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client.userId === userId && client.readyState === 1) {
client.send(message);
}
});
}
module.exports = { setupWebSocket };
And your server.js should still look like this (unchanged from the previous step):
const express = require('express');
const http = require('http');
const path = require('path');
const { setupWebSocket } = require('./websocket');
const app = express();
// Your existing middleware and routes here
// app.use(express.json());
// app.use('/api/auth', authRoutes);
// etc.
// Serve static frontend files
app.use(express.static(path.join(__dirname, 'public')));
// Create HTTP server from Express app
const server = http.createServer(app);
// Attach WebSocket server
setupWebSocket(server);
// Use server.listen instead of app.listen
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`WebSocket available at ws://localhost:${PORT}/ws`);
});
Your WebSocket server is secure and self-maintaining. Next up, you'll make it do something useful: broadcast chat messages to everyone in real time.
Add Real-Time Chat Broadcasting
Your WebSocket server authenticates users and detects stale connections, but connected clients can't actually communicate with each other yet.
In this step, you'll wire up chat broadcasting, join/leave notifications, and a live viewer count. Every connected client will see messages the instant they're sent.
In this step, get ready to:
- Handle incoming chat messages and broadcast them to all connected clients.
- Send user-joined and user-left notifications when clients connect and disconnect.
- Send the current viewer count whenever someone joins or leaves.
Broadcast join notifications and send the welcome message
When a new user connects, two things need to happen. Everyone else should be told someone joined, and the new user needs to know the current state of the stream.
- In websocket.js, find the line that says clients.add(ws); inside the wss.on('connection', ...) handler.
- Add the following code directly below that line:
// Notify others that a new user joined
broadcast(wss, {
type: 'user-joined',
username: ws.username,
viewerCount: clients.size
}, ws);
// Send current state to the new client
ws.send(JSON.stringify({
type: 'welcome',
viewerCount: clients.size,
broadcasterActive: broadcaster !== null
}));
What does this code do?
The broadcast() call sends a user-joined message to every connected client except the one who just joined. The third argument (ws) excludes the new user from receiving their own join notification.
The welcome message is sent only to the new client. It tells them how many people are currently watching and whether someone is broadcasting video.
You won't see a change yet. The next chunk adds the message handler that processes chat input from connected clients.
Seeing a syntax error?
Make sure the new code sits inside the wss.on('connection', (ws, req) => { ... }) block, right after clients.add(ws);. If you accidentally paste it outside the connection handler, the ws variable won't be in scope.
Help me fix a scope error in my WebSocket connection handler
Handle incoming chat messages
Connected clients need a way to send chat messages that get relayed to everyone else. The server will parse each incoming message, attach the sender's username and a timestamp, then broadcast it.
- Still inside the wss.on('connection', ...) handler, add the following code below the welcome message you just wrote:
ws.on('message', (data) => {
let message;
try {
message = JSON.parse(data);
} catch (err) {
return;
}
switch (message.type) {
case 'chat':
broadcast(wss, {
type: 'chat',
username: ws.username,
text: message.text,
timestamp: Date.now()
});
break;
}
});
What does this code do?
The ws.on('message', ...) listener fires every time this client sends a message. The try/catch around JSON.parse protects the server from crashing if a client sends malformed data.
The switch statement routes messages by their type field. For now it only handles chat, but you'll add more cases in the next step for WebRTC signaling.
Why a switch statement instead of if/else?
A switch makes it easy to add new message types later. You'll add start-broadcast, offer, answer, and ice-candidate cases in the next step without restructuring the handler.
You won't see a change yet. The next chunk adds disconnect handling so the viewer count stays accurate.
Not sure where to place this code?
The ws.on('message', ...) block goes after the ws.send(JSON.stringify({ type: 'welcome', ... })) call from the previous chunk. It must still be inside the wss.on('connection', ...) callback.
Help me figure out where my message handler goes
Send leave notifications on disconnect
When a user closes their browser tab or loses network, the server needs to clean up their connection. It also needs to tell everyone else that the viewer count dropped.
- Add the following code directly below the ws.on('message', ...) block you just wrote (still inside the connection handler):
ws.on('close', () => {
clients.delete(ws);
if (ws === broadcaster) {
broadcaster = null;
broadcast(wss, { type: 'broadcast-ended' });
}
broadcast(wss, {
type: 'user-left',
username: ws.username,
viewerCount: clients.size
});
});
What does this code do?
When a client disconnects, the handler removes them from the clients Set so the viewer count stays accurate. If the disconnected user was the broadcaster, it resets that variable and notifies everyone the stream ended.
Finally, it broadcasts a user-left message with the updated viewerCount. The frontend will use this to keep the viewer counter in sync.
- Save websocket.js.
- Start your server by running:
node server.js
- You should see Server running on port 3000 and WebSocket available at ws://localhost:3000/ws with no errors.
Seeing an error when you start the server?
Check that every opening brace { has a matching closing brace }. The three new blocks (broadcast + welcome, ws.on('message'), and ws.on('close')) must all be inside the wss.on('connection', ...) callback.
If you see EADDRINUSE, another process is already using port 3000. Stop it first, then try again.
Help me debug my WebSocket server startup error
✔️ Awesome, I've got everything!
Great. Double check you've saved websocket.js and confirmed the server starts without errors.
ⓧ I'd like to double check the full code
Here's the complete websocket.js file at this point:
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
function setupWebSocket(server) {
const wss = new WebSocketServer({ server, path: '/ws' });
let broadcaster = null;
const clients = new Set();
// Heartbeat to detect stale connections
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(interval));
wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
ws.close(4001, 'Authentication required');
return;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
ws.userId = decoded.userId;
ws.username = decoded.username;
} catch (err) {
ws.close(4001, 'Invalid token');
return;
}
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
clients.add(ws);
// Notify others that a new user joined
broadcast(wss, {
type: 'user-joined',
username: ws.username,
viewerCount: clients.size
}, ws);
// Send current state to the new client
ws.send(JSON.stringify({
type: 'welcome',
viewerCount: clients.size,
broadcasterActive: broadcaster !== null
}));
ws.on('message', (data) => {
let message;
try {
message = JSON.parse(data);
} catch (err) {
return;
}
switch (message.type) {
case 'chat':
broadcast(wss, {
type: 'chat',
username: ws.username,
text: message.text,
timestamp: Date.now()
});
break;
}
});
ws.on('close', () => {
clients.delete(ws);
if (ws === broadcaster) {
broadcaster = null;
broadcast(wss, { type: 'broadcast-ended' });
}
broadcast(wss, {
type: 'user-left',
username: ws.username,
viewerCount: clients.size
});
});
});
return wss;
}
function broadcast(wss, data, exclude = null) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client !== exclude && client.readyState === 1) {
client.send(message);
}
});
}
function sendToUser(wss, userId, data) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client.userId === userId && client.readyState === 1) {
client.send(message);
}
});
}
module.exports = { setupWebSocket };
Your chat backbone is live. Every connected client gets join/leave updates and can broadcast messages to the room. Next up, you'll add WebRTC signaling so the server can coordinate video streaming between a broadcaster and viewers.
Handle WebRTC Video Signaling
Your chat system is live. Every connected user can send and receive messages in real time. Now it's time to add the feature that makes this a livestream app: video.
For video to flow between a broadcaster and viewers, their browsers need to establish direct peer-to-peer connections using WebRTC. Your WebSocket server acts as the middleman during setup, relaying the handshake messages (offers, answers, and ICE candidates) that let two browsers find each other.
In this step, get ready to:
- Handle broadcast start/stop messages so one user can claim the broadcaster role.
- Relay WebRTC offer and answer messages between broadcaster and viewers.
- Route ICE candidate messages to establish the peer connection.
Handle broadcast lifecycle messages
Before any video can flow, the server needs to know who the broadcaster is. When a user clicks "Start Broadcasting" on the frontend, they'll send a start-broadcast message. The server registers them and tells everyone else a stream is available.
- In websocket.js, find the switch (message.type) block inside ws.on('message').
- Add the following cases after the case 'chat': block:
case 'start-broadcast':
broadcaster = ws;
broadcast(wss, {
type: 'broadcast-started',
username: ws.username
}, ws);
break;
case 'stop-broadcast':
broadcaster = null;
broadcast(wss, { type: 'broadcast-ended' });
break;
case 'viewer-request':
if (broadcaster && broadcaster.readyState === 1) {
broadcaster.send(JSON.stringify({
type: 'viewer-request',
viewerId: ws.userId
}));
}
break;
What does this code do?
These three cases manage the broadcast lifecycle:
- start-broadcast stores a reference to the broadcaster's WebSocket connection. It then notifies all other clients that a stream is available.
- stop-broadcast clears the broadcaster reference and tells everyone the stream has ended.
- viewer-request is sent by a viewer who wants to watch. The server forwards this request to the broadcaster so it knows to create a new peer connection for that viewer.
- Save websocket.js.
You won't see a change yet. The next chunk adds the offer/answer relay that actually kicks off the video connection.
Relay offer and answer messages
When the broadcaster receives a viewer request, it creates a WebRTC "offer" describing what media it can send. The viewer responds with an "answer" accepting the connection. Your server relays both messages between them.
- In websocket.js, add the following cases after the case 'viewer-request': block you just added:
case 'offer':
sendToUser(wss, message.viewerId, {
type: 'offer',
offer: message.offer
});
break;
case 'answer':
if (broadcaster && broadcaster.readyState === 1) {
broadcaster.send(JSON.stringify({
type: 'answer',
viewerId: ws.userId,
answer: message.answer
}));
}
break;
What does this code do?
- offer comes from the broadcaster. The server uses sendToUser() to forward it to the specific viewer identified by message.viewerId.
- answer comes from the viewer. The server sends it back to the broadcaster, including the viewer's ID so the broadcaster knows which peer connection to finalize.
This exchange is called SDP signaling. SDP (Session Description Protocol) describes the media capabilities of each peer. Once both sides have each other's SDP, they can attempt a direct connection.
- Save websocket.js.
You won't see a change yet. The final piece is ICE candidate relay, which helps peers find the network path to each other.
Relay ICE candidate messages
Even after exchanging offers and answers, browsers still need to figure out the actual network route between them. ICE (Interactive Connectivity Establishment) candidates represent possible routes. Each peer discovers candidates and sends them to the other side through your signaling server.
- In websocket.js, add the following case after the case 'answer': block:
case 'ice-candidate':
if (message.target === 'broadcaster') {
if (broadcaster && broadcaster.readyState === 1) {
broadcaster.send(JSON.stringify({
type: 'ice-candidate',
candidate: message.candidate,
viewerId: ws.userId
}));
}
} else {
sendToUser(wss, message.target, {
type: 'ice-candidate',
candidate: message.candidate
});
}
break;
What does this code do?
ICE candidates flow in both directions, so this case checks message.target to decide where to route:
- If the target is 'broadcaster', the candidate came from a viewer. The server forwards it to the broadcaster along with the viewer's ID.
- Otherwise, the candidate came from the broadcaster. The server uses sendToUser() to forward it to the specific viewer.
Once enough ICE candidates are exchanged, the two browsers establish a direct media connection. Video and audio then flow peer-to-peer without passing through your server.
- Save websocket.js.
- Start your server by running:
node server.js
- You should see Server running on port 3000 and WebSocket available at ws://localhost:3000/ws in your terminal. This confirms the signaling server starts without errors.
Seeing a syntax error?
- Make sure each case block ends with break;. A missing break causes fall-through to the next case.
- Check that your new cases are inside the switch (message.type) block's curly braces, not outside them.
- Verify that all JSON.stringify() calls have matching parentheses and curly braces.
My server won't start after adding WebRTC signaling cases. Help me debug the syntax.
Why use WebSocket for signaling instead of HTTP?
WebRTC signaling requires messages to arrive in real time and in both directions. HTTP would force you to poll the server or use long-polling hacks. Since you already have a WebSocket connection open for chat, reusing it for signaling costs nothing extra and keeps latency minimal.
✔️ Awesome, I've got everything!
Great. Double check you've saved websocket.js and that your server starts without errors.
ⓧ I'd like to double check the full code
Here is the full websocket.js file with all signaling handlers:
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
function setupWebSocket(server) {
const wss = new WebSocketServer({ server, path: '/ws' });
let broadcaster = null;
const clients = new Set();
// Heartbeat to detect stale connections
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(interval));
wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
ws.close(4001, 'Authentication required');
return;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
ws.userId = decoded.userId;
ws.username = decoded.username;
} catch (err) {
ws.close(4001, 'Invalid token');
return;
}
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
clients.add(ws);
// Notify others that a new user joined
broadcast(wss, {
type: 'user-joined',
username: ws.username,
viewerCount: clients.size
}, ws);
// Send current state to the new client
ws.send(JSON.stringify({
type: 'welcome',
viewerCount: clients.size,
broadcasterActive: broadcaster !== null
}));
ws.on('message', (data) => {
let message;
try {
message = JSON.parse(data);
} catch (err) {
return;
}
switch (message.type) {
case 'chat':
broadcast(wss, {
type: 'chat',
username: ws.username,
text: message.text,
timestamp: Date.now()
});
break;
case 'start-broadcast':
broadcaster = ws;
broadcast(wss, {
type: 'broadcast-started',
username: ws.username
}, ws);
break;
case 'stop-broadcast':
broadcaster = null;
broadcast(wss, { type: 'broadcast-ended' });
break;
case 'viewer-request':
if (broadcaster && broadcaster.readyState === 1) {
broadcaster.send(JSON.stringify({
type: 'viewer-request',
viewerId: ws.userId
}));
}
break;
case 'offer':
sendToUser(wss, message.viewerId, {
type: 'offer',
offer: message.offer
});
break;
case 'answer':
if (broadcaster && broadcaster.readyState === 1) {
broadcaster.send(JSON.stringify({
type: 'answer',
viewerId: ws.userId,
answer: message.answer
}));
}
break;
case 'ice-candidate':
if (message.target === 'broadcaster') {
if (broadcaster && broadcaster.readyState === 1) {
broadcaster.send(JSON.stringify({
type: 'ice-candidate',
candidate: message.candidate,
viewerId: ws.userId
}));
}
} else {
sendToUser(wss, message.target, {
type: 'ice-candidate',
candidate: message.candidate
});
}
break;
}
});
ws.on('close', () => {
clients.delete(ws);
if (ws === broadcaster) {
broadcaster = null;
broadcast(wss, { type: 'broadcast-ended' });
}
broadcast(wss, {
type: 'user-left',
username: ws.username,
viewerCount: clients.size
});
});
});
return wss;
}
function broadcast(wss, data, exclude = null) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client !== exclude && client.readyState === 1) {
client.send(message);
}
});
}
function sendToUser(wss, userId, data) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client.userId === userId && client.readyState === 1) {
client.send(message);
}
});
}
module.exports = { setupWebSocket };
Your signaling server is complete. It can manage broadcaster registration, relay SDP offers and answers, and route ICE candidates. Next up, you'll build the frontend that puts all of this into action with a video player and live chat panel.
Build the Frontend
Your backend is fully wired up. The WebSocket server authenticates users, broadcasts chat messages, and relays all the WebRTC signaling needed for video connections.
Now you need the user-facing piece: an HTML page with a video player, chat panel, and the JavaScript that ties it all together. In this step, you'll create three files in your public/ directory that turn your backend into a usable livestream app.
In this step, get ready to:
- Create the HTML page with video elements, broadcast controls, and a chat panel.
- Write the JavaScript client that handles WebSocket messaging and WebRTC peer connections.
- Style the layout with a dark theme, video section on the left, and chat on the right.
Create the HTML page structure
The HTML page defines two main areas: a video section for the livestream, and a chat section for real-time messages.
- Create a new file called livestream.html inside your public/ folder, then paste this code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Livestream</title>
<link rel="stylesheet" href="livestream.css">
</head>
<body>
<div class="container">
<div class="video-section">
<div class="video-wrapper">
<!-- Local video shows broadcaster's own camera -->
<video id="localVideo" autoplay muted playsinline></video>
<!-- Remote video shows the broadcaster's stream for viewers -->
<video id="remoteVideo" autoplay playsinline></video>
</div>
<div class="controls">
<button id="startBroadcastBtn">Start Broadcasting</button>
<button id="stopBroadcastBtn" style="display:none;">Stop Broadcasting</button>
<button id="joinStreamBtn" style="display:none;">Watch Stream</button>
<span id="viewerCount">0 viewers</span>
</div>
<div id="streamStatus" class="status">Not connected</div>
</div>
<div class="chat-section">
<div class="chat-header">
<h3>Live Chat</h3>
</div>
<div id="chatMessages" class="chat-messages"></div>
<div class="chat-input">
<input type="text" id="chatInput" placeholder="Type a message..." autocomplete="off">
<button id="sendBtn">Send</button>
</div>
</div>
</div>
<script src="livestream.js"></script>
</body>
</html>
What does this page structure do?
- Two <video> elements handle both roles: localVideo shows the broadcaster their own camera feed, while remoteVideo shows the stream to viewers.
- The muted attribute on localVideo prevents audio feedback from your own microphone.
- The broadcast controls start hidden (style="display:none;") and appear dynamically based on the stream state.
- The chat panel at the bottom has an input field and send button, with a scrollable message area above.
- Save public/livestream.html.
- Start your server by running:
node server.js
- Open your browser and navigate to http://localhost:3000/livestream.html. You should see unstyled buttons (Start Broadcasting, 0 viewers), a text input, and a Send button.
Seeing a blank page or 404?
- Make sure the file is saved inside the public/ folder, not in the project root.
- Check that your server.js has the express.static middleware pointing to public.
- Confirm the server is running (you should see the console message).
Why isn't my livestream.html page loading?
Write the JavaScript client
The JavaScript file is the brain of the frontend. It handles WebSocket communication, chat messaging, and all the WebRTC logic for broadcasting and viewing video.
- Create a new file called livestream.js inside your public/ folder. Start by pasting the initialization and WebSocket message handler:
const token = prompt('Enter your JWT token:');
if (!token) {
document.body.innerHTML = '<p style="padding:20px;">Token required to join.</p>';
throw new Error('No token');
}
const wsUrl = `ws://${window.location.host}/ws?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
// Grab references to all DOM elements
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const startBroadcastBtn = document.getElementById('startBroadcastBtn');
const stopBroadcastBtn = document.getElementById('stopBroadcastBtn');
const joinStreamBtn = document.getElementById('joinStreamBtn');
const viewerCount = document.getElementById('viewerCount');
const streamStatus = document.getElementById('streamStatus');
const chatMessages = document.getElementById('chatMessages');
const chatInput = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
// WebRTC configuration with a public STUN server
const rtcConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
// State variables for tracking broadcast and connections
let localStream = null;
let peerConnections = {};
let viewerPeerConnection = null;
let isBroadcaster = false;
// WebSocket handlers
ws.onopen = () => {
streamStatus.textContent = 'Connected';
};
ws.onclose = () => {
streamStatus.textContent = 'Disconnected';
};
ws.onerror = () => {
streamStatus.textContent = 'Connection error';
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'welcome':
viewerCount.textContent = `${message.viewerCount} viewers`;
if (message.broadcasterActive) {
joinStreamBtn.style.display = 'inline-block';
}
break;
case 'chat':
addChatMessage(message.username, message.text);
break;
case 'user-joined':
addSystemMessage(`${message.username} joined`);
viewerCount.textContent = `${message.viewerCount} viewers`;
break;
case 'user-left':
addSystemMessage(`${message.username} left`);
viewerCount.textContent = `${message.viewerCount} viewers`;
break;
case 'broadcast-started':
addSystemMessage(`${message.username} started streaming`);
if (!isBroadcaster) {
joinStreamBtn.style.display = 'inline-block';
}
break;
case 'broadcast-ended':
addSystemMessage('Stream ended');
joinStreamBtn.style.display = 'none';
remoteVideo.style.display = 'none';
if (viewerPeerConnection) {
viewerPeerConnection.close();
viewerPeerConnection = null;
}
break;
case 'viewer-request':
handleViewerRequest(message.viewerId);
break;
case 'offer':
handleOffer(message.offer);
break;
case 'answer':
handleAnswer(message.viewerId, message.answer);
break;
case 'ice-candidate':
handleIceCandidate(message);
break;
}
};
What does this code do?
- The prompt() asks the user for their JWT token on page load. The token is passed as a query parameter in the WebSocket URL so the server can authenticate the connection.
- The rtcConfig object points to Google's public STUN server, which helps peers discover their public IP address for establishing direct video connections.
- The onmessage handler routes every incoming message to the right behavior based on its type field. It mirrors the message types your backend broadcasts.
You won't see a change yet. The next chunk adds the chat and WebRTC functions that the message handler calls.
- Below the code you just added in livestream.js, paste the chat functions:
// Chat functions
function addChatMessage(username, text) {
const div = document.createElement('div');
div.className = 'message';
div.innerHTML = `<span class="author">${escapeHtml(username)}</span><div class="text">${escapeHtml(text)}</div>`;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addSystemMessage(text) {
const div = document.createElement('div');
div.className = 'system-message';
div.textContent = text;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function sendChat() {
const text = chatInput.value.trim();
if (!text) return;
ws.send(JSON.stringify({ type: 'chat', text }));
chatInput.value = '';
}
sendBtn.addEventListener('click', sendChat);
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendChat();
});
What do these chat functions do?
- The escapeHtml() function prevents cross-site scripting (XSS) by converting user input to safe text before inserting it into the DOM.
- The addChatMessage() function creates styled message elements, while addSystemMessage() handles join/leave notifications with italic styling.
- The scrollTop = scrollHeight line auto-scrolls the chat panel so the newest message is always visible.
- Save public/livestream.js.
- Refresh your browser at http://localhost:3000/livestream.html. A prompt will ask for your JWT token.
- Enter a valid token from your existing auth system. You should see the status change to "Connected".
- Type a message in the chat input and click Send. Your message should appear in the chat panel.
How do I get a JWT token?
Use your existing auth endpoint (e.g. POST to your login route) to get a token. The token must contain a userId and username in its payload, since the WebSocket server reads those fields during verification.
Status shows Disconnected immediately?
- Make sure your server is still running. If you stopped it, restart with node server.js.
- Confirm the token is valid and not expired. The server closes the connection with code 4001 if auth fails.
- Check that your JWT_SECRET environment variable is set when running the server.
Help me debug my WebSocket connection closing immediately.
- Below the chat functions in livestream.js, paste the WebRTC broadcaster and viewer functions:
// Broadcaster functions
startBroadcastBtn.addEventListener('click', async () => {
try {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
localVideo.srcObject = localStream;
localVideo.style.display = 'block';
isBroadcaster = true;
startBroadcastBtn.style.display = 'none';
stopBroadcastBtn.style.display = 'inline-block';
streamStatus.textContent = 'Broadcasting';
ws.send(JSON.stringify({ type: 'start-broadcast' }));
} catch (err) {
streamStatus.textContent = 'Camera access denied';
}
});
stopBroadcastBtn.addEventListener('click', () => {
if (localStream) {
localStream.getTracks().forEach((track) => track.stop());
localStream = null;
}
localVideo.style.display = 'none';
Object.values(peerConnections).forEach((pc) => pc.close());
peerConnections = {};
isBroadcaster = false;
stopBroadcastBtn.style.display = 'none';
startBroadcastBtn.style.display = 'inline-block';
streamStatus.textContent = 'Connected';
ws.send(JSON.stringify({ type: 'stop-broadcast' }));
});
// Broadcaster: handle new viewer
async function handleViewerRequest(viewerId) {
const pc = new RTCPeerConnection(rtcConfig);
peerConnections[viewerId] = pc;
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'ice-candidate',
target: viewerId,
candidate: event.candidate
}));
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({
type: 'offer',
viewerId,
offer: pc.localDescription
}));
}
// Broadcaster: handle answer from viewer
async function handleAnswer(viewerId, answer) {
const pc = peerConnections[viewerId];
if (pc) {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
}
// Viewer functions
joinStreamBtn.addEventListener('click', () => {
ws.send(JSON.stringify({ type: 'viewer-request' }));
joinStreamBtn.style.display = 'none';
streamStatus.textContent = 'Connecting to stream...';
});
async function handleOffer(offer) {
viewerPeerConnection = new RTCPeerConnection(rtcConfig);
viewerPeerConnection.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
remoteVideo.style.display = 'block';
streamStatus.textContent = 'Watching stream';
};
viewerPeerConnection.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'ice-candidate',
target: 'broadcaster',
candidate: event.candidate
}));
}
};
await viewerPeerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await viewerPeerConnection.createAnswer();
await viewerPeerConnection.setLocalDescription(answer);
ws.send(JSON.stringify({
type: 'answer',
answer: viewerPeerConnection.localDescription
}));
}
// Handle ICE candidates
function handleIceCandidate(message) {
if (isBroadcaster) {
const pc = peerConnections[message.viewerId];
if (pc) {
pc.addIceCandidate(new RTCIceCandidate(message.candidate));
}
} else if (viewerPeerConnection) {
viewerPeerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
}
}
How does WebRTC streaming work here?
The broadcaster's flow: clicking Start Broadcasting grabs the camera/mic with getUserMedia(), then notifies the server. When a viewer connects, handleViewerRequest() creates a new peer connection, attaches the media tracks, and sends an offer through the WebSocket.
The viewer's flow: clicking Watch Stream sends a request to the server, which forwards it to the broadcaster. The viewer receives an offer, creates an answer, and once ICE candidates are exchanged, video flows directly peer-to-peer.
- Save public/livestream.js.
- Open two separate browser tabs at http://localhost:3000/livestream.html. Enter a valid token in each.
- In one tab, click Start Broadcasting and allow camera access when prompted. You should see your camera feed appear.
- In the other tab, click Watch Stream. After a moment, the broadcaster's video should appear in the viewer tab.
Video not appearing for the viewer?
- Both tabs must be using different valid JWT tokens (different userIds). If both use the same token, the signaling messages get confused.
- Make sure you clicked Start Broadcasting before clicking Watch Stream. The broadcast must be active for the viewer-request to succeed.
- Check the browser console for errors. If you see ICE connection failures, both tabs need to be on the same machine (localhost) or you need a TURN server for cross-network connections.
Help me debug why WebRTC video isn't reaching the viewer.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
const token = prompt('Enter your JWT token:');
if (!token) {
document.body.innerHTML = '<p style="padding:20px;">Token required to join.</p>';
throw new Error('No token');
}
const wsUrl = `ws://${window.location.host}/ws?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const startBroadcastBtn = document.getElementById('startBroadcastBtn');
const stopBroadcastBtn = document.getElementById('stopBroadcastBtn');
const joinStreamBtn = document.getElementById('joinStreamBtn');
const viewerCount = document.getElementById('viewerCount');
const streamStatus = document.getElementById('streamStatus');
const chatMessages = document.getElementById('chatMessages');
const chatInput = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
const rtcConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
let localStream = null;
let peerConnections = {};
let viewerPeerConnection = null;
let isBroadcaster = false;
// WebSocket handlers
ws.onopen = () => {
streamStatus.textContent = 'Connected';
};
ws.onclose = () => {
streamStatus.textContent = 'Disconnected';
};
ws.onerror = () => {
streamStatus.textContent = 'Connection error';
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'welcome':
viewerCount.textContent = `${message.viewerCount} viewers`;
if (message.broadcasterActive) {
joinStreamBtn.style.display = 'inline-block';
}
break;
case 'chat':
addChatMessage(message.username, message.text);
break;
case 'user-joined':
addSystemMessage(`${message.username} joined`);
viewerCount.textContent = `${message.viewerCount} viewers`;
break;
case 'user-left':
addSystemMessage(`${message.username} left`);
viewerCount.textContent = `${message.viewerCount} viewers`;
break;
case 'broadcast-started':
addSystemMessage(`${message.username} started streaming`);
if (!isBroadcaster) {
joinStreamBtn.style.display = 'inline-block';
}
break;
case 'broadcast-ended':
addSystemMessage('Stream ended');
joinStreamBtn.style.display = 'none';
remoteVideo.style.display = 'none';
if (viewerPeerConnection) {
viewerPeerConnection.close();
viewerPeerConnection = null;
}
break;
case 'viewer-request':
handleViewerRequest(message.viewerId);
break;
case 'offer':
handleOffer(message.offer);
break;
case 'answer':
handleAnswer(message.viewerId, message.answer);
break;
case 'ice-candidate':
handleIceCandidate(message);
break;
}
};
// Chat functions
function addChatMessage(username, text) {
const div = document.createElement('div');
div.className = 'message';
div.innerHTML = `<span class="author">${escapeHtml(username)}</span><div class="text">${escapeHtml(text)}</div>`;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addSystemMessage(text) {
const div = document.createElement('div');
div.className = 'system-message';
div.textContent = text;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function sendChat() {
const text = chatInput.value.trim();
if (!text) return;
ws.send(JSON.stringify({ type: 'chat', text }));
chatInput.value = '';
}
sendBtn.addEventListener('click', sendChat);
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendChat();
});
// Broadcaster functions
startBroadcastBtn.addEventListener('click', async () => {
try {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
localVideo.srcObject = localStream;
localVideo.style.display = 'block';
isBroadcaster = true;
startBroadcastBtn.style.display = 'none';
stopBroadcastBtn.style.display = 'inline-block';
streamStatus.textContent = 'Broadcasting';
ws.send(JSON.stringify({ type: 'start-broadcast' }));
} catch (err) {
streamStatus.textContent = 'Camera access denied';
}
});
stopBroadcastBtn.addEventListener('click', () => {
if (localStream) {
localStream.getTracks().forEach((track) => track.stop());
localStream = null;
}
localVideo.style.display = 'none';
Object.values(peerConnections).forEach((pc) => pc.close());
peerConnections = {};
isBroadcaster = false;
stopBroadcastBtn.style.display = 'none';
startBroadcastBtn.style.display = 'inline-block';
streamStatus.textContent = 'Connected';
ws.send(JSON.stringify({ type: 'stop-broadcast' }));
});
// Broadcaster: handle new viewer
async function handleViewerRequest(viewerId) {
const pc = new RTCPeerConnection(rtcConfig);
peerConnections[viewerId] = pc;
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'ice-candidate',
target: viewerId,
candidate: event.candidate
}));
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({
type: 'offer',
viewerId,
offer: pc.localDescription
}));
}
// Broadcaster: handle answer from viewer
async function handleAnswer(viewerId, answer) {
const pc = peerConnections[viewerId];
if (pc) {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
}
// Viewer functions
joinStreamBtn.addEventListener('click', () => {
ws.send(JSON.stringify({ type: 'viewer-request' }));
joinStreamBtn.style.display = 'none';
streamStatus.textContent = 'Connecting to stream...';
});
async function handleOffer(offer) {
viewerPeerConnection = new RTCPeerConnection(rtcConfig);
viewerPeerConnection.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
remoteVideo.style.display = 'block';
streamStatus.textContent = 'Watching stream';
};
viewerPeerConnection.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'ice-candidate',
target: 'broadcaster',
candidate: event.candidate
}));
}
};
await viewerPeerConnection.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await viewerPeerConnection.createAnswer();
await viewerPeerConnection.setLocalDescription(answer);
ws.send(JSON.stringify({
type: 'answer',
answer: viewerPeerConnection.localDescription
}));
}
// Handle ICE candidates
function handleIceCandidate(message) {
if (isBroadcaster) {
const pc = peerConnections[message.viewerId];
if (pc) {
pc.addIceCandidate(new RTCIceCandidate(message.candidate));
}
} else if (viewerPeerConnection) {
viewerPeerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
}
}
Style the livestream page
The page works but looks rough without styling. This stylesheet creates a dark-themed layout with the video player on the left and the chat panel fixed at 350px on the right.
- Create a new file called livestream.css inside your public/ folder, then paste this code:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
height: 100vh;
}
.container {
display: flex;
height: 100vh;
}
.video-section {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
}
.video-wrapper {
flex: 1;
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.video-wrapper video {
width: 100%;
height: 100%;
object-fit: contain;
}
#localVideo {
display: none;
}
#remoteVideo {
display: none;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
}
.controls button {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
#startBroadcastBtn {
background: #e53935;
color: #fff;
}
#startBroadcastBtn:hover {
background: #c62828;
}
#stopBroadcastBtn {
background: #555;
color: #fff;
}
#joinStreamBtn {
background: #1e88e5;
color: #fff;
}
#joinStreamBtn:hover {
background: #1565c0;
}
#viewerCount {
color: #aaa;
font-size: 14px;
margin-left: auto;
}
.status {
color: #aaa;
font-size: 13px;
padding: 4px 0;
}
.chat-section {
width: 350px;
display: flex;
flex-direction: column;
border-left: 1px solid #2a2a2a;
}
.chat-header {
padding: 16px;
border-bottom: 1px solid #2a2a2a;
}
.chat-header h3 {
font-size: 16px;
font-weight: 600;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.chat-messages .message {
margin-bottom: 12px;
}
.chat-messages .message .author {
font-weight: 600;
font-size: 13px;
color: #e0e0e0;
}
.chat-messages .message .text {
font-size: 14px;
color: #ccc;
margin-top: 2px;
word-wrap: break-word;
}
.chat-messages .system-message {
font-size: 12px;
color: #888;
margin-bottom: 8px;
font-style: italic;
}
.chat-input {
display: flex;
padding: 12px;
border-top: 1px solid #2a2a2a;
gap: 8px;
}
.chat-input input {
flex: 1;
padding: 10px 12px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a1a;
color: #fff;
font-size: 14px;
outline: none;
}
.chat-input input:focus {
border-color: #1e88e5;
}
.chat-input button {
padding: 10px 16px;
background: #1e88e5;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.chat-input button:hover {
background: #1565c0;
}
What does this layout do?
- The .container uses flexbox to place the video section (flexible width) on the left and the chat panel (fixed 350px) on the right.
- Both video elements start hidden (display: none) and only appear when JavaScript sets them to display: block during broadcasting or viewing.
- The red Start Broadcasting button uses a color scheme that signals "live" to users, while the blue Watch Stream button indicates a passive viewer action.
- Save public/livestream.css.
- Refresh your browser. You should see a dark-themed page with a large video area on the left, a red Start Broadcasting button below it, and a chat panel with a blue border on the right.
Page still looks unstyled?
- Hard-refresh your browser (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to clear the cached stylesheet.
- Check that livestream.css is in the same public/ folder as your HTML file.
- Confirm the <link> tag in your HTML points to livestream.css (not a different filename).
Why isn't my CSS loading on the page?
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
height: 100vh;
}
.container {
display: flex;
height: 100vh;
}
.video-section {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
}
.video-wrapper {
flex: 1;
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.video-wrapper video {
width: 100%;
height: 100%;
object-fit: contain;
}
#localVideo {
display: none;
}
#remoteVideo {
display: none;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
}
.controls button {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
#startBroadcastBtn {
background: #e53935;
color: #fff;
}
#startBroadcastBtn:hover {
background: #c62828;
}
#stopBroadcastBtn {
background: #555;
color: #fff;
}
#joinStreamBtn {
background: #1e88e5;
color: #fff;
}
#joinStreamBtn:hover {
background: #1565c0;
}
#viewerCount {
color: #aaa;
font-size: 14px;
margin-left: auto;
}
.status {
color: #aaa;
font-size: 13px;
padding: 4px 0;
}
.chat-section {
width: 350px;
display: flex;
flex-direction: column;
border-left: 1px solid #2a2a2a;
}
.chat-header {
padding: 16px;
border-bottom: 1px solid #2a2a2a;
}
.chat-header h3 {
font-size: 16px;
font-weight: 600;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.chat-messages .message {
margin-bottom: 12px;
}
.chat-messages .message .author {
font-weight: 600;
font-size: 13px;
color: #e0e0e0;
}
.chat-messages .message .text {
font-size: 14px;
color: #ccc;
margin-top: 2px;
word-wrap: break-word;
}
.chat-messages .system-message {
font-size: 12px;
color: #888;
margin-bottom: 8px;
font-style: italic;
}
.chat-input {
display: flex;
padding: 12px;
border-top: 1px solid #2a2a2a;
gap: 8px;
}
.chat-input input {
flex: 1;
padding: 10px 12px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a1a;
color: #fff;
font-size: 14px;
outline: none;
}
.chat-input input:focus {
border-color: #1e88e5;
}
.chat-input button {
padding: 10px 16px;
background: #1e88e5;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.chat-input button:hover {
background: #1565c0;
}
Your livestream app is fully functional. You have real-time chat, authenticated WebSocket connections, and peer-to-peer video streaming all working together. Next up, there's a secret mission that takes your broadcaster controls to the next level.
Secret mission
Add screen sharing to your livestream
Your livestream app already handles camera video, but real streamers need more flexibility. What if the broadcaster could share their screen mid-stream to show a demo, then switch back to camera? In this secret mission, you'll use RTCRtpSender.replaceTrack() to swap video tracks seamlessly without disconnecting viewers.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your livestream feature integrated, pause it, or remove it entirely from your Express app.
Resources you used:
- websocket.js (WebSocket server module).
- public/livestream.html, public/livestream.css, public/livestream.js (frontend files).
- ws npm package.
- Modifications to server.js (HTTP server creation, static middleware, WebSocket setup).
Keep everything running
No action needed. Choose this if you plan to keep building on your livestream feature or want to experiment with additional WebRTC functionality.
- Your Express server continues to serve the livestream page alongside your existing routes.
- The WebSocket server runs automatically when you start your Express app.
- No cloud resources are running, so there is nothing accumulating cost.
Pause - I'll come back to this later
Stop the Node.js server to free up the port. Your files stay on disk ready for next time.
- In the terminal where your server is running, press Ctrl+C to stop it.
- When you want to resume, run node server.js from your project folder to start everything back up.
Delete - I don't want to use this again
Remove all livestream-related files and the WebSocket package from your Express project.
- Stop your server by pressing Ctrl+C in the terminal.
- Remove the WebSocket server module and frontend files by running:
macOS
rm websocket.js
rm public/livestream.html public/livestream.css public/livestream.js
Windows
del websocket.js
del public\livestream.html, public\livestream.css, public\livestream.js
- Uninstall the ws package by running:
npm uninstall ws
- Revert your server.js changes. Remove the http import, the setupWebSocket require, the express.static middleware line, and the http.createServer call. Switch back to using app.listen() directly.
Nice Work!
Nice Work!
Nice work! You just built a full livestream application from scratch using WebSocket and WebRTC.
You've learned how to:
- Integrate a WebSocket server into an existing Express app with JWT authentication, heartbeat detection, and real-time chat broadcasting.
- Use WebRTC peer connections to stream live video and audio from a broadcaster to multiple viewers, with WebSocket handling all the signaling.
- Build a frontend with a video player, broadcast controls, and a live chat panel that connects to your WebSocket server.
- Add screen sharing so the broadcaster can seamlessly switch between camera and screen using RTCRtpSender.replaceTrack() without disconnecting viewers.
Ready to quiz yourself?