Recover a stalled player startup
without guessing.
A working guide to the patterns Streamwake catches on a real player startup — manifest fetch, JS / MSE init, codec negotiation, autoplay policy, first-frame decode — split into the three diagnostic lanes (network, init, decode) so an on-call engineer can read it next to a recent timeline capture and act on it.
Book a technical demo for Player startup
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.
Three lanes, three cascades
A startup is a tiny ordered cascade — the manifest fetches, the engine attaches, the decoder paints. Streamwake names each step and orders the failures accordingly: when one lane fails, the next lane's probes stay pending instead of double-counting.
- Master playlist returns HTTP 200 within the probe budget (default 5s).
- TLS 1.3 completes, HTTP/2 negotiated, ALPN h2 selected.
- CORS preflight / segment-init OPTIONS returns 204 with allow-origin headers.
- JS engine attaches within 3500ms (hls.js / Shaka / AVPlayer).
- MSE SourceBuffer opens with the CODECS string the variant STREAM-INF declared.
- EME key system attaches (Widevine / PlayReady / FairPlay) within 4500ms.
- Codec negotiation matches CODECS to bytes-on-the-wire (init.mp4 ftyp vs STREAM-INF).
- First frame painted under 4500ms of attach.
- SourceBuffer stays under the platform quota through the live window.
Anatomy of a startup timeline
A timeline the agent captured on a real HLS session: manifest fetch returns green, TLS and CORS preflight return green, but the init lane stalls — player.init.hls_attached fails at t=1826ms. Every probe downstream of the failing event is pending — the agent does not double-count them as failures.
{
"stream_id": "ckliveplaystartup7421",
"source": "https://cdn.example.com/live/event/manifest.m3u8",
"protocol": "HLS+PLAYER",
"viewport": "eu-west",
"session_started_at": "2026-08-06T10:42:13.412Z",
"events": [
{
"event": "player.network.manifest_fetch",
"at_ms": 0,
"verdict": "pass",
"detail": "manifest returned 200, application/vnd.apple.mpegurl, 4.2KB, x-cache=HIT"
},
{
"event": "player.network.tls_handshake",
"at_ms": 38,
"verdict": "pass",
"detail": "TLS 1.3 completed in 38ms with HTTP/2 negotiated"
},
{
"event": "player.network.cors_preflight",
"at_ms": 41,
"verdict": "pass",
"detail": "OPTIONS segment-init returned 204 with Access-Control-Allow-Origin: *"
},
{
"event": "player.init.hls_attached",
"at_ms": 1826,
"verdict": "fail",
"detail": "hls.js failed to attach within 3500ms; device.browser = Safari 17.5 / Mobile; the engine reported 'destroying MediaSource' twice before BUFFER_APPENDED"
},
{
"event": "player.init.sourcebuffer_open",
"at_ms": null,
"verdict": "pending",
"detail": "never reached; the init lane stalled before MSE.addSourceBuffer resolved"
},
{
"event": "player.autoplay.gesture_required",
"at_ms": null,
"verdict": "pending",
"detail": "never evaluated; the init-lane stall prevented playback from advancing into the autoplay-gate check"
},
{
"event": "player.decode.codec_negotiation",
"at_ms": null,
"verdict": "pending",
"detail": "never evaluated; decode lane cannot start until SourceBuffer is open"
},
{
"event": "player.decode.first_frame_ms",
"at_ms": null,
"verdict": "pending",
"detail": "never evaluated"
}
],
"summary": {
"failing_lane": "init",
"first_failing_event": "player.init.hls_attached",
"at_ms": 1826,
"downstream_events_skipped": [
"player.init.sourcebuffer_open",
"player.autoplay.gesture_required",
"player.decode.codec_negotiation",
"player.decode.first_frame_ms"
]
}
}- player.network.manifest_fetch → master playlist response (200 vs timeout)
- player.network.tls_handshake → TLS ALPN-selected cipher + RTT
- player.network.cors_preflight → OPTIONS segment-init response
- player.init.hls_attached → engine attach completion within 3500ms
- player.init.sourcebuffer_open → MSE
addSourceBufferresolve - player.init.drm_attach → EME key system attach within 4500ms
- player.decode.codec_negotiation → STREAM-INF CODECS vs init.mp4 ftyp
- player.decode.first_frame_ms → time from attach to first paint
The cascade is ordered: a failure on hls_attached clears the manifest, TLS, and CORS preflight probes out of the way (they all returned green) and tells the on-call the issue is the engine-to-MSE attachment — not the manifest URL, not the CDN, not the network.
The summary block at the bottom is the diagnostic headline — failing_lane picks one of the three lanes, first_failing_event is the leaf that triggered the lane, and downstream_events_skipped names every probe the agent did NOT run because the prior lane failed.
This is the trick the agent does: it never fails on the same symptom twice. A 200 vs a timeout on the manifest are not "two failures" against a stalled startup — they are one. The cascading summary tells you exactly which leaf to act on.
If the engine never attached, first_frame_ms has no value to report — it cannot fail, because the player never tried. Marking it pending keeps the timeline honest and avoids false alarms in an alerting feed.
Ten ways a player startup stalls
Each row: the symptom a player reports → the probe pattern that names the failing lane → the fix that holds under the next probe cycle. The lane badge is the single most useful signal — the rest of the row trusts it.
Player appears to "do nothing" for several seconds before throwing MediaError. curl from the same edge returns the master playlist in <300ms; no JS-level error fires.
player.network.manifest_fetch → fail (timed out at 5000ms); player.network.tls_handshake → fail; player.network.cors_preflight → fail. Decode and init probes are pending because the manifest never returned.
Verify the manifest URL from the failing edge IP block (NOT a healthy POP). Confirm preflight headers are present on segment-init and that the CDN does not 304 the master on the cold first-request. The agent never starts the probe cascade without a 200 from manifest_fetch.
Console shows two "destroying MediaSource" logs before BUFFER_APPENDED; the player never reaches FIRST_FRAME. manifest fetch, TLS, and CORS preflight all return green.
player.network.* → pass (manifest, TLS, CORS); player.init.hls_attached → fail; player.init.sourcebuffer_open → never reached. Decode lane is gated behind init — its probes are pending.
Pin the engine build to a version that handles iOS / Safari 17 MediaSource detach correctly; the typical fix is upgrading hls.js to the latest minor and enabling enableWorker=true in the config. The agent will then run sourcebuffer_open and report its verdict on the next probe.
Player reaches FIRST_FRAME after a short stall then drops back to a black canvas with a "video can't be played" overlay. Audio plays; video is blank or frozen.
player.network.* → pass; player.init.* → pass (hls_attached + sourcebuffer_open both green); player.decode.codec_negotiation → fail; player.decode.first_frame_ms → fail at t=4631ms.
Confirm the variant CODECS string covers every referenced audio group ("avc1.640028" alone is the common culprit — full string is "mp4a.40.2,avc1.640028"). codec_negotiation passes on the next probe once the audio codec is included in every STREAM-INF.
Player attaches, source buffer opens, but the video never starts. Browser has muted the page by autoplay policy; user has not interacted with the document yet.
player.network.* + player.init.* + player.decode.codec_negotiation → pass; player.autoplay.gesture_required → fail (no user gesture on document); player.decode.first_frame_ms → pending (decoded, then muted by policy).
The probe correctly fails on the autoplay gate — do NOT bypass it by setting autoplay=true. Surface the play button with a mute toggle so the first user gesture is captured into the SAME gesture token the browser consumes; autoplay will flip to pass on the next probe after the gesture fires.
Player attaches, MSE opens, codec negotiation passes, but the decoded first frame never paints. Network stalls at first-frame.ts — players that poll for it more than once will dead-lock.
player.network.manifest_fetch + player.init.* → pass; player.decode.codec_negotiation → pass; player.decode.first_frame_ms → fail at t=4972ms (timeout 4500ms).
Confirm the first-frame segment is reachable from the same edge IP block — usually a CDN miss on /init/segments/0001.ts. Pull a manual HEAD request to verify; once the segment serves green, first_frame_ms will return a value under 2500ms.
Console logs "Failed to execute appendBuffer on SourceBuffer" with the MIME the source buffer was opened with. Player rolls back to the start of the preroll after a hard stall.
player.network.* + player.init.* → pass; player.decode.codec_negotiation → fail; the codec mismatch is the smoking gun — STREAM-INF declares avc1 but init.mp4 is mp4v.
Align the init.mp4 ftyp brand with the variant CODECS string. Once the MIME on the source buffer matches the bytes appended, codec_negotiation will pass.
Player plays for ~30s, then throws "QuotaExceededError: Upgrading in-flight MSE storage" and stalls. The buffer is configured but the actual bytes-on-the-wire exceed the source-buffer quota for the platform.
player.network.* + player.init.* → pass; player.decode.codec_negotiation → pass; player.decode.first_frame_ms → pass; player.decode.sourcebuffer_quota → fail (peak_bytes=1.4GB > quota=1.1GB).
Reduce the maximum buffer length (maxBufferLength=120 in hls.js config) OR raise the platform MSE quota; the probe will flip to pass when peak_bytes stays under quota for one cycle.
Player replays the buffer after a 3-second stall at the discontinuity, then drops a "decode error" and aborts the rendition. Media tracks come back inconsistent for a brief window.
player.network.* + player.init.* → pass; player.decode.first_frame_ms → pass; player.decode.discontinuity_decode → fail; player.decode.sourcebuffer_quota → pass.
Inspect the segment before / after the discontinuity boundary; in CMAF packaging, the ftyp brand set must match across the boundary. Force the packager to re-emit the segment pair and the discontinuity_decode probe will flip to pass.
Long-running live session jumps backwards by exactly 26.5 hours at the PTS wrap point. Player restarts the timeline but the source buffer is now out-of-order.
player.network.* + player.init.* → pass; player.decode.* → fail around the wrap; decoder re-syncs but ABR ladder mis-orders the variants until the next refresh.
Wrap-safe PTS handling on the packager + a refresh that lands the new PDT within the segment.tail_window; the decoder_recovery probe will flip to pass when wrap is recognized.
Widevine / PlayReady attach hangs past 4500ms; player renders black and waits. Manifest, init, and decode are otherwise fine — but the DRM envelope never opened.
player.network.* → pass; player.init.hls_attached → pass; player.init.drm_attach → fail at t=4872ms; player.init.sourcebuffer_open → pending (gate on EME).
Verify the keyserver from the failing edge; the EME attach requires the keyserver URL to be reachable on the same path as the segment-init. If keyserver reachability is the cause, fix the network path; if it is a license-server-side slow response, raise the EME attach timeout — the agent flags the value as a parameter to tune.
Diagnose with Streamwake
Register the HLS+PLAYER source 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 — one leaf fails, the downstream lanes stay pending.
The curl below registers an HLS source with a 30-second probe cadence. The HLS+PLAYER protocol routing makes the agent run player.network, player.init, player.decode, and player.autoplay on every refresh. The agents field is the exact set you need to act on the triage table on this page.
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/manifest.m3u8",
"protocol": "HLS+PLAYER",
"probeIntervalSeconds": 30,
"agents": ["player.network", "player.init", "player.decode", "player.autoplay"]
}'curl https://streamwake.polsia.io/api/v1/agents?stream_id=<id> \
-b "better-auth.session_token=<your-session-cookie>"GET /api/v1/agents?stream_id=ckliveplaystartup7421
→ 200 OK
{
"stream_id": "ckliveplaystartup7421",
"failing_lane": "init",
"first_failing_event": "player.init.hls_attached",
"at_ms": 1826,
"events": [
{ "event": "player.network.manifest_fetch", "verdict": "pass", "at_ms": 0 },
{ "event": "player.network.tls_handshake", "verdict": "pass", "at_ms": 38 },
{ "event": "player.network.cors_preflight", "verdict": "pass", "at_ms": 41 },
{ "event": "player.init.hls_attached", "verdict": "fail", "at_ms": 1826,
"detail": "destroying MediaSource before BUFFER_APPENDED" }
],
"downstream_events_skipped": [
"player.init.sourcebuffer_open",
"player.autoplay.gesture_required",
"player.decode.codec_negotiation",
"player.decode.first_frame_ms"
]
}Want Streamwake to catch this on its own?
Sign up, register an HLS+PLAYER probe, and the same leaf-failure cascade the rows above name runs on every refresh — and surfaces 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.