Tutorials Developer

Video Playback in the Browser: HLS, DASH, and Players

By Infinite Creation · Intermediate · ~20 min
A 1960s mid-century comic book style editorial illustration showing two engineers at terminals in a machine room, with a rack of servers, a phone clamped to a rig visible in the frame.

Overview

Browser video ranges from a single element with a source attribute to a full adaptive streaming stack, and choosing the wrong end of that range costs you either playback reliability or a great deal of unnecessary complexity. This guide covers when progressive download is genuinely sufficient, what adaptive streaming actually buys, why the same file plays in one browser and not another, and the small details (faststart, codec strings, CORS) that account for most playback bugs.

What You Need

  • Media encoded in a browser-compatible codec and container
  • A server or CDN that supports range requests
  • Correct MIME types configured, which is a common oversight
  • Test devices beyond your development machine, particularly mobile
  • For adaptive streaming: a packager and a player library
  • Somewhere to check playback behaviour on a constrained connection

Steps

1

Start with a plain video element and progressive download

For short clips, single-quality content, and anything where audience bandwidth is not the constraint, a video element pointing at an MP4 is sufficient, requires no library, and is keyboard accessible by default. Adaptive streaming is a solution to a problem you may not have, and adopting it unnecessarily adds a packaging step and a player dependency.

2

Put the index at the front of your MP4

MP4 files store an index that a player needs before playback can begin, and by default it is written at the end. Without moving it to the front, commonly called faststart, a server that cannot answer byte range requests forces the browser to download the entire file before it can start. When the server does answer range requests, the browser can fetch the index from the end of the file instead, which costs an extra request, so faststart still helps. It is a common cause of "the video takes forever to start" reports.

3

Encode for compatibility before efficiency

H.264 video with AAC audio in an MP4 container remains the broadest-support combination by a wide margin. More efficient codecs compress substantially better and have uneven support and uneven hardware decoding, which affects battery and performance on mobile. Offer efficient formats as an addition, not a replacement.

4

Adopt adaptive streaming when bandwidth varies meaningfully

HLS and DASH split media into segments at several quality levels so the player can switch as conditions change. This is worth the complexity for long-form content, for mobile audiences on variable connections, and for live. It requires packaging your media into segments and a manifest, plus a player library on most browsers.

5

Get the codec string right in your manifests and source elements

Browsers decide whether they can play something from a codec string that specifies profile and level, not just the container. An imprecise or wrong string causes a browser to reject media it could actually play, or to attempt media it cannot. This is a frequent and frustrating source of device-specific failures.

6

Check the boring infrastructure before debugging the player

Range requests must be supported for seeking to work. MIME types must be correct or browsers refuse the file. CORS headers must permit the request when media is served from another origin. A large share of playback bugs are one of these three rather than anything to do with the media itself.

Pro Tips

  • Always enable faststart on MP4s intended for the web. It is one flag and it fixes the most-reported symptom.
  • Test on a real phone on cellular, not on desktop with a throttled connection. They fail differently.
  • Serve an H.264/AAC MP4 rendition alongside anything more efficient, as the compatibility fallback.
  • Check range request support early, without it, seeking silently does not work.
  • The native video element is accessible by default. Any custom player has to earn that back.

Tested: faststart, range requests, MIME types, CORS and codec strings in two browsers

We tested the page's playback claims on 2026-09-29 in headless Chrome 154.0.8037.58 and Firefox 157.0 on one Mac, against a local HTTP server that counts the bytes it has sent when each media event fires. The test file is a 7.8 MB, 12 second 720p H.264 and AAC MP4 made with FFmpeg 8.1.2, written twice: once with the index (the moov atom) at the end, which is FFmpeg's default, and once with faststart, which moves it to the front. The server slows each response to about 2.5 MB a second so a byte count means what a slow connection would show. Byte counts are read when the event reaches the server, so they are approximate. Nothing else about timing was measured. Safari is covered by another guide's tests, and no phone was used.

CaseChrome 154Firefox 157
Index at the front, byte ranges servedFirst frame after 1.4 MB (18 percent), seek to 8 s landed at 8 sFirst frame after 0.3 MB (3 percent), seek to 8 s landed at 8 s
Index at the end, byte ranges servedFirst frame after 0.4 MB (5 percent), seek to 8 s landed at 8 sFirst frame after 0.5 MB (6 percent), seek to 8 s landed at 8 s
Index at the front, ranges ignoredFirst frame after 0.3 MB (3 percent), seek to 8 s landed at 0 sFirst frame after 0.4 MB (5 percent), seek to 8 s landed at 0 s
Index at the end, ranges ignoredFirst frame after 7.8 MB (100 percent), seek to 8 s landed at 0 sFirst frame after 7.8 MB (100 percent), seek to 8 s landed at 8 s
Served as application/octet-streamPlayedPlayed
Served as text/plainPlayedFailed, media error 4
Served as text/plain with nosniffPlayedFailed, media error 4
No Content-Type headerPlayedPlayed
From another origin, no CORS headersPlayed, canvas read blockedPlayed, canvas read blocked
Another origin, crossorigin attribute, no CORS headersFailed, media error 4Failed, media error 4
Another origin, crossorigin attribute, CORS header sentPlayed, canvas read allowedPlayed, canvas read allowed
source type with codecs="theora", a file that is H.264Never requested, no error on the videoNever requested, no error on the video
canPlayType, video/mp4 alone"maybe""maybe"
canPlayType, codecs="avc1" with no profile"maybe"empty string
canPlayType, codecs="avc1.64001f""probably""probably"
canPlayType, codecs="avc1.64ffff"empty stringempty string
canPlayType, application/vnd.apple.mpegurl (HLS)"maybe"empty string

Step 2 was half right and has been corrected (2026-10-06). Faststart matters when the server cannot answer byte range requests. With ranges ignored, the file with the index at the end was downloaded whole, 7.8 MB, before either browser reported its metadata, while the faststart copy showed a first frame after under a fifth of it. But when the server did serve ranges, both browsers asked for the tail of the file to find the index and showed a first frame after 0.4 MB in Chrome and 0.5 MB in Firefox, about 6 percent of the file. So the page's earlier "the browser must download the entire file" holds without range support and not with it, and the page now says so. Faststart still removes the extra request for the tail, which this test did not time. The byte count is how much had been requested by the event, not how much was needed, so Chrome's higher figure for the faststart file only reflects it reading ahead.

Step 6's range claim holds for seeking. With ranges ignored, Chrome reported no seekable range and a seek to 8 seconds stayed at zero, even with the whole file downloaded. Firefox could not seek the faststart file either, and could seek the index-at-end file, which it had downloaded in full before playing. The page says seeking silently does not work, and it did not work, with no error raised.

Step 6's MIME claim depends on the browser. Chrome played the file as text/plain, even with the nosniff header sent, and with no Content-Type at all. Firefox refused text/plain with media error 4, with or without nosniff, and played it with no header or with application/octet-stream. "Browsers refuse the file" is true of Firefox and not of Chrome here.

Step 6's CORS claim is narrower than the page says. Both browsers played a video from another origin with no CORS headers at all. What failed was reading it: drawing a frame to a canvas and reading the pixels was blocked. Adding the crossorigin attribute without the server's header made the video fail with media error 4, and adding the header fixed it and allowed the canvas read. CORS matters when the page needs to read the media, not when it only plays it.

Step 5 holds, with a sharper edge than the page describes. A codec string with a profile and level, avc1.64001f, returned "probably" in both browsers, and an invalid one returned an empty string. A bare "avc1" returned "maybe" in Chrome and an empty string in Firefox, so the same string is judged differently. Worse, putting a wrong codec in a source element's type attribute made both browsers skip a playable H.264 file without requesting it and without an error event on the video, so nothing on the page said why it did not play. The invalid avc1.64ffff string behaved the same. Chrome answered "maybe" for the HLS type and Firefox returned an empty string, and both returned an empty string for DASH, so native HLS or DASH support cannot be assumed.

Put the index at the front of an MP4 without re-encoding: ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4

Validated. Measured 2026-09-29 in headless Chrome 154.0.8037.58 and Firefox 157.0 on one Mac against a local server, with FFmpeg 8.1.2. Byte counts are approximate and were read from the server when each event arrived. We did not time playback start, and we tested no phone, Safari, CDN, HLS or DASH playback, or adaptive switching. The command is the one that produced the faststart file used here.

What You'll Learn

Playback problems cluster into a few causes. Below: what adaptive streaming actually does, and the container-versus-codec distinction behind most compatibility complaints.

What Adaptive Streaming Actually Buys

Adaptive bitrate streaming is frequently adopted because it sounds professional rather than because the problem it solves is present.

The mechanism: your media is encoded at several quality levels and split into short segments, with a manifest describing what is available. The player measures throughput and buffer state and chooses which quality level to request for each successive segment, switching mid-playback as conditions change.

The benefit is that a viewer on a poor connection gets continuous playback at reduced quality rather than a stalling high-quality stream, and a viewer on a good connection gets full quality without you having to guess. It also makes live streaming feasible over ordinary HTTP infrastructure.

The cost is a packaging step producing multiple renditions and a manifest, storage for all those renditions, and a player library on browsers without native support. For a two-minute clip watched on office wifi, none of that pays for itself. For a forty-minute video watched largely on phones, it clearly does.

Container Versus Codec, and Why the Same File Fails Elsewhere

The single most common playback confusion is treating the file extension as describing what a browser can play.

A container: MP4, WebM, MOV, describes how streams and metadata are organised. A codec. H.264, HEVC, AV1, AAC, Opus, is the compression used for the stream inside it. The extension names the container and says almost nothing about playability.

This produces the classic report: the same MP4 plays on one machine and not another. The container is fine everywhere. The codec inside is not supported on the failing device, frequently for licensing rather than technical reasons.

It also explains why the codec string matters. A browser asked whether it can play a type answers based on the codec, profile, and level specified: so an incomplete string leads it to guess, and it guesses conservatively.

The practical defence is to serve an H.264/AAC MP4 rendition as a universally-supported fallback, declare codec strings precisely, and test on real devices rather than assuming the desktop result generalises.

Where This Fits

Playback is the last step of a media pipeline, and Building Media Applications covers the upload and job patterns before it.

FAQ

Q: Do I need HLS or DASH for my site?
A: Only if bandwidth varies meaningfully across your audience or your content is long-form or live. For short clips at a single quality, a plain video element with a faststart MP4 is simpler, has no dependencies, and is accessible by default. Adaptive streaming solves a real problem you may not have.

Q: Why does my video take a long time to start playing?
A: Often because the MP4 index is at the end of the file. If the server cannot answer byte range requests, the browser must download all of it before playback can begin, and even with range requests it needs an extra request for the tail. Remux with faststart so the index is at the front, and check that the server supports range requests.

Q: Why does the same file play on desktop but not on a phone?
A: Almost always codec support rather than the container. The extension names the container while the codec inside determines playability, and support varies by browser and platform partly for licensing reasons. Provide an H.264/AAC MP4 rendition as a compatibility fallback.

Q: Should I build a custom player?
A: Only with a concrete reason such as branding or analytics, and knowing you are taking on accessibility work the native element provides free: keyboard operation, labelled controls, and caption support. Adopting a reputable accessible player library is usually cheaper than making a custom one compliant.

Translate this page

Machine translation provided by Google Translate, on Google’s servers. We do not check these translations and they will get technical terms wrong. The English page is the authoritative one. Following a link sends this page’s address to Google. Your browser may also offer to translate this page itself, which keeps the request on your device.