DEV Community

Mason K
Mason K

Posted on

Add a DVR window to your live HLS stream without 404ing the scrubber

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 liveSyncPosition instead 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_size default 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

-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-ENDLIST appears, 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:

  1. Playlist: hls_time × hls_list_size = 600s
  2. Origin disk: (hls_list_size + hls_delete_threshold) × hls_time = 630s
  3. 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`);
Enter fullscreen mode Exit fullscreen mode
$ node scripts/check-dvr-consistency.mjs
DVR window OK: 600s advertised, 630s on disk
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

💡 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();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Wrapping up

The whole feature is four decisions:

  1. 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.
  2. Consistency: assert playlist ≤ origin ≤ storage TTL in CI.
  3. Go live: liveSyncPosition, never seekable.end().
  4. 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)