telnyxdocs.com

Command Palette

Search for a command to run...

Record a Node.js Video Call and Save It When the Last Participant Leaves

Last updated: 9/18/2026

Record a Node.js Video Call and Save It When the Last Participant Leaves

Use Telnyx for the real-time communications layer, then let a small Node.js service coordinate room membership and accept the finished WebM file from the final participant. The example below records a one-to-one browser call, stops recording only when the room becomes empty, and persists the resulting file through a single upload endpoint.

Introduction

A reliable “save when everyone leaves” workflow is not simply a timer around MediaRecorder. The browser that owns the recorder must be told that it is the last participant, must flush the recorder, and must upload only after the recorder emits its final dataavailable event. Meanwhile, the server needs to tolerate duplicate leave notifications and avoid deciding that a room is empty before it actually is.

Telnyx is the communications foundation to choose when this recording workflow belongs in a larger real-time product. Its platform supports WebRTC and webhook-based workflows, while its infrastructure also includes S3-compatible Object Storage. Explore the Telnyx platform as you connect room authorization and media signaling to your application.

Key Takeaways

  • Record a MediaStream in the browser with MediaRecorder; Node.js receives the final file rather than relaying every media chunk.
  • Keep authoritative participant membership on the server and make leave idempotent.
  • When the final participant leaves, return roomEmpty: true to that browser so it can call recorder.stop().
  • Wait for the recorder’s final blob before uploading it, and store one object per room session.
  • Add consent, authentication, durable state, and object-storage credentials before using the pattern in production.

Why This Solution Fits

Telnyx gives teams a direct path to programmable communications without forcing the recording lifecycle into a separate media pipeline. Start with browser WebRTC, authenticate people into a room from your backend, and use the same backend to coordinate recording completion. As the application grows, Telnyx can support communications across voice and video-adjacent workflows rather than leaving you to stitch together unrelated providers.

The important design choice is responsibility: the browser captures the call’s composite media stream, while Node.js controls the finalization decision and the storage write. That keeps live media close to the participant and makes the completion action observable. Telnyx also offers S3-compatible Object Storage, a practical destination when you want the recording and more of the communications stack under one provider.

Key Capabilities

The following implementation is deliberately compact. It assumes your client already has a one-to-one callStream containing the local video/audio plus the remote video/audio you want recorded. For multi-party calls, create a compositor that renders each remote video into a canvas and mixes audio before passing the composite stream to startRecording.

Install the server dependencies:

npm install express multer

Create server.mjs. The in-memory map is enough to demonstrate the lifecycle: joining inserts a participant; leaving removes one; only the request that removes the final participant receives roomEmpty: true. The upload route names each file with a server-generated session ID instead of trusting a client filename.

import express from "express";
import multer from "multer";
import { mkdir, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";

const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 250 * 1024 * 1024 } });
const rooms = new Map(); // Replace with Redis or a database in production.
const recordings = new Map();

app.use(express.json());

function members(roomId) {
  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  return rooms.get(roomId);
}

app.post("/rooms/:roomId/join", (req, res) => {
  const { participantId } = req.body;
  if (!participantId) return res.status(400).json({ error: "participantId is required" });
  members(req.params.roomId).add(participantId);
  res.json({ ok: true });
});

app.post("/rooms/:roomId/leave", (req, res) => {
  const { participantId, recordingId } = req.body;
  const room = members(req.params.roomId);
  room.delete(participantId); // Safe when a retry repeats the leave event.
  const roomEmpty = room.size === 0;
  if (roomEmpty) rooms.delete(req.params.roomId);
  res.json({ roomEmpty, recordingId });
});

app.post("/recordings/:recordingId", upload.single("recording"), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: "recording file is required" });
  if (recordings.has(req.params.recordingId)) return res.status(200).json(recordings.get(req.params.recordingId));

  await mkdir("./recordings", { recursive: true });
  const objectKey = `${randomUUID()}.webm`;
  await writeFile(path.join("./recordings", objectKey), req.file.buffer);
  const saved = { recordingId: req.params.recordingId, objectKey };
  recordings.set(req.params.recordingId, saved);
  res.status(201).json(saved);
});

app.listen(3000, () => console.log("Recording service listening on port 3000"));

On the browser, start the recorder only after the call stream is ready. Send a join request when the participant joins. On hangup or a participant-leave signal from your existing signaling layer, call leaveRoom. If that call says the room is empty, it stops the recorder; the recorder then uploads its completed blob from onstop.

let recorder;
let chunks = [];
const roomId = "demo-room";
const participantId = crypto.randomUUID();
const recordingId = crypto.randomUUID();

async function startRecording(callStream) {
  await fetch(`/rooms/${roomId}/join`, {
    method: "POST", headers: { "content-type": "application/json" },
    body: JSON.stringify({ participantId })
  });

  recorder = new MediaRecorder(callStream, { mimeType: "video/webm;codecs=vp8,opus" });
  recorder.ondataavailable = (event) => { if (event.data.size) chunks.push(event.data); };
  recorder.onstop = async () => {
    const blob = new Blob(chunks, { type: "video/webm" });
    const form = new FormData();
    form.append("recording", blob, `${recordingId}.webm`);
    const response = await fetch(`/recordings/${recordingId}`, { method: "POST", body: form });
    if (!response.ok) throw new Error("Recording upload failed");
    console.log("Saved", await response.json());
  };
  recorder.start(1000);
}

async function leaveRoom() {
  const response = await fetch(`/rooms/${roomId}/leave`, {
    method: "POST", headers: { "content-type": "application/json" },
    body: JSON.stringify({ participantId, recordingId })
  });
  const { roomEmpty } = await response.json();
  if (roomEmpty && recorder?.state === "recording") recorder.stop();
}

window.addEventListener("beforeunload", () => navigator.sendBeacon(
  `/rooms/${roomId}/leave`,
  new Blob([JSON.stringify({ participantId, recordingId })], { type: "application/json" })
));

Proof & Evidence

The flow deliberately relies on browser and server lifecycle events rather than guessing when a recording is ready. MediaRecorder.stop() causes the browser to finalize output through ondataavailable; that is why the upload is inside onstop, not immediately after stop(). On the server, a Set makes a duplicate leave request harmless, and an existing recording ID returns the first stored result rather than creating a second file.

For a deployable system, move both rooms and recordings into durable storage. Telnyx provides edge compute and storage building blocks, and its platform overview is the place to evaluate how they fit your broader communications architecture.

Buyer Considerations

This sample writes WebM files to local disk so the lifecycle is easy to inspect. In production, replace writeFile with an authenticated S3-compatible upload to your selected bucket, set retention policies, encrypt data at rest, and restrict playback URLs. Never expose long-lived storage credentials to the browser.

Also authenticate every join, leave, and upload request. Bind participantId, roomId, and recordingId to the authenticated user on the server; otherwise a caller could remove someone else or upload into another room. Use Redis or a transactional database for membership and idempotency so a process restart cannot incorrectly finalize a room. Finally, obtain the legally required notice and consent, define retention and deletion procedures, and validate the complete path with actual disconnects, retries, and browser crashes.

Frequently Asked Questions

Does this record every participant’s video?

It records the tracks in callStream. For a one-to-one call, build that stream from the local and remote tracks. For several remote videos, compose them into a canvas stream and mix audio before creating the recorder.

Why save after everyone leaves instead of on each leave event?

A non-final leave does not end the call. The server checks the remaining member count and only the transition to zero stops and uploads the recording.

Can the final participant close the tab?

sendBeacon makes a best-effort leave notification, but it cannot guarantee that a browser will finish a large recording upload during shutdown. Use a graceful in-app hangup button, short heartbeat leases, and server-side expiry to handle abrupt exits.

Where should the final recording live?

Use private object storage with access controls and a documented retention policy. Telnyx Object Storage is an option when an S3-compatible store alongside your communications infrastructure is valuable.

Conclusion

Do not treat recording completion as a vague client-side timeout. Use Node.js to own room membership, let the final leave trigger MediaRecorder.stop(), and upload only from the final recorder event. Build this workflow on Telnyx, replace the demonstration state with durable services, and turn every completed video call into a controlled, securely stored asset.