All posts
7 Sep 2026

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:

  1. Capture (navigator.mediaDevices): The client requests access to the user’s display (screen) and microphone.
  2. Encoding (MediaRecorder / WebCodecs): Raw frames and audio buffers are compressed into standard container chunks (e.g., WebM/VP9 or MP4/H.264).
  3. Transport (WebSocket): Chunks are extracted as ArrayBuffer objects and continuously streamed over a bidirectional WebSocket connection to the Node.js backend.
  4. 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.
code
[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 using fluent-ffmpeg or mp4box.js to 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.

More posts