Building a Real-Time Cloud Screen and Audio/Video Recorder in Node.js with WebCodecs and WebSockets
An architectural guide and code-heavy tutorial on capturing browser-side media streams using MediaRecorder and WebCodecs, streaming binary chunks over WebSockets, and stitching them server-side in Node.js.
Building a Real-Time Cloud Screen and Audio/Video Recorder in Node.js with WebCodecs and WebSockets
Building a reliable cloud recording service historically meant heavy client-side processing, complex multi-part HTTP uploads, or expensive pre-packaged SaaS solutions. But modern browsers give us powerful primitives: the MediaRecorder API and the low-level WebCodecs API. Combined with a persistent WebSocket pipeline and a robust Node.js backend, you can stream, buffer, and stitch high-definition video and audio recordings directly to disk or cloud storage in real time.
In this architectural guide, we will design and build a full-stack media recording system. We’ll cover browser-side stream capture, binary chunk transmission over WebSockets, and stateful Node.js stream assembly.
System Architecture Overview
Before writing code, let’s look at the data flow:
- Capture (
navigator.mediaDevices): The client requests access to the user’s display (screen) and microphone. - Encoding (
MediaRecorder/WebCodecs): Raw frames and audio buffers are compressed into standard container chunks (e.g., WebM/VP9 or MP4/H.264). - Transport (
WebSocket): Chunks are extracted asArrayBufferobjects and continuously streamed over a bidirectional WebSocket connection to the Node.js backend. - Ingestion & Assembly (
Node.js): The backend receives the binary packets, manages session-specific buffers, and writes them sequentially to an open file descriptor using native Node.js streams.
[Browser Screen/Mic] ---> [MediaRecorder API]
|
v (Binary ArrayBuffers)
[WebSocket Client]
|
v (Network Stream)
[Node.js WebSocket Server] -> [fs.createWriteStream] -> [Disk / Cloud Storage]
Phase 1: The Browser Capture & Streaming Client
Let’s start by building the client-side module. We need to capture the screen and audio streams simultaneously, initialize a MediaRecorder, and pipe data chunks to our WebSocket server as soon as they become available.
public/recorder.js
class CloudRecorder {
constructor(wsUrl) {
this.wsUrl = wsUrl;
this.ws = null;
this.mediaRecorder = null;
this.stream = null;
}
async start() {
try:
// 1. Establish WebSocket connection
this.ws = new WebSocket(this.wsUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = async () => {
console.log('WebSocket connected. Initializing media streams...');
// 2. Capture screen and audio streams
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 30, width: 1920, height: 1080 },
audio: true
});
const audioStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
});
// 3. Combine tracks into a single MediaStream
const combinedTracks = [
...screenStream.getVideoTracks(),
...audioStream.getAudioTracks()
];
this.stream = new MediaStream(combinedTracks);
// Handle user stopping share via browser UI
screenStream.getVideoTracks()[0].onended = () => this.stop();
// 4. Initialize MediaRecorder with optimal mimeType
const options = { mimeType: 'video/webm;codecs=vp9,opus' };
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
console.warn(`${options.mimeType} not supported. Falling back to default.`);
options.mimeType = 'video/webm';
}
this.mediaRecorder = new MediaRecorder(this.stream, options);
// 5. Stream chunks over WebSocket as they arrive
this.mediaRecorder.ondataavailable = async (event) => {
if (event.data && event.data.size > 0) {
if (this.ws.readyState === WebSocket.OPEN) {
const buffer = await event.data.arrayBuffer();
this.ws.send(buffer);
}
}
};
this.mediaRecorder.onstop = () => {
console.log('MediaRecorder stopped. Closing WebSocket.');
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close();
}
};
// Request chunks every 1000ms (1 second)
this.mediaRecorder.start(1000);
console.log('Recording started successfully.');
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (err) {
console.error('Failed to start recording session:', err);
}
}
stop() {
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
}
}
}
// Usage hook
const recorder = new CloudRecorder('ws://localhost:8080');
document.getElementById('startBtn').addEventListener('click', () => recorder.start());
document.getElementById('stopBtn').addEventListener('click', () => recorder.stop());
Phase 2: The Node.js WebSocket Backend
On the backend, our primary job is to accept incoming connections, parse incoming binary buffers, and stream them cleanly to disk. We will use ws for high-performance WebSocket handling in Node.js.
Installing Dependencies
npm init -y
npm install express ws uuid
server.js
const express = require('express');
const { WebSocketServer } = require('ws');
const http = require('http');
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// Serve static files (our client UI)
app.use(express.static(path.join(__dirname, 'public')));
// Ensure recordings directory exists
const RECORDINGS_DIR = path.join(__dirname, 'recordings');
if (!fs.existsSync(RECORDINGS_DIR)) {
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
}
wss.on('connection', (ws, req) => {
const sessionId = uuidv4();
const filePath = path.join(RECORDINGS_DIR, `${sessionId}.webm`);
console.log(`[Session ${sessionId}] Client connected from ${req.socket.remoteAddress}`);
// Create a writable file stream for this specific session
const fileWriteStream = fs.createWriteStream(filePath, {
flags: 'w',
encoding: 'binary'
});
let totalBytesReceived = 0;
// Handle incoming binary chunks from the browser
ws.on('message', (data, isBinary) => {
if (!isBinary) {
console.warn(`[Session ${sessionId}] Received non-binary message. Ignoring.`);
return;
}
const buffer = Buffer.from(data);
totalBytesReceived += buffer.length;
// Write chunk directly to disk via Node.js Stream API
const canWriteMore = fileWriteStream.write(buffer);
// Optional: Implement backpressure management if disk I/O stalls
if (!canWriteMore) {
ws.paused = true;
fileWriteStream.once('drain', () => {
ws.paused = false;
});
}
});
ws.on('close', (code, reason) => {
console.log(`[Session ${sessionId}] WebSocket closed. Code: ${code}, Reason: ${reason.toString()}`);
// Finalize the file stream
fileWriteStream.end(() => {
console.log(`[Session ${sessionId}] Recording saved successfully. Total size: ${(totalBytesReceived / (1024 * 1024)).toFixed(2)} MB`);
console.log(`[Session ${sessionId}] File location: ${filePath}`);
});
});
ws.on('error', (error) => {
console.error(`[Session ${sessionId}] WebSocket error:`, error);
fileWriteStream.destroy(error);
});
});
const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
console.log(`Cloud recording service running on http://localhost:${PORT}`);
});
Phase 3: Advanced Optimization with WebCodecs API
While MediaRecorder is great for out-of-the-box compression, advanced use cases (like custom sub-second latency, precise keyframe injection, or custom pixel manipulation) require the low-level WebCodecs API.
WebCodecs allows raw access to hardware-accelerated encoders. Here is how you can initialize a VideoEncoder manually inside your browser client to feed custom VideoFrame objects directly into our real-time pipeline:
async function initWebCodecsEncoder(ws) {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const track = stream.getVideoTracks()[0];
const processor = new MediaStreamTrackProcessor({ track });
const reader = processor.readable.getReader();
const encoder = new VideoEncoder({
output: (chunk, metadata) => {
// Metadata contains decoder configurations on keyframes
if (metadata.decoderConfig) {
const configBuffer = new TextEncoder().encode(JSON.stringify(metadata.decoderConfig));
// Send configuration prefix marker or handle out-of-band
}
// Extract raw chunk data
const buf = new ArrayBuffer(chunk.byteLength);
chunk.copyTo(buf);
ws.send(buf);
},
error: (e) => console.error('WebCodecs Encoder Error:', e)
});
encoder.configure({
codec: 'vp09.00.10.08',
width: 1920,
height: 1080,
bitrate: 5_000_000, // 5 Mbps
framerate: 30
});
// Read frames from stream track and encode
while (true) {
const { value: frame, done } = await reader.read();
if (done) break;
if (encoder.encodeQueueSize > 30) {
// Drop frame if encoder is congested
frame.close();
continue;
}
encoder.encode(frame, { keyFrame: Math.random() < 0.05 }); // Force keyframe every ~5% of frames
frame.close();
}
}
Architectural Note: When using raw WebCodecs encoding (
VideoEncoder), your Node.js backend receives raw encoded elementary stream packets (e.g., raw Annex B H.264 or IVF/WebM cluster frames) rather than a pre-packaged file container. You will need a post-processing step on the backend usingfluent-ffmpegormp4box.jsto wrap the raw elementary streams into a seekable container format upon session termination.
Handling Production Failure Modes
When building distributed real-time media ingestion backends, you must prepare for edge cases:
1. Network Drops & Reconnections
If a client loses connectivity mid-recording, the WebSocket drops. Standard MediaRecorder will lose any buffers held in memory unless you implement a client-side IndexedDB fallback buffer queue.
// Example: Client-side local persistence fallback
const dbRequest = indexedDB.open('MediaBufferDB', 1);
// Store chunks locally in IndexedDB if ws.readyState !== OPEN,
// then flush queued chunks upon reconnection.
2. Backpressure and Memory Spikes
Node.js handles streaming efficiently, but if a client sends packets faster than your disk subsystem can write them (common on spinning disks or high-latency network mounts like AWS EFS), memory consumption will spike. Always check the boolean return value of stream.write() and manage stream pausing as shown in our server code.
Conclusion
By leveraging the browser’s native MediaRecorder or WebCodecs APIs alongside a persistent WebSocket connection, you can build a powerful, scalable real-time cloud screen and audio recording service in Node.js.
This architecture bypasses the need for bloated client-side dependencies, giving you complete control over incoming streams, real-time data inspection, and direct-to-disk cloud storage pipelines.