DEV Community

Mason K
Mason K

Posted on

Build a live latency HUD with ffmpeg, a burned-in clock, and hls.js

TL;DR

We'll measure live stream latency two honest ways at once: a UTC clock burned into the video (true glass-to-glass) and EXT-X-PROGRAM-DATE-TIME math surfaced by hls.js (hls.latency). You'll end up with a local test stream, a player page with a latency HUD, and a clear picture of where your seconds actually go.

Most teams quote their protocol's brochure latency ("HLS is ~30s, LL-HLS is single digits") and have never measured their own. Let's fix that locally in about twenty minutes, with tools you already have: FFmpeg 7.0+ and hls.js 1.6+.

1. Generate a live stream with a clock burned in 🕐

The oldest trick in streaming QA is still the best: put the current time inside the video. Any latency measurement scheme can lie to you; pixels can't.

# make-stream.sh
mkdir -p out
ffmpeg -f lavfi -i "testsrc2=size=1280x720:rate=30" \
  -vf "drawtext=text='UTC %{gmtime\:%H\\\:%M\\\:%S}':fontsize=64:fontcolor=white:box=1:boxcolor=black@0.6:x=(w-tw)/2:y=h-th-40" \
  -c:v libx264 -preset veryfast -tune zerolatency -g 60 \
  -f hls \
  -hls_time 2 \
  -hls_list_size 6 \
  -hls_flags delete_segments+program_date_time \
  out/live.m3u8
Enter fullscreen mode Exit fullscreen mode

The two flags that matter for this build:

  • -hls_flags +program_date_time writes an EXT-X-PROGRAM-DATE-TIME tag per segment, which is what player-side latency math needs.
  • -g 60 pins the GOP to 2 seconds at 30fps, matching -hls_time 2. Segment duration can never be shorter than your GOP; this pairing is a latency decision, not a detail.

Sanity-check the playlist while it runs:

cat out/live.m3u8
Enter fullscreen mode Exit fullscreen mode
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:2
#EXT-X-PROGRAM-DATE-TIME:2026-08-31T09:14:32.640+0000
#EXTINF:2.000000,
live0042.ts
...
Enter fullscreen mode Exit fullscreen mode

Serve the folder (segments and the player page from the same origin keeps CORS quiet):

python3 -m http.server 8000 -d out
Enter fullscreen mode Exit fullscreen mode

2. The player page with a latency HUD 📺

<!-- out/player.html -->
<!doctype html>
<video id="v" controls muted autoplay style="width:640px"></video>
<div id="hud" style="font:16px monospace; padding:8px"></div>

<script src="https://cdn.jsdelivr.net/npm/hls.js@1.6"></script>
<script>
  const video = document.getElementById("v");
  const hud = document.getElementById("hud");

  const hls = new Hls({ liveSyncDurationCount: 3 }); // default-ish: 3 segments back
  hls.loadSource("/live.m3u8");
  hls.attachMedia(video);

  setInterval(() => {
    // 1) hls.js's own PDT-derived distance from the live edge
    const reported = Number.isFinite(hls.latency) ? hls.latency.toFixed(1) : "n/a";

    // 2) manual check: wall clock minus the PDT of what's on screen
    let manual = "n/a";
    if (hls.playingDate) {
      manual = ((Date.now() - hls.playingDate.getTime()) / 1000).toFixed(1);
    }

    hud.textContent =
      `hls.latency: ${reported}s | manual PDT delta: ${manual}s | ` +
      `buffer: ${(video.buffered.length ? video.buffered.end(video.buffered.length - 1) - video.currentTime : 0).toFixed(1)}s`;
  }, 1000);
</script>
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8000/player.html and you have three numbers updating live:

  • hls.latency: hls.js's estimate of how far you are behind the live edge, derived from PROGRAM-DATE-TIME.
  • manual PDT delta: the same idea computed by hand from hls.playingDate, so you can see there's no magic.
  • buffer: how much insurance the player is holding. Watch how it moves with the other two.

3. Now read the pixels 👀

Here's the payoff. Compare the burned-in UTC clock in the video against your machine's actual clock (date -u in a terminal next to the player):

date -u +%H:%M:%S   # run repeatedly, or: while true; do date -u; sleep 1; done
Enter fullscreen mode Exit fullscreen mode

On my run, the burned-in clock trailed wall time by about 8 seconds while hls.latency said about 6. Both numbers are correct. They answer different questions:

  • The pixel clock measures glass-to-glass: everything from the frame being drawn, through encode, segmentation, and buffering, to render.
  • The PDT math measures from wherever the timestamp was stamped, which here is FFmpeg's segmenter. Encode-side delay upstream of the stamp is invisible to it.

⚠️ Note: this gap gets bigger in production. Hosted platforms typically stamp PROGRAM-DATE-TIME at ingest; Mux, for example, documents that its PDT-based latency metric reads about one second lower than true glass-to-glass for exactly this reason. Neither number is a lie. Know which one your dashboard shows.

💡 Tip: both methods assume clocks agree. If the encoder box and the viewer device disagree by two seconds of NTP drift, every number above silently absorbs it. On a single laptop you're safe; across real infrastructure, check chronyc tracking (or your platform's equivalent) before trusting sub-second readings.

4. Turn the knobs and watch the budget move 🎛️

The HUD makes latency cause-and-effect visible in a way no doc can. Try each of these and watch the numbers:

  1. Segment duration: rerun with -hls_time 6 and -g 180. Latency roughly triples. This is the classic ~30s HLS default experience: three 6-second segments of sync distance plus buffer.
  2. Player sync target: change liveSyncDurationCount: 3 to 1. hls.js parks you closer to the edge; rebuffer risk rises accordingly. That's the whole latency/stability trade in one line.
  3. Pause for 30 seconds, then resume. You resume where you paused; your latency now includes your coffee. Real viewers do this constantly, which is why fleet-wide latency is a distribution, not a number.
Knob Where it lives Typical cost
GOP length encoder seconds, sets the floor for segment size
Segment duration x sync count packager + player the bulk of classic HLS latency
Buffer target player whatever it wants, silently
Viewer behavior (pause, join late) humans unbounded

5. Ship it as a beacon 📡

The HUD proves the math on one machine. The production version is the same three numbers sampled quietly and sent home, because fleet latency is a distribution and your desk is its most flattering sample:

// latency-beacon.js -- add next to the HUD code
function sampleLatency() {
  if (!Number.isFinite(hls.latency)) return;
  const payload = JSON.stringify({
    t: Date.now(),
    latency: +hls.latency.toFixed(2),
    target: hls.targetLatency ?? null,
    buffered: video.buffered.length
      ? +(video.buffered.end(video.buffered.length - 1) - video.currentTime).toFixed(2)
      : 0,
    // whatever session/stream IDs your analytics already use:
    sessionId: window.SESSION_ID,
  });
  navigator.sendBeacon("/telemetry/latency", payload);
}

setInterval(sampleLatency, 15_000);
window.addEventListener("pagehide", sampleLatency); // last word on the way out
Enter fullscreen mode Exit fullscreen mode

sendBeacon survives tab closes and costs nothing on the render path. Fifteen-second sampling is plenty; you're charting a distribution, not tracing frames. On the backend, keep p50 and p95 per stream, and put them on the same dashboard as rebuffer rate so every latency conversation happens next to its trade-off.

💡 Tip: also record why a session drifted when you can. A paused: true flag on samples taken within a minute of a pause event separates "our pipeline got slower" from "viewers make coffee," and those need very different meetings.

What's next

  • Chart the percentiles. Once the beacon lands, p95 live latency across real viewers is the number to watch; it's where the spoiler complaints live.
  • Go low-latency for real. Partial segments and blocking playlist reload (LL-HLS) can bring the packager's share down to low single digits; the HUD you just built works unchanged and will tell you honestly whether your config delivers it.
  • Watch both curves. Any latency win you take, verify against rebuffer rate. They're the same dial, viewed from opposite sides.

The whole point of the HUD is that "we're about five seconds" stops being a vibe. It becomes a number you watched move.

Top comments (0)