Tune LL-HLS for low-latency
without losing the live edge.
A working guide to the LL-HLS optimization pitfalls Streamwake catches on real delta playlists — partial-segment timing drift, BLOCKING vs delta request mistakes, CAN-PRELOAD / EXT-X-PRELOAD-HINT placement, SERVER-CONTROL PART-HOLD-BACK tuning, segment vs PART-duration mismatch, and the encoder GOP alignment required to keep PART boundaries on keyframes.
Book a technical demo for LL-HLS optimization
Read the postmortem — then bring your own incident to Streamwake.
Two ways to engage on this exact failure pattern: book a 30-minute technical demo where we walk through the probe cascade on your source, or hand us an archived incident and watch the agent diagnose it end-to-end.
Both routes land on the scoping intake form — no SDR gate.
What Streamwake checks
Five families of probes, each with a small, deterministic pass/fail verdict that directly reads the playlist and the publish cadence. Every check has a name — that's the name you'll see on the agents feed.
EXT-X-PART-INF:PART-TARGETread off the variant head, divided into observed PART-DURATION jitter.- Each PART-DURATION must stay within ±0.05s of PART-TARGET for the whole window.
- Jitter past the threshold is the smoking gun for encoder / packager cadence drift.
_HLS_msn+_HLS_partmatch the next part the server returns on every delta request.- Delta responses should return under 200B p95; full BLOCKING responses over 3KB are a regression.
- BLOCKING budget: must complete in < 3 × TARGETDURATION + 1s RTT headroom.
EXT-X-PRELOAD-HINTplacement matches SERVER-CONTROLCAN-PRELOAD-BEFORE.- Hints must reference a part the server has NOT yet published or that sits inside the PART-HOLD-BACK window.
- Mismatched hint vs SERVER-CONTROL is a fail — the hint is misleading the player.
EXT-X-SERVER-CONTROL: CAN-BLOCK-RELOAD + PART-HOLD-BACK + HOLD-BACK read from the variant head.- Measured playback-head window must sit within PART-HOLD-BACK (no over-buffering or starvation).
- Missing CAN-BLOCK-RELOAD regresses the connection to BLOCKING-only; the probe fails on any variant that drops the field.
- Every keyframe must sit at the start of an
EXT-X-PART— mid-PART IDR is a re-buffer risk. - GOP period must divide cleanly into TARGETDURATION; 2.0s GOP with 6s TARGET makes for 6 parts per GOP.
- Encoder cadence drift > ±0.05s fails the probe — packager cannot honor PART-TARGET.
Anatomy of an LL-HLS media playlist
A realistic LL-HLS variant playlist with PART-TARGET=0.33, SERVER-CONTROL declaring CAN-BLOCK-RELOAD + PART-HOLD-BACK, a full segment of 18 PARTs, and a PRELOAD-HINT for the next part. The probe table on the left names exactly which field each probe reads.
#EXTM3U
#EXT-X-VERSION:9
#EXT-X-TARGETDURATION:6
#EXT-X-PART-INF:PART-TARGET=0.33
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=1.0,HOLD-BACK=4.0
#EXT-X-MEDIA-SEQUENCE:1237
#EXT-X-MAP:URI="init.mp4"
#EXTINF:6.000,
seg1237.m4s
#EXT-X-PROGRAM-DATE-TIME:2026-08-06T10:42:10.000Z
#EXT-X-PART:DURATION=0.330,URI="seg1237.0.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.1.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.2.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.3.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.4.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.5.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.6.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.7.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.8.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.9.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.10.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.11.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.12.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.13.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.14.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.15.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.16.m4s"
#EXT-X-PART:DURATION=0.330,URI="seg1237.17.m4s"
#EXTINF:6.000,
seg1238.m4s
#EXT-X-PROGRAM-DATE-TIME:2026-08-06T10:42:16.000Z
#EXT-X-PART:DURATION=0.330,URI="seg1238.0.m4s"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="seg1239.0.m4s"GET /live/event/llhls-variant.m3u8?_HLS_msn=1239&_HLS_part=2
HTTP/2 200
content-type: application/vnd.apple.mpegurl
date: Thu, 06 Aug 2026 10:42:17.500Z
cache-control: max-age=0
server-timing: _HLS_msn=1239; _HLS_part=2; part_drift_ms=0
x-cache: MISS
# returning full delta UPDATE the server could have shipped
# in a header-only response — 4.9KB returned, ~80 bytes needed# Encoder cadence vs EXT-X-PART-INF
expected_part_target=0.330
observed: 0.000, 0.330, 0.330, 0.330, 0.660, 0.330,
0.330, 0.330, 0.330, 0.330, 0.330, 0.330,
0.330, 0.330, 0.330, 0.660, 0.330, 0.990
# PART-DURATION jitter on the 0.660 and 0.990 entries —
# encoder GOP slipped and dragged the parts past PART-TARGET.- part.timing_consistent →
EXT-X-PART DURATIONvsEXT-X-PART-INF PART-TARGET - playlist.delta_updates →
_HLS_msn,_HLS_part, response body size - playlist.blocking→ request method + payload size + RTT (BLOCKING > 3KB after a delta cursor exists = fail)
- preload.can_preload_hint →
EXT-X-PRELOAD-HINT TYPEplusCAN-PRELOAD-BEFORE - server_control.target_latency_window →
EXT-X-SERVER-CONTROLfields - gop.boundary_aligned →
EXT-X-PARTstart times vs GOP keyframe timestamps
The variant playlist is the canonical LL-HLS shape: EXT-X-PART-INF:PART-TARGET=0.33 tells the player where its parts will land, EXT-X-SERVER-CONTROL opens the BLOCKING channel and gates PART-HOLD-BACK, and every EXT-X-PART entry is one PART-TARGET of media the player can fetch out of order.
The whole idea of LL-HLS is that the client can keep requesting small deltas — the _HLS_msn + _HLS_part query on the GET line. When the server returns a tiny ~80-byte delta, latency stays low. When a delta cursor breaks and the client requests a full BLOCKING reload (the trace above), the server sends back the full playlist and the player stalls for the BLOCKING budget — playlist.blocking flags the regression.
The part-cadence trace on the left is the encoder drift trace: PART-DURATION oscillates past PART-TARGET (one cycle to 0.660, one cycle to 0.990). That's the exact pattern the agent surfaces under encoder.cadence_part_compatible. The fix is almost always encoder-side: lock GOP duration against PART-TARGET × N so the packager cannot be asked to fill a part from a 2.5s GOP when the target is 0.33s.
A delta response is the new + the last MEDIA-SEQUENCE worth of deltas — typically a single new EXT-X-PART line plus the trailing EXT-X-PRELOAD-HINT. A BLOCKING response is the entire served window back to the playback head. The probe counts bytes: > 3KB on a delta cursor means the server failed to honor CAN-BLOCK-RELOAD.
Ten ways LL-HLS optimization goes wrong
Each row: symptom the player reports → the underlying cause → a fix that holds under the next probe cycle. The named probes are what you'd grep for in the agents feed.
Player sees PART inflight but the next PART arrives 0.6–0.9s after the previous one instead of the declared PART-TARGET=0.33. Latency rises sharply; the player stutters because it waits for parts that the encoder dragged longer.
Encoder GOP slipped out of rhythm with the partial-segment packager. EXT-X-PART-INF declared PART-TARGET=0.33 but the encoder emits 2.5s GOPs under load, and the packager is forced to fill each PART with ~0.66s of media. part.timing_consistent flags the proportional drift.
Tighten the encoder cadence so GOP duration is an exact multiple of PART-TARGET (PART-TARGET=0.33 with GOP=2.0 aligns as 6 parts per GOP). Reconfigure the packager to refresh PART-DURATION each cycle; the part.timing_consistent probe reports bounded jitter once the cadence is in lock-step.
Edge logs show the player issuing a full BLOCKING playlist GET every refresh instead of a delta request. Cache misses spike; bandwidth spent on each refresh is 4–5KB when ~80B would have been enough.
The client lost its delta cursor — usually because _HLS_msn/_HLS_part drifted, the server missed a delta window, or the client believed the playlist was stale and triggered a reload. CAN-BLOCK-RELOAD lets the server hold the connection; the client gave up too early.
Tune the delta-timeout on the packager to match the BLOCKING budget (default 3s); when a BLOCKING request returns the full playlist, the server-control_can_block_reload probe should flip to fail and streamwake should report the over-fetch ratio.
A preload hint is in the playlist — TYPE=PART for the next part the encoder is about to publish — but the SERVER-CONTROL block says CAN-PRELOAD-BEFORE=false (or is missing). Players that trust the hint issue a request, the 404 fires, and the player mis-attributes the failure.
Packager inconsistency: the hint tag is being emitted by the partial-segment emitter, but the SERVER-CONTROL block was authored by a different upstream module that does not "see" the same publish pipeline. The two pieces disagree on whether preload is allowed.
Make SERVER-CONTROL authoritative — every playlist writer must read CAN-PRELOAD-BEFORE from the same source as the hint emitter. preload.can_preload_hint should fail across any playlist where the hint and the SERVER-CONTROL disagree.
Every few segments the player drops into a discontinuity. PDT jumps by 0.5–1.0s at the boundary, even though no EXT-X-DISCONTINUITY tag is in the playlist. ABR ladder descends because the time mismatch breaks the buffer math.
TARGETDURATION=6 was set but the packager emitted 6.5s segments. PART-TARGET=0.33 over 6s = 18 parts, but 18 × 0.33 = 5.94 < 6, leaving ~60ms unaccounted for; the packager rolled the leftover into the next segment, shifting PDT.
Either tune PART-TARGET so the residual is divisible (18 × 0.33 = 5.94 → PART-TARGET=0.333 for 18 parts = 6.00), or lower TARGETDURATION to PART-TARGET * floor(segment / PART-TARGET). The part.timing_consistent probe must flag mismatches between TARGETDURATION and PART-TARGET * N.
Player reports frequent re-buffer events at the start of every sixth part. Codec profiler shows a keyframe mid-PART, so the player rolls forward to the next IDR but the partial append is missing the leading frame.
Encoder GOP period and packager PART cadence are not locked. The encoder emits a 2.0s GOP, but the packager is splitting on inference — parts occasionally start in the middle of a frame. gop.boundary_aligned fails whenever a keyframe index lands inside a PART instead of at its boundary.
Lock the encoder to a fixed GOP that divides evenly into TARGETDURATION, hand that cadence to the packager, and force partial splitters to start at every keyframe. The gop.boundary_aligned probe flips to pass when every PART starts with an IDR.
The variant playlist lacks the SERVER-CONTROL block entirely (no PART-HOLD-BACK, no CAN-BLOCK-RELOAD). Players default to legacy HLS timing, stop asking for delta updates, and revert to a full BLOCKING request every refresh.
The packager detected the partial-segment emission but did not emit the SERVER-CONTROL block at the variant head. Most often a config-flag (SERVER-CONTROL_ENABLED=false), or the playlist is being proxied by an upstream cache that dropped the line.
Make the SERVER-CONTROL block a required emission on every LL-HLS variant. server_control.target_latency_window should fail on any variant missing CAN-BLOCK-RELOAD or PART-HOLD-BACK — both fields are required for the delta vs reload decision.
Latency glass shows either 8–10 seconds of glass-to-glass delay (over-buffered) or 0.3 seconds (starved, dropping in the playback core). Both happen on the same source within minutes of each other.
PART-HOLD-BACK was tuned once and never re-tuned. As segment duration shifted (4s → 6s) and GOP cadence was changed to accommodate higher resolutions, the original PART-HOLD-BACK value either over-anchored the playback head or let it chase the live edge too aggressively.
Tune PART-HOLD-BACK against the measured playback-head window — the agent reports the actual window in server_control.target_latency_window. As a rule, PART-HOLD-BACK should sit between 1.0× and 1.5× the measured jitter; tune up if over-buffered, down if starved.
Player keeps asking for a part the server already deleted from the playlist. Server returns 410 Gone or a full reload. Cursor TTL is permanently wrong because the player believes it is still behind.
Client cursor (_HLS_msn/_HLS_part) advanced past the server's bookkeeping window — usually because the client was paused and the server removed 30+ parts from the tail of the playlist while paused. The cursor is no longer valid; the server cannot fulfill a delta, so it returns 410.
Force a full playlist reload after any pause > PART-HOLD-BACK × 3, or have the agent monitor part-cursor deltas and prompt a reload proactively. The playlist.delta_updates probe should treat 410 responses as fail and run a fresh playlist fetch.
PLAYER_LOGS show playlist_load timeout at 6s; the player stalls for the reload window, then attempts a full BLOCKING request, which itself times out. Latency spikes to ~12s before the first frame re-paints.
CAN-BLOCK-RELOAD=YES but the BLOCKING budget is too short for the edge round-trip — typically because edge POP RTT rose under load, or the playlist itself grew past the budget due to long EXTINF lists. blocking-playlist.timeout fires before the playlist returns.
Raise the BLOCKING budget (the HLS spec default is 3 * TARGETDURATION), or move the player to a delta-first workload under higher POP RTT. blocking-playlist.timeout should flag timeouts shorter than 3× TARGETDURATION + 1s RTT headroom.
Player preloads a part the encoder already finished; cache miss for the part, then a redundant fetch right after. HTTP request rate roughly doubles on the live edge during preload windows.
Packager emitted the preload hint just before becoming aware that the part had already been published (only possible with a 0.0s PART-TARGET or an encoder publishing ahead of PART-HOLD-BACK). The hint references a URL the player no longer needs.
Filter hints against the published MSD — preload.can_preload_hint rejects any hint whose URI is already covered by an EXTINF in the playlist. The probe should report hint_vs_published_uri diff every cycle; resolve by aligning the packager pipeline.
Diagnose with Streamwake
Register the LL-HLS probe against POST /api/v1/streams, then read the agent timeline back through GET /api/v1/agents. The probe verdicts in the timeline are exactly the rows above — part cadence, delta vs BLOCKING, preload, server control, GOP alignment.
The curl below registers an LL-HLS source URL with a 30-second probe cadence. The HLS+LL protocol routing makes the agent run the part cadence / delta / preload / server control / GOP probes on every refresh, plus the agent timeline the curl below lists back is the canonical verdicts summary you'll see when the playlist is in a healthy delta mode with one warning lane.
The cookie is the same better-auth.session_token that gates every /api/v1/* call — see the auth guide for how to mint one.
curl -X POST https://streamwake.polsia.io/api/v1/streams \
-H "content-type: application/json" \
-b "better-auth.session_token=<your-session-cookie>" \
-d '{
"sourceUrl": "https://cdn.example.com/live/event/llhls-variant.m3u8",
"protocol": "HLS+LL",
"probeIntervalSeconds": 30,
"agents": [
"part.timing_consistent",
"playlist.delta_updates",
"playlist.blocking",
"preload.can_preload_hint",
"preload.hint_for_unpublished_part",
"server_control.target_latency_window",
"server_control.can_block_reload",
"gop.boundary_aligned",
"encoder.cadence_part_compatible"
]
}'curl https://streamwake.polsia.io/api/v1/agents?stream_id=<id> \
-b "better-auth.session_token=<your-session-cookie>"{
"stream_id": "cklivellhlsplanner891",
"source": "https://cdn.example.com/live/event/llhls-variant.m3u8",
"protocol": "HLS+LL",
"playlist_mode": "delta",
"checks": [
{
"probe": "part.timing_consistent",
"result": "pass",
"detail": "PART-DURATION stays within +/-0.05s of PART-TARGET=0.330 across 18 parts in seg1237; part drift bounded"
},
{
"probe": "playlist.delta_updates",
"result": "pass",
"delta_responses": 24,
"delta_bytes_p50": 78,
"delta_bytes_p95": 142,
"detail": "next_msn matches _HLS_msn in 24/24 probes; delta headers return under 200B p95"
},
{
"probe": "playlist.blocking",
"result": "warn",
"blocking_requests": 2,
"blocking_ms_p95": 3840,
"detail": "client fell back to a BLOCKING (full playlist) request twice — DNS hiccup, retry took 3.84s (above 3s BLOCKING budget)"
},
{
"probe": "preload.can_preload_hint",
"result": "pass",
"detail": "CAN-PRELOAD-BEFORE=YES in client manifest; EXT-X-PRELOAD-HINT TYPE=PART present for seg1239.0.m4s"
},
{
"probe": "preload.hint_for_unpublished_part",
"result": "pass",
"detail": "preload hint references a part the server has not yet published — align with PART-HOLD-BACK=1.0s window"
},
{
"probe": "server_control.target_latency_window",
"result": "pass",
"part_hold_back_s": 1.0,
"hold_back_s": 4.0,
"measured_window_s": 1.04,
"detail": "PART-HOLD-BACK=1.0 matches the measured playback-head window — no over-buffering"
},
{
"probe": "server_control.can_block_reload",
"result": "pass",
"can_block_reload": "YES",
"detail": "CAN-BLOCK-RELOAD=YES — client may hold the connection open for delta updates"
},
{
"probe": "gop.boundary_aligned",
"result": "pass",
"gop_period_s": 2.0,
"part_count_per_gop": 6,
"detail": "every keyframe sits at the start of an EXT-X-PART — no mid-PART GOP slip on the captured window"
},
{
"probe": "encoder.cadence_part_compatible",
"result": "fail",
"detail": "encoder GOP slipped to ~2.5s for two cycles; PART-DURATION jitter to 0.660 and 0.990 — PART-INF PART-TARGET=0.330 cannot be honored"
}
]
}Want Streamwake to catch this on its own?
Sign up, register an LL-HLS probe, and the same cadence + delta + preload + server control + GOP probes that produced the timeline above run on every refresh — and surface in a Slack channel, a webhook, or the streams dashboard.
- Pick a recent on-call incident — manifest stall, edge miss, player-side stall, or peer congestion.
- We replay it through the same reliability-agent probe cascade used on the postmortem above.
- You walk away with a written what-could-have-been-Automated readout, not a sales deck.
Read the next protocol guide
The other live entries cover the canonical protocol-by-protocol failure patterns.