Server-Side Recording & Composition
Recording a multi-party WebRTC session is not “save the stream to a file” — there is no single stream to save. A Selective Forwarding Unit relays each participant’s RTP independently, so what arrives at the recorder is a set of separately-encoded, separately-timed media flows that must be decoded, aligned, laid out, mixed, and re-encoded before anything resembling a watchable file exists. This guide is part of the Media Server Architecture: SFU & MCU guide, and it covers the server-side pipeline end to end: deciding between per-track and composite recording, decoding RTP on the server, building a compositor and an audio mixer, encoding to MP4/WebM, and keeping audio and video in sync across participants who join and leave at arbitrary times.
The audience is engineers running an SFU who now need durable recordings — for compliance, replay, transcription, or highlight reels — and who have discovered that browser-side MediaRecorder cannot capture what it never receives: the other participants’ original, full-quality streams. Server-side recording moves the capture to where every stream already converges. The goal of the pipeline below is a single, correctly-synchronized output (or a clean set of per-track outputs) produced without dropping frames or drifting audio over a one-hour call.
Step 1 — Choose per-track recording vs composite
The first decision dictates the entire pipeline cost, and it is irreversible without re-recording. There are two strategies.
Per-track recording writes each participant’s RTP to its own file (or its own track inside a container) with no decoding and no re-encoding. You essentially dump the depacketized media — VP8/H.264 frames and Opus packets — straight to disk alongside a timing manifest. CPU cost is near zero, quality is bit-exact, and a four-person call costs four cheap recorders. The downside: the output is not directly watchable as one video; you defer composition to an offline batch job or to the playback client.
Composite recording decodes every track, lays the video out into a single canvas, mixes all audio into one track, and re-encodes the result live. The output is one finished file. The cost is real: decoding plus encoding N video streams in real time is the single most expensive operation an SFU host performs, and it does not scale per-room the way forwarding does.
| Dimension | Per-track | Composite |
|---|---|---|
| Server CPU | minimal (no transcode) | high (decode + encode N streams) |
| Output | N files + manifest | one watchable file |
| Quality | bit-exact source | re-encoded (generation loss) |
| Layout flexibility | deferred to playback | fixed at record time |
| Best fit | compliance, archival, post-edit | replay, sharing, livestream egress |
A common production pattern is per-track for the durable archive plus an optional composite egress only for rooms that request it. If you need composite output but not in real time, record per-track and run the compositor as an offline job — same code path, no real-time deadline. The layout and synchronization mechanics below apply identically; only the deadline changes.
Sizing the composite recorder budget
Composite recording is an MCU bolted onto an SFU, and it inherits the MCU cost curve described in SFU vs MCU Topologies. Forwarding costs roughly 1–3 ms of processing per packet; decoding, scaling, mixing and re-encoding puts you back in the 80–200 ms transcode regime, and moves the work from a per-packet memcpy to a per-pixel operation whose cost scales with resolution times frame rate times participant count. Budget against that product, not against the room count.
Concretely: software-decoding one 720p30 VP8 stream costs roughly a third of a modern core, scaling and colour-converting it into a tile costs a little more, and software-encoding the single 720p30 composite costs about a full core on its own. A six-person room therefore lands near three to four cores for one output file, while forwarding that same room costs a small fraction of one. Two levers move the number materially: dropping the composite from 1280×720 to 960×540 cuts the encoder’s pixel rate by about 44% for output that is still perfectly legible on replay, and going from 30 fps to 24 fps removes another 20%. Hardware encoders (NVENC, Quick Sync, VA-API) take encoding off the CPU entirely, but only pay off if decoded frames stay in GPU memory for the scale-and-composite step — a pipeline that copies every frame back to system RAM to draw it and uploads it again spends more on PCIe transfers than it saved.
Because the cost is per output stream rather than per participant, resist any request for per-participant composites: that multiplies encode cost by N for output nobody watches.
Step 2 — Decode RTP on the server
A recorder is an RTP receiver that never renders to a screen. It joins the room as a subscriber (often a special “recorder” peer the SFU treats like any other), terminates DTLS-SRTP, and receives the same forwarded RTP that a browser would. From there the server-side path diverges from a browser: you must reconstruct frames yourself.
The incoming RTP needs a jitter buffer before anything else. Packets arrive out of order and with variable spacing; a recorder that depacketizes in arrival order produces corrupt frames. Size the buffer for the worst-case network jitter you expect (typically 50–200 ms of depth) and reorder by RTP sequence number before handing complete frames to the decoder. Unlike a live renderer, a recorder can afford a deeper buffer — you are writing to disk, not to a human’s eyes, so trading 100–200 ms of added buffering for fewer dropped frames is almost always correct.
// Server-side RTP receiver feeding a jitter buffer, then a decoder.
// Runs in a Node SFU process; mediasoup-style PlainTransport delivers raw RTP.
const jitterBuffer = new JitterBuffer({ maxDepthMs: 150 }); // deeper than a live renderer
rtpReceiver.on('rtp', (packet) => {
// packet: { sequenceNumber, timestamp, payload, marker, ssrc }
jitterBuffer.push(packet); // reorder by sequenceNumber internally
});
// Drain complete frames on a fixed cadence aligned to the stream clock rate
jitterBuffer.on('frame', (frame) => {
// frame.rtpTimestamp is in the codec clock (90000 Hz for video, 48000 Hz for Opus)
decoder.decode(frame.payload, frame.rtpTimestamp, (rawFrame) => {
// rawFrame is YUV (video) or PCM (audio); tag it with a normalized wall-clock ts
pipeline.submit(frame.ssrc, normalizeTimestamp(frame.rtpTimestamp, frame.ssrc), rawFrame);
});
});
The decoder itself is codec-specific. For video you decode VP8, H.264, or AV1 to raw YUV; the codec your participants negotiated is the codec you must decode, which is one more reason to pin codecs at the application layer as discussed in VP8 vs H.264 vs AV1 Codec Selection. For audio you decode Opus to PCM at 48 kHz. Critically, request a keyframe from each sender when the recorder joins (an RTCP PLI/FIR) — without a recent keyframe the decoder cannot start, and the first seconds of a recording will be black or smeared. Because that request costs every subscriber a bitrate spike, throttle it the way the SFU already throttles subscriber-driven requests, following the patterns in Keyframe Request Strategies in an SFU.
Loss concealment on the recorder’s own path
The recorder is a subscriber, so it has its own network path and its own packet loss — and that is where recording differs fundamentally from live viewing. A live viewer tolerates a corrupted frame because it disappears in 33 ms; a recorder writes that corruption into a file people will watch repeatedly. Every artifact you fail to repair at capture time is permanent, which justifies spending far more on repair than a real-time renderer would.
Three mechanisms carry that weight. Negotiate NACK/RTX on the recorder’s transport with a longer retransmission window than a viewer would use: at a 150 ms jitter buffer instead of a viewer’s 50 ms, a retransmission arriving 120 ms late is still in time to be placed correctly, so one round trip of repair is effectively free. For video, a lost packet invalidates the whole frame and the damage propagates through the rest of the group of pictures, so track concealed frames per sender and issue an RTCP PLI once concealment crosses a small threshold — rate-limited to one request per 2 s per sender, or a recorder glitch becomes a bitrate spike for every live participant. For audio, enable Opus in-band FEC, which piggybacks a low-bitrate copy of the previous 20 ms frame on the next packet and recovers isolated losses with no retransmission at all; the bitrate and packet-loss-percentage tuning behind that redundancy is covered in Tuning Opus Bitrate and FEC for Lossy Networks.
The cheapest fix is topological: put the recorder on the same host, rack, or at minimum the same VPC as the SFU forwarding to it. A recorder reachable only across the public internet at 1% loss bakes 1% loss into every archive, and no buffer depth changes that. Colocation also keeps the TURN relay’s 20–40 ms one-way penalty off the recorder’s path.
Step 3 — Lay out video and mix audio
Composition splits cleanly into two independent subsystems sharing one clock: a video compositor and an audio mixer.
The compositor maintains a canvas at the target resolution (1280×720 is a sane default for a grid) and, on each output frame tick — typically 30 fps, one tick every 33.3 ms — draws the most recent decoded frame from each active participant into its assigned rectangle. Layout is a pure function of the active participant set: a 2×2 grid for up to four, a speaker-plus-thumbnails view driven by audio energy, or a fixed presenter layout. The compositor must hold a “last known frame” per participant, because senders run at different and variable frame rates; if participant B sent its last frame 80 ms ago, you redraw B’s last frame rather than leaving a hole. Active-speaker layouts read short-term audio energy (RMS over a 100–300 ms window) to pick the main tile, and should debounce switches by 1–2 s so the layout does not flicker on every cough.
The audio mixer runs on its own clock at 48 kHz. It resamples every decoded PCM stream to a common rate, aligns each to the output timeline by its normalized timestamp, and sums the samples, applying a soft limiter or per-source attenuation to prevent clipping when several people speak at once. Mixing is where echo and double-talk artifacts surface; the same device-side hygiene from Audio/Video Track Management matters here, because a participant whose echo cancellation failed will inject that echo into the mix for everyone in the recording. Silence-suppressed (DTX) Opus streams send no packets during silence, so the mixer must fill those gaps with silence on the output timeline rather than stalling.
// Compositor output tick: 30 fps. Draw each active participant's latest frame.
const TARGET_FPS = 30;
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS; // 33.3 ms per output frame
function renderCompositeFrame(outputPtsMs) {
const layout = computeLayout(activeParticipants); // e.g. 2x2 grid rectangles
canvas.clear();
for (const p of activeParticipants) {
// Use the most recent decoded frame at or before this output PTS.
// Senders run at different / variable fps, so reuse the last frame on a gap.
const frame = p.frameQueue.latestAtOrBefore(outputPtsMs) ?? p.lastDrawnFrame;
if (frame) {
const rect = layout[p.id];
canvas.drawScaled(frame, rect.x, rect.y, rect.w, rect.h);
p.lastDrawnFrame = frame; // hold for the next gap
}
}
encoder.pushVideoFrame(canvas.snapshot(), outputPtsMs); // PTS in output timeline ms
}
Aspect ratios, mid-stream resolution changes, and layout transitions
Two properties of the incoming frames break naive compositors, and both show up the first time a phone joins a desktop call. The first is aspect ratio: a laptop sends 16:9, an iPad may send 4:3, a handheld in portrait sends 9:16. Stretching a 9:16 frame to fill a 16:9 tile widens faces, and it is the most-reported visual defect in composited recordings. Pick a policy per tile class rather than globally — centre-crop small grid tiles, where losing the outer thirds of a portrait frame is invisible and a letterbox would waste most of the tile, and letterbox the large speaker tile, where cropping a shared slide destroys content. Fill the bars with the panel colour so the tile boundary stays visible.
The second is that a sender’s resolution is not constant. An encoder downscaling under congestion can drop from 1280×720 to 640×360 mid-sentence, and Chrome does so with no signaling event the recorder can subscribe to — the decoder simply starts emitting smaller frames. A compositor that configures its scaler once, on the first frame, crashes or silently produces garbage at that moment. Read dimensions off every decoded frame and rescale per frame, caching the scaler keyed on input geometry so the common case stays cheap.
Layout changes need the same care from the other direction. Recompute the layout whenever the active participant set changes, but keep the output canvas geometry and encoder configuration fixed for the whole recording: changing encoded resolution mid-stream forces a codec reconfiguration, and a meaningful share of players — including some hardware-accelerated mobile decoders — stall or show a black frame at that discontinuity. Cross-fading tile positions over 200–300 ms rather than snapping them costs one extra draw per tile and makes joins read as intentional rather than as a glitch.
Step 4 — Encode, mux, and verify
The composited canvas and the mixed audio buffer feed an encoder. Encode video to H.264 or VP8/VP9 and audio to Opus or AAC, then mux into a container: WebM for VP8/VP9 + Opus, MP4 for H.264 + AAC. Write a fragmented container (fMP4 or streamable WebM) so a crash leaves a playable file up to the last fragment instead of an unfinalized, unplayable blob — finalize the moov atom incrementally rather than only on clean shutdown.
The encoder is fed by presentation timestamps (PTS) derived from the shared output clock, not from arrival time. Video PTS advances by exactly FRAME_INTERVAL_MS per frame; audio PTS advances by samples-over-sample-rate. As long as both are derived from the same monotonic output clock, the muxer interleaves them correctly and the file plays in sync regardless of what the network did upstream; the normalization maths for turning per-sender RTP timestamps into that one timeline is worked through in Synchronising Audio and Video in Recordings.
// Verification pass after the recording finalizes — run this in CI and post-record.
const probe = await ffprobe(outputPath); // shell out to ffprobe -show_streams
const v = probe.streams.find((s) => s.codec_type === 'video');
const a = probe.streams.find((s) => s.codec_type === 'audio');
console.assert(Math.abs(v.duration - a.duration) < 0.25, // A/V drift under 250 ms
`A/V duration drift too large: v=${v.duration}s a=${a.duration}s`);
console.assert(v.avg_frame_rate.startsWith('30'), // encoder held target fps
`unexpected fps: ${v.avg_frame_rate}`);
console.assert(probe.format.duration > 0 && a.channels >= 1,
'file is unplayable or has no audio');
Verify three things on every recording: total duration matches the call duration within a second, audio and video durations agree within 250 ms (the sync budget), and the file opens in a standard player without a remux. Sample the output mid-call, not just at the start — drift accumulates, so a file that is in sync at second 5 can be 400 ms out at minute 30 if the audio and video clocks were ever allowed to diverge.
Storage, crash recovery, and watching a recording while it runs
Fragment size trades recoverability against overhead. Two-to-four-second fragments bound worst-case loss on a crash to a few seconds while keeping container overhead under a percent; sub-second fragments buy little extra safety and multiply index entries and upload requests. Upload each fragment as it closes rather than at the end — a one-hour 720p recording is roughly a gigabyte, and a process that only uploads on clean shutdown turns every OOM kill and node preemption into total data loss. Keep the last few fragments on local disk so a transient upload failure retries without stalling the encoder, and write a manifest of fragment order, start PTS, and duration so a restarted process resumes the same recording instead of starting a second file.
The recorder also needs a health signal, because its failure mode is silent: nobody notices a broken recording until someone asks for the file. The measurement that matters is encoder lag — the wall-clock cost of compositing and encoding one frame against the 33.3 ms you have. Once it consistently exceeds the frame interval, the input queue grows without bound and the process dies of memory exhaustion. Bound the queue and degrade on a policy you chose rather than one the allocator chooses for you.
// Recorder health loop: watch encode cost against the real-time budget.
const FRAME_BUDGET_MS = 1000 / 30; // 33.3 ms per composited frame
let overBudgetStreak = 0;
function onFrameEncoded(encodeMs, queueDepth) {
recorderMetrics.observe('encode_ms', encodeMs); // export as a histogram
recorderMetrics.gauge('queue_depth', queueDepth); // frames waiting to encode
overBudgetStreak = encodeMs > FRAME_BUDGET_MS ? overBudgetStreak + 1 : 0;
if (overBudgetStreak > 90) { // ~3 s of missed deadlines
degradeOutput({ fps: 15 }); // shed load deliberately
recorderMetrics.increment('degradations_total');
}
if (queueDepth > 120) dropOldestFrames(queueDepth - 120); // hard ceiling: ~4 s
}
Export those counters next to the forwarding metrics so one dashboard covers both the media path and the archive path, following the labelling conventions in Exporting SFU Metrics to Prometheus and Grafana. Carry the room and recording id on every series: when someone reports a bad file weeks later, replaying those series for that recording is the only reconstruction you get.
Edge Cases & Browser Quirks
- Safari H.264-only senders. Safari often will not send VP8, so a room with a Safari participant may carry mixed H.264 and VP8 tracks. Your decoder set must cover every codec actually negotiated, not just your house default. Pinning codecs per VP8 vs H.264 vs AV1 Codec Selection keeps the recorder’s decoder requirements predictable.
- Chrome simulcast spatial layers. When senders use simulcast, the SFU forwards one chosen layer to the recorder. Subscribe the recorder to the highest-quality layer explicitly, or your archive captures a 180p thumbnail of a 720p call. This couples directly to Simulcast-Aware Forwarding, and the recorder should be exempted from the bandwidth-driven downgrade logic described in Forwarding Simulcast Layers by Subscriber Bandwidth — it has a fast link and no latency deadline.
- Opus DTX gaps. Firefox and Chrome both implement Opus discontinuous transmission; during silence, no RTP arrives. A mixer that advances its clock on packet arrival will compress the timeline and pull audio ahead of video. Advance the audio timeline on the wall clock and pad silence explicitly.
- Keyframe latency on join. A recorder that joins mid-call sees no decodable video until the next keyframe. Chrome senders may space keyframes seconds apart under good network conditions; send an RTCP FIR/PLI the moment the recorder subscribes rather than waiting.
- RTP timestamp wraparound. The 32-bit RTP timestamp wraps roughly every 13 hours at 90 kHz video — short calls never hit it, but long-running room recordings must handle the rollover when normalizing timestamps.
- Rotation signalled out-of-band on mobile. iOS Safari and Android Chrome frequently encode video in the sensor’s native orientation and signal the display rotation in the
urn:3gpp:video-orientationRTP header extension rather than rotating pixels. A browser applies that rotation on render; a server-side compositor that ignores the extension records a sideways participant. Parse the extension per frame — rotation can change mid-call when the phone turns — and apply it before drawing the tile. If your SFU rewrites extension ids when forwarding, make sure the recorder resolves the id from its own negotiated map, as covered in Rewriting RTP Header Extensions When Forwarding. - Screen shares at 5 fps inside a 30 fps composite. A
getDisplayMediatrack of a static document may emit only a handful of frames per minute; held-frame logic covers it and the encoder skips unchanged macroblocks, but a naive “drop the tile if no frame arrived in 2 s” liveness check blanks the most important tile in the recording. Judge staleness from the sender’s transport state, not from frame arrival. - Recorder counted as a live subscriber in bandwidth estimation. If the SFU feeds the recorder’s path into room-wide estimation, one congested recorder pulls everyone’s target bitrate down. Exclude it and give it a fixed, generous allocation.
Common Implementation Mistakes
- Syncing on arrival time instead of RTP timestamps. Network jitter means packet arrival order and spacing do not reflect capture timing. Drive all alignment from RTP timestamps normalized to a shared clock, never from
Date.now()at receive time. - One clock per participant. Giving each decoded stream its own output clock guarantees drift. The compositor and mixer must share a single monotonic output timeline; participants are sampled against it, not driving it.
- No “last frame” hold in the compositor. Drawing only when a fresh frame arrives leaves black gaps whenever a sender’s frame rate dips. Always redraw the last decoded frame for an active participant.
- Real-time transcode for archival recordings. If you only need a watchable file later, record per-track and composite offline. Paying for live decode-plus-encode on every room does not scale and competes with the SFU’s forwarding budget.
- Unfragmented containers. A plain MP4 whose
moovatom is written only on clean shutdown is unrecoverable if the process crashes. Use fragmented output so partial recordings remain playable. - Ignoring join/leave during the recording. A static layout computed once breaks the moment someone joins or drops. Recompute layout on every participant-set change and keep the encoder’s PTS continuous across the change.
- Discarding RTCP sender reports. The sender report is the only thing that maps a participant’s RTP timestamp onto a shared NTP timeline; without it you can align a stream against itself but never two streams against each other. The first SR can arrive seconds after the media does, so buffer the opening frames and back-fill the mapping once it lands rather than guessing an offset you can never correct.
- Letting the recording depend on one participant staying connected. Anchoring the layout, the room clock, or the recording lifetime to “the first participant” means the file ends when that laptop lid closes. Own the recorder’s lifecycle in the server, driven by explicit start and stop events.
FAQ
Should I record on the SFU host or on a dedicated recorder node?
Dedicate a node for composite recording. Live decode-plus-encode competes for CPU with packet forwarding, and an overloaded SFU drops media for live participants — which is far worse than a delayed recording. Per-track recording is cheap enough to colocate, but composite transcoding belongs on separate capacity, often a different machine class with hardware encoders — scale that pool on its own encoder-CPU signal rather than the forwarding metrics covered in Autoscaling SFU Nodes on CPU and Bandwidth.
How do I keep audio and video in sync over a long call?
Derive both audio PTS and video PTS from one monotonic output clock and normalize every input by its RTP timestamp, not arrival time. Pad Opus DTX silence explicitly so the audio timeline never compresses. Then verify with ffprobe that the two stream durations agree within roughly 250 ms, sampling mid-call rather than only at the start.
Can’t the browser’s MediaRecorder do this instead?
Only for the local participant’s own composited DOM, and only at the quality and layout that one client sees. It cannot capture other participants’ original full-resolution streams, it dies if that one client’s tab closes, and it gives you no server-side archive. Server-side recording at the Selective Forwarding Unit Design is the only place every full-quality stream converges.
What codec and container should I default to?
H.264 + AAC in fragmented MP4 is the most broadly playable output and benefits from hardware H.264 encoders. Choose VP9/Opus in WebM when you want better quality per bitrate and control the playback environment. Match the encoder to your audience’s players, not to the codecs your participants happened to send.
What resolution and frame rate should the composite output use?
720p at 24–30 fps covers almost every use. Recordings are watched, not interacted with, so the frame rate that matters is the one that keeps motion smooth, and 24 fps saves roughly a fifth of the encoder budget with no perceptible difference on talking-head content. Drop to 540p for grid-heavy rooms where no tile exceeds 640×360 anyway, and reserve 1080p for single-presenter screen shares where text legibility is the point. Whatever you pick, fix it for the whole recording.
How should I record a simulcast sender without decoding everything?
For per-track recording, subscribe the recorder to exactly one spatial layer — the highest — and write those RTP packets verbatim; keeping all three layers triples storage for footage you will never composite from. For SVC, capture the full bitstream, since the layers form one dependent stream and dropping enhancement layers at capture time is irreversible. Record the layer decision in the manifest either way, so a downstream compositor can tell a deliberate low layer from a participant who was congested throughout.
Related: return to Media Server Architecture: SFU & MCU, then read the deep-dive on Compositing Multi-Party Recordings Server-Side, and cross-reference Selective Forwarding Unit Design and Simulcast-Aware Forwarding for how the source streams reach the recorder.