TL;DR
We're adding rewind to a live HLS stream: configure a sliding DVR window in FFmpeg, expose it correctly in hls.js, build a seek bar that tracks the moving window, and add a go-live button that seeks to
liveSyncPositioninstead of the end of the seekable range (which stalls). Plus the storage-lifecycle check that prevents 404s inside your own advertised window.
"Can I go back to the start?" is the first request you get after shipping live. The segments are already on disk, so it feels like a config change. It is a config change, across three systems that have to agree, plus about 60 lines of player code.
Requirements: ffmpeg 7.0+, hls.js 1.6+, node 22.x.
ffmpeg -version | head -1
npm ls hls.js
1. Configure the packager window
The DVR window length is hls_time × hls_list_size. That's it. Everything else is bookkeeping.
# 10-minute sliding window: 6s segments × 100 entries
ffmpeg -i rtmp://localhost/live/stream \
-c:v libx264 -preset veryfast -tune zerolatency \
-g 120 -keyint_min 120 -sc_threshold 0 \
-c:a aac -b:a 128k -ar 48000 \
-f hls \
-hls_time 6 \
-hls_list_size 100 \
-hls_flags delete_segments+independent_segments \
-hls_delete_threshold 5 \
-hls_segment_filename 'segments/seg_%05d.ts' \
stream.m3u8
What each flag is doing:
| Flag | Effect | Default |
|---|---|---|
hls_time 6 |
target segment duration | 2 |
hls_list_size 100 |
entries kept in the playlist | 5 |
delete_segments |
remove files once they leave the playlist | off |
hls_delete_threshold 5 |
unreferenced segments kept before deletion | 1 |
independent_segments |
every segment starts on a keyframe | off |
⚠️ That
hls_list_sizedefault of 5 is why so many first live deployments ship an accidental 30-second DVR nobody designed. If you never set it, you already have a DVR window. It's just very short.
-g 120 at 60fps gives a 2-second GOP, which divides evenly into 6-second segments. If your GOP doesn't divide your segment duration, segments drift off the target and your EXT-X-TARGETDURATION climbs.
Check the manifest:
$ head -8 stream.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:7
#EXT-X-MEDIA-SEQUENCE:412
#EXT-X-INDEPENDENT-SEGMENTS
#EXTINF:6.000000,
segments/seg_00412.ts
EXT-X-MEDIA-SEQUENCE: 412 climbing on each refresh with the entry count staying at 100 means the sliding window is working.
2. Sliding window vs event playlist
There's a second shape, and it's a design decision, not a tuning knob.
# full-event DVR: append-only, nothing is deleted
ffmpeg -i rtmp://localhost/live/stream \
... -f hls -hls_time 6 -hls_playlist_type event stream.m3u8
-hls_playlist_type event emits #EXT-X-PLAYLIST-TYPE:EVENT and forces hls_list_size to 0, so the playlist contains every segment since the stream started.
| Sliding window | Event playlist | |
|---|---|---|
| History | bounded | full |
| Manifest size | constant | grows all stream |
| Storage | bounded | grows all stream |
| Good for | 24/7 channels | bounded broadcasts |
| Gotcha | history falls off | see below |
⚠️ The event-playlist gotcha: some players refuse to seek in an EVENT playlist until
#EXT-X-ENDLISTappears, which only happens when the stream ends. There's a long-running video.js issue on exactly this (videojs/video.js#8856) and the same pattern has shown up elsewhere. Test on your real target players, not just desktop Chrome, before you commit to EVENT.
For the rest of this build we use the sliding window.
3. The check nobody runs: does storage agree?
Your playlist advertises a seekable range. Three systems have to back it up, and they're configured by different people at different times:
-
Playlist:
hls_time × hls_list_size= 600s -
Origin disk:
(hls_list_size + hls_delete_threshold) × hls_time= 630s - Object storage lifecycle: whatever the bucket policy says
If (3) is shorter than (1), your scrubber offers a range that 404s. Assert it in CI:
// scripts/check-dvr-consistency.mjs
const HLS_TIME = 6;
const HLS_LIST_SIZE = 100;
const HLS_DELETE_THRESHOLD = 5;
const STORAGE_TTL_SECONDS = 3600; // read this from your IaC, don't hardcode
const advertised = HLS_TIME * HLS_LIST_SIZE;
const onDisk = HLS_TIME * (HLS_LIST_SIZE + HLS_DELETE_THRESHOLD);
const problems = [];
if (advertised > onDisk) {
problems.push(`playlist advertises ${advertised}s but origin keeps ${onDisk}s`);
}
if (advertised > STORAGE_TTL_SECONDS) {
problems.push(`playlist advertises ${advertised}s but storage TTL is ${STORAGE_TTL_SECONDS}s`);
}
if (problems.length) {
console.error("DVR window inconsistent:\n " + problems.join("\n "));
process.exit(1);
}
console.log(`DVR window OK: ${advertised}s advertised, ${onDisk}s on disk`);
$ node scripts/check-dvr-consistency.mjs
DVR window OK: 600s advertised, 630s on disk
Nine lines of assertion. It catches the failure mode that support tickets are made of.
4. Read the window in hls.js
Now the player side. hls.js gives you the live edge and the seekable range; you have to turn that into UI.
// src/dvr.js
import Hls from 'hls.js';
export function attachDvr(video, src) {
const hls = new Hls({
// how far behind the live edge we sit, as a multiple of EXT-X-TARGETDURATION
liveSyncDurationCount: 3, // default is 3
// liveSyncDuration: 18, // seconds-based alternative; takes precedence
backBufferLength: 90,
});
hls.loadSource(src);
hls.attachMedia(video);
const state = { hasDvr: false, windowSeconds: 0 };
hls.on(Hls.Events.LEVEL_LOADED, (_evt, data) => {
const details = data.details;
state.hasDvr = details.live && details.totalduration > 30;
state.windowSeconds = details.totalduration;
});
return { hls, state };
}
details.totalduration on a live level is the length of the current playlist, which is your DVR window. It moves as the window slides, so read it on every LEVEL_LOADED rather than caching it once.
5. A seek bar that tracks a moving target
The trap: on a live stream, video.duration is Infinity and seekable.start(0) increases over time. A normal <input type=range> bound to currentTime / duration does nothing useful.
Map the seekable range instead:
// src/dvr-ui.js
export function wireScrubber(video, hls, els) {
const { scrubber, liveBadge, goLiveBtn, behindLabel } = els;
function seekableRange() {
if (!video.seekable.length) return null;
return {
start: video.seekable.start(0),
end: video.seekable.end(video.seekable.length - 1),
};
}
function render() {
const range = seekableRange();
if (!range) return;
const span = range.end - range.start;
if (span <= 0) return;
// position within the window, 0..1
const pos = (video.currentTime - range.start) / span;
if (!scrubber.matches(':active')) {
scrubber.value = String(Math.max(0, Math.min(1, pos)));
}
// how far behind the live edge are we?
const behind = Math.max(0, hls.liveSyncPosition - video.currentTime);
const atLive = behind < 10; // one-and-a-bit segments of slack
liveBadge.classList.toggle('is-live', atLive);
goLiveBtn.hidden = atLive;
behindLabel.textContent = atLive ? 'LIVE' : `-${formatClock(behind)}`;
}
scrubber.addEventListener('input', () => {
const range = seekableRange();
if (!range) return;
video.currentTime = range.start + Number(scrubber.value) * (range.end - range.start);
});
video.addEventListener('timeupdate', render);
video.addEventListener('progress', render);
return render;
}
function formatClock(seconds) {
const s = Math.floor(seconds % 60).toString().padStart(2, '0');
const m = Math.floor(seconds / 60);
return `${m}:${s}`;
}
Two details worth calling out. We skip updating scrubber.value while the user is dragging (:active), otherwise timeupdate fights their thumb. And the live badge uses a tolerance, because being 3 seconds behind the edge is normal live playback, not rewind.
6. The go-live button (this is the one that stalls)
The obvious implementation is wrong:
// ❌ lands you at the newest byte with no buffer ahead, then stalls
video.currentTime = video.seekable.end(video.seekable.length - 1);
Use liveSyncPosition. hls.js computes it as the live edge minus a deliberate safety margin (liveSyncDurationCount × EXT-X-TARGETDURATION, default multiple of 3):
// ✅ src/go-live.js
export function goLive(video, hls) {
const target = hls.liveSyncPosition;
if (typeof target === 'number' && Number.isFinite(target)) {
video.currentTime = target;
} else if (video.seekable.length) {
// fallback: back off from the edge ourselves
const end = video.seekable.end(video.seekable.length - 1);
video.currentTime = Math.max(0, end - 3 * (hls.levels[hls.currentLevel]?.details?.targetduration ?? 6));
}
if (video.paused) video.play();
}
💡 Tip: 3 to 4 segment durations behind the edge is where most deployments land. Closer and ordinary delivery jitter starves the buffer; further and you're needlessly behind. Tune
liveSyncDurationCount, don't fight it in your seek code.
7. Handle the window sliding out from under a paused viewer
A viewer pauses inside the window, walks away, comes back 10 minutes later and presses play. Their position no longer exists.
// src/window-recovery.js
export function wireWindowRecovery(video, hls, onRecover) {
function checkPlayhead() {
if (!video.seekable.length) return;
const start = video.seekable.start(0);
if (video.currentTime < start) {
// we fell out of the window while paused
video.currentTime = start + 2; // small cushion past the edge
onRecover?.();
}
}
video.addEventListener('play', checkPlayhead);
video.addEventListener('seeked', checkPlayhead);
hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR && data.response?.code === 404) {
// a segment we asked for is already gone; jump to the current window start
checkPlayhead();
}
});
}
You want the visible recovery, not the silent one. Tell the viewer their position expired and you moved them, or they'll think the player jumped at random.
$ # simulate it: pause, wait past the window, press play
[dvr] playhead 412.8s fell behind window start 640.2s, recovering
Wrapping up
The whole feature is four decisions:
-
Window length:
hls_time × hls_list_size, chosen on purpose. 30s for replaying a goal, 10min for a 24/7 channel, event playlist for a bounded broadcast. - Consistency: assert playlist ≤ origin ≤ storage TTL in CI.
-
Go live:
liveSyncPosition, neverseekable.end(). - Recovery: detect a playhead that's fallen out of the window and move it visibly.
What's next: if you're on a 24/7 channel, look at whether a rolling VOD archive alongside the live window serves your use case better than a very long DVR (cheaper storage class, no manifest growth). And if you're targeting native, be aware that seeking inside a moving live window has its own long history on other platforms, including an ExoPlayer issue thread (google/ExoPlayer#87) that's worth reading before you assume parity with the web.
Top comments (0)