We record our video meetings using LiveKit Egress (startRoomCompositeEgress), outputting MP4 files directly to an S3 bucket. The egress runs a headless Chrome instance against a custom renderer that composes the meeting layout (speaker view plus shared media and whiteboard content). Our current model is: when a host clicks “Record,” we start egress; when they click “Stop,” we get an egress_ended webhook and store the recording segment (S3 URL, start time, end time) in our backend, then later send the consolidated session to an external API for the meeting summary. Now we want to add a play/pause capability — i.e., pause the live recording and resume it so the final output isn’t one long file but honor that pause boundary. Since Egress writes a single monotonically growing file, a true pause isn’t natively supported; the obvious approach seems to be stop/start egress at each pause boundary and stitch segments later (which also matches how we already handle multiple recording segments in a session). Is that the recommended pattern? Are there gotchas with startRoomCompositeEgress when starting a new segment for the same room (gaps, timestamps, file naming), or is there a better-supported way to get pause/resume semantics with Egress? We generate segment timestamps off the egress_ended/recording_start Redis keys we track, and consolidate them into one payload afterward.
yes, stop/start per pause boundary is the pattern. There’s no native pause the feature request for it (egress#195) has been open since Dec 2022, unassigned, with no PR. Nothing has shipped in the meantime, and since Egress finalizes an MP4 (moov atom, upload) on stop, a mid-file pause isn’t something that can be bolted on cheaply. Your instinct to reuse the multi-segment machinery you already have is right.
The things worth changing are mostly around how you do the stop/start and where your timestamps come from.
Fix the timestamp source first
This is the one I’d flag hardest. Deriving segment boundaries from your own Redis recording_start key records when your backend asked, not when media actually started. The delta is the Chrome cold start plus template load plus room connect plus the wait for the START_RECORDING console signal variable, typically a few seconds, occasionally much worse under load. Over a session with several pauses, those errors accumulate and your consolidated payload drifts against the actual media.
Use FileInfo from the egress_ended webhook instead. It carries filename, location, started_at, ended_at, duration (nanoseconds), and size. Note FileInfo.started_at is when the file recording started, which is not the same as EgressInfo.started_at (when the egress started). Take the former.
Keep the Redis keys, but demote them: they’re your intent/UI-state record and your reconciliation key, not the timeline source of truth. Also don’t build ordering on webhook arrival order webhooks retry and can arrive out of order. Key on egress_id, sort by FileInfo.started_at, and reconcile against ListEgress or the JSON manifest Egress writes next to each file (keep disable_manifest false; it’s a free backstop for a dropped webhook).
Related: ended_at - started_at will not exactly equal duration. Use duration for concatenation math and wall-clock for “where in the meeting did this happen,” and don’t mix the two bases.
Gotchas specific to restarting on the same room
Don’t serialize resume behind the previous stop. Stop is slow finalize plus S3 upload, and egress_ended only fires after the upload completes. People report 20+ seconds end to end. If you wait for egress_ended before issuing the next start, every pause/resume costs the host half a minute. Fire the new start immediately and reconcile the old segment’s metadata asynchronously when its webhook lands.
But that means brief overlap, which hits the concurrency quota. Two egresses on the same room is functionally fine, but it counts as two egress requests. The Build plan default is 2 concurrent egress requests per project — check your plan’s actual number, because if two rooms happen to resume at the same moment you’ll get a hard failure on StartEgress, not a queue.
There will be a real gap in the media. Nothing that happens between the stop and the new egress reaching EGRESS_ACTIVE exists anywhere. Don’t show “Recording” in the host UI until egress_started / EGRESS_ACTIVE; show “Resuming…” so nobody says something important into a hole. If your custom template defers EgressHelper.startRecording() until some readiness condition (all tracks subscribed, whiteboard hydrated, shared media loaded), that delay is added directly to the gap worth auditing, since a heavyweight renderer can make this much worse than the baseline browser boot.
File naming will collide. {time} resolves to second precision (testroom-2022-10-04T011306.mp4). A fast stop/start, or a scripted test, can put two segments in the same second and S3 will silently overwrite. Stop using the templated default and write an explicit deterministic path: sessions/{sessionId}/segments/{index}-{egressId}.mp4. Sortable, collision-free, and you never have to parse keys to rebuild order.
Guard against double-clicks. There’s no server-side dedupe two StartEgress calls give you two recorders and two files. Use a Redis mutex keyed on session ID. Don’t use a listEgress() check as the guard; it’s racy and it’s an extra round trip on the latency-sensitive path.
Two recorder participants exist momentarily. The default template already filters the local egress participant, but if any app-side logic reacts to participant join/leave participant counts, a recording indicator, active-speaker logic — you’ll get a flicker during the overlap. Filter by participant kind or identity prefix.
The room can die during a pause. RoomComposite is tied to the room lifecycle. If everyone drops while paused and empty_timeout fires, your resume will fail or immediately abort. Your state machine also needs to accept “segment ended without a Stop click,” since a room ending stops an active egress on its own. Handle EGRESS_ABORTED and EGRESS_FAILED explicitly rather than treating any non-COMPLETE terminal state as a bug.
Watch the file-output time limit. MP4 file output on Cloud caps at 3 hours (self-hosted defaults are lowerfile_output_max_duration: 1h in the config, configurable). You’ll get EGRESS_LIMIT_REACHED. Pausing helps incidentally, but a long unpaused meeting can still trip it, so consider an automatic rollover around 2.5h that’s recorded as a segment boundary flagged as not a user pause.
Make the stitch actually work
Pin encoding explicitly with advanced EncodingOptions same width, height, framerate, codecs, bitrate on every segment of a session, rather than relying on the preset default. That’s what makes ffmpeg -f concat -c copy viable. Layout changes via UpdateLayout or a screen share appearing won’t change output resolution as long as encoding options are fixed, so those are safe. Do test a boundary where a screen share starts or stops right at the pause, since that’s where A/V sync problems show up if they’re going to; regenerate PTS on the concat, and fall back to a re-encode if you see drift.
Consider HLS segments instead of MP4
Given you already have a stitching step, segment_outputs (SegmentedFileOutput) is arguably a better substrate than file_outputs for this:
- 12-hour limit instead of 3
- Segments upload continuously, so stop latency drops sharply no big finalize-and-upload at the boundary
- Concatenation across recording periods becomes playlist manipulation plus one ffmpeg pass to produce the deliverable MP4, which is cleaner than chaining MP4 concats
- You get progressive availability, useful if you ever want the summary pipeline to start before the meeting ends
The cost is a lot more S3 objects and a mandatory final mux if you must deliver one file. You can also request both a file and a segment output from the same egress Egress transcodes once and writes to both so you could keep MP4 for the simple path and add HLS without doubling transcode cost.
One migration note
Since you’re already in this code: startRoomCompositeEgress is now deprecated in favor of StartEgress with a TemplateSource, along with the other four source-specific APIs. Same capabilities, plus request-level StorageConfig instead of per-output S3 blocks, and a per-request webhooks field that would let you route egress events for this feature to a dedicated endpoint. Requires LiveKit server v1.13.5+ if you’re self-hosting.
Finally, on the summary handoff: if the external API needs a single consolidated media file, the stitch has to be a durable retried job, not something inline in your webhook handler — the handler will sometimes fire before all segments have landed. If the provider accepts multiple files or works off a transcript, you can skip stitching for the summary path entirely and only stitch for playback.