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
The two flags that matter for this build:
-
-hls_flags +program_date_timewrites anEXT-X-PROGRAM-DATE-TIMEtag per segment, which is what player-side latency math needs. -
-g 60pins 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
#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
...
Serve the folder (segments and the player page from the same origin keeps CORS quiet):
python3 -m http.server 8000 -d out
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>
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
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:
-
Segment duration: rerun with
-hls_time 6and-g 180. Latency roughly triples. This is the classic ~30s HLS default experience: three 6-second segments of sync distance plus buffer. -
Player sync target: change
liveSyncDurationCount: 3to1. hls.js parks you closer to the edge; rebuffer risk rises accordingly. That's the whole latency/stability trade in one line. - 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
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: trueflag 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)