Why HLS Subtitles Do Not Appear: A WebVTT Troubleshooting Guide
Diagnose missing HLS subtitles by checking EXT-X-MEDIA, WebVTT playlists, timestamps, CORS, track selection, and the active browser playback path.
Video and audio can play perfectly while the subtitle menu is empty. A subtitle track can also appear in the menu but show no cues, drift several seconds, disappear after a seek, or work in one browser and fail in another. These symptoms look similar to a viewer, but they occur at different stages of the HLS pipeline.
The useful question is not simply “Does the stream have subtitles?” It is: where does the subtitle path stop working? Start at the master playlist, follow the subtitle rendition playlist and WebVTT segments, then check timing, browser support, player state, and the visible controls.
Use only streams you own or are authorized to inspect. Signed playlist and segment URLs can contain credentials. Redact query strings, cookies, tokens, account IDs, and viewer IP addresses before sharing a network trace.
Verification note — September 14, 2026: We loaded Apple’s public Advanced HEVC/H.264 HLS example in headless Chromium 151.0.7922.34 with hls.js 1.6.10. We waited for hls.js’s SUBTITLE_TRACKS_UPDATED event and observed one English subtitle track marked as default and not forced, with no fatal hls.js error during manifest discovery. This was a manifest and track-discovery test; it did not judge visual cue styling, spoken-word accuracy, Safari behavior, or physical-device accessibility controls. Those areas remain procedures to test on the target device.
Identify the exact failure
Do not begin by changing several player settings. First record what the viewer actually sees.
| Symptom | First evidence to collect | Likely stopping point |
|---|---|---|
| No subtitle or captions menu | Master playlist and player track list | Missing or unrecognized track declaration |
| Track is listed but selecting it shows nothing | Subtitle playlist, WebVTT requests, cues, and timestamps | Delivery, parsing, timing, or selection state |
| Cues appear at the wrong time | WebVTT cue times, X-TIMESTAMP-MAP, media timestamps | Subtitle-to-media timeline mapping |
| Subtitles work until a seek or discontinuity | Requests and timestamps around the new position | Discontinuity, segment boundary, or state recovery |
| One language works and another does not | Compare the two EXT-X-MEDIA entries and child playlists | Language-specific packaging or URL problem |
| Captions work in one browser only | Active playback path, browser version, and track type | Native HLS versus JavaScript/MSE behavior |
“Subtitles” and “closed captions” are often used interchangeably in an interface, but their HLS representations can differ. A WebVTT subtitle rendition normally has its own URI and media playlist. In-band closed captions such as CEA-608/708 are carried inside the video stream and declared with an INSTREAM-ID. Diagnose the type that is actually present rather than assuming every CC button reads a .vtt file.
Follow the complete request chain
For a WebVTT rendition, the chain normally looks like this:
- The master playlist declares a subtitle group with
#EXT-X-MEDIA:TYPE=SUBTITLES. - A variant stream refers to that group with a
SUBTITLESattribute. - The subtitle
EXT-X-MEDIAentry points to a subtitle media playlist. - That media playlist lists WebVTT segments.
- The player downloads, parses, synchronizes, selects, and displays cues.
A successful master request proves only step one was reachable. Open browser developer tools before loading the page, preserve the network log, and filter for m3u8, vtt, webvtt, or the subtitle directory name. Our M3U8 developer-tools guide explains how to trace child requests safely.
If the subtitle playlist is never requested, investigate the declaration and selection path. If it is requested but its segments fail, investigate URLs, authorization, CORS, and caching. If all files arrive successfully, move to WebVTT structure, timestamps, and player state.
Check the master playlist declaration
A typical out-of-band subtitle entry resembles:
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",
# LANGUAGE="en",AUTOSELECT=YES,DEFAULT=YES,FORCED=NO,
# URI="subtitles/en/prog_index.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720,
# CODECS="avc1.64001f,mp4a.40.2",SUBTITLES="subs"
video/720p.m3u8
The line wrapping above is for readability; validate the real playlist syntax as delivered.
| Attribute | What to verify | Common mistake |
|---|---|---|
TYPE | It is SUBTITLES for an external subtitle rendition | Treating an in-band caption declaration as a WebVTT URI |
GROUP-ID | It exactly matches the variant’s SUBTITLES value | Typo or case mismatch disconnects the group |
NAME | It is meaningful and distinguishable in the player UI | Multiple tracks use the same visible name |
LANGUAGE | It uses the intended language tag | Track is mislabeled, so automatic selection chooses poorly |
URI | It resolves relative to the master playlist and is authorized | Resolving it relative to the web page instead |
DEFAULT | At most one member of a group is marked YES | Several tracks compete as the default |
AUTOSELECT | It is consistent with the intended automatic behavior | Expecting automatic selection when it is disabled |
FORCED | It is used only for essential forced-subtitle content | Marking a full dialogue track as forced |
RFC 8216 requires AUTOSELECT=YES when DEFAULT=YES. It also states that a group must not contain more than one member with DEFAULT=YES. These values guide client behavior; they do not replace a visible user choice when viewers need to turn subtitles on or off.
For in-band captions, inspect TYPE=CLOSED-CAPTIONS and INSTREAM-ID instead. The associated variant uses CLOSED-CAPTIONS, not a WebVTT subtitle URI. Mixing the two models in a test report wastes time because the missing object you are searching for may never be a separate network request.
Open the subtitle playlist directly
Resolve the subtitle URI exactly as a browser would. Relative paths are based on the playlist that contains them, not on the page that embeds the video.
Confirm that the response:
- Returns an HLS playlist rather than an HTML login, error, or bot-challenge page.
- Has a successful status after redirects.
- Lists the expected WebVTT segment URLs.
- Uses consistent media sequence and discontinuity information for the presentation.
- Remains available for at least as long as the parent and variant URLs when signed access is used.
- Does not serve a stale live playlist long after video and audio have advanced.
Then request one WebVTT segment with the same authorization context. A .vtt filename returning 200 is not sufficient if the body is an access-denied page. Inspect the content and response type.
Validate WebVTT structure before styling
An HLS WebVTT segment should be valid WebVTT. Check the header, cue timing syntax, cue order, encoding, and text before debating fonts or colors. A minimal segment may look like:
WEBVTT
X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:900000
00:00:01.000 --> 00:00:04.000
Example subtitle text.
Use a validator or parser that reports the offending cue. Look for byte-order marks, unexpected binary compression handling, malformed arrows, invalid timestamps, overlapping cues that a specific renderer handles poorly, and character encoding problems. Do not silently “repair” production files in the browser; fix the packaging source so all clients receive the same valid output.
If the network log shows successful segments but the browser exposes no cues, inspect the text track objects and player errors. This separates “downloaded” from “parsed and attached.”
Check subtitle-to-video timing
The HLS specification defines X-TIMESTAMP-MAP to map local WebVTT cue time to the MPEG-2 timestamp timeline used by other renditions. Without it, a client must assume local WebVTT time zero maps to MPEG-2 timestamp zero. That assumption may be wrong for media cut from a longer program or passing through discontinuities.
For drift or cues that never enter the visible playback window, record:
- The current video time when a cue should appear.
- The cue’s local start and end times.
- The
LOCALandMPEGTSvalues inX-TIMESTAMP-MAP. - Media timestamps around the same segment.
- Discontinuity tags or encoder restarts.
- Whether the problem begins only after seeking, ad insertion, or a live-window slide.
Do not fix a constant offset in player UI code until you know why the timelines differ. A hard-coded adjustment may repair one asset and break every correctly packaged stream.
Treat CORS and authorization as separate tests
When hls.js or another browser JavaScript player fetches HLS resources, the subtitle playlist and segments need the same usable cross-origin access as the video path. It is possible for video to work while subtitles fail because they are served from another hostname, storage bucket, or token policy.
Check the actual failing response rather than adding headers blindly:
- Does the subtitle request include an
Originheader? - Does the response allow the page’s origin?
- Did a preflight occur, and did it succeed?
- Are credentials required, and is the response policy compatible with them?
- Does the subtitle URL expire earlier than video URLs?
- Does a CDN cache a 403 or stale playlist differently for the subtitle path?
Opening a subtitle URL directly in a browser tab is not a complete CORS test. Top-level navigation and a JavaScript fetch use different security checks. Reproduce through the real player page.
Verify selection state and visible controls
A discovered track is not necessarily selected, and a selected track is not necessarily visible in a custom interface.
With hls.js, wait for documented subtitle-track events instead of reading the track array at an arbitrary moment. Our Chromium test initially checked at MANIFEST_PARSED; the manifest was available, but the subtitle list had not yet been delivered through SUBTITLE_TRACKS_UPDATED. Waiting for the track-specific event exposed the English track correctly. That timing difference is a useful debugging lesson: “empty at this instant” is not the same as “the manifest contains no subtitle track.”
Record:
- The hls.js version.
- The subtitle-track list after its update event.
- The selected subtitle-track index.
- Track-switch and subtitle-fragment events.
- Fatal and non-fatal error details.
- The video element’s text tracks, modes, and active cues.
For native HLS, rely on the video element, platform media diagnostics, and the browser’s controls. Do not assume Safari and a Chromium browser using Media Source Extensions will expose identical event sequences or menu behavior.
Custom controls introduce another failure layer. The stream may expose valid tracks while the UI filters them out, uses stale state, hides the menu at a responsive breakpoint, or changes selection without updating the underlying player. Compare the native/player track list with the options displayed to the user.
Test on the devices that matter
Responsive desktop mode does not emulate an iPhone or tablet media pipeline. For a release candidate, test at least one real device from each supported playback path.
| Test | What to record |
|---|---|
| Start with subtitles off, then enable a track | Time to first cue and selected language |
| Start with the intended default track | Whether client preference overrides it |
| Seek forward and backward | Cue recovery and synchronization |
| Pause and resume | Duplicate, missing, or stale cues |
| Change quality | Whether subtitles continue across rendition switches |
| Rotate or enter full screen | Menu access, cue clipping, and safe-area layout |
| Switch network or rejoin live playback | Track playlist refresh and recovery |
| Try captions and subtitles separately | Correct treatment of in-band and out-of-band tracks |
Use the phone and tablet HLS checklist for orientation, touch controls, and network handoffs. Use the cross-browser HLS test matrix to record native and JavaScript playback paths separately.
A practical result template
Write the result so an encoding, player, or CDN owner can act on it:
On Chromium 151 with hls.js 1.6.10, the master and video rendition loaded. The master declared subtitle group
sub1. hls.js emitted one English track afterSUBTITLE_TRACKS_UPDATED. Selecting track 0 requested the subtitle playlist, but its first WebVTT segment returned 403 from the subtitle CDN. Query tokens removed from the attached trace.
That report is more useful than “captions broken.” It identifies the active path, the last successful stage, and the first observed failure without claiming a cause that the evidence does not prove.
For delivery stalls affecting video as well, use the HLS buffering diagnosis guide. For broad failures such as mixed content, DRM, or unsupported codecs, start with Why your M3U8 stream will not play.
References
- HTTP Live Streaming — RFC 8216
- HLS Authoring Specification for Apple Devices
- HTML track element — MDN Web Docs
- hls.js API documentation
- Apple Advanced HEVC/H.264 HLS example
Missing subtitles become tractable when the investigation follows the data in order: declaration, child playlist, segment, parser, timeline, selection, and presentation. Preserve one clean trace, change one variable at a time, and stop at the first stage whose evidence differs from the working track or device.