Fixing html video playback on chrome

- 10 min read - Text Only

I do not often include videos on my blog, but when I do, I test to make sure they work. And to my dismay, these HTML5 videos could start, they could pause, but any time I attempted to seek the video playback position, it would fail.

When I wrote Device OAuth Flow is Phishable, I included a video like this below.

The video is so dull that the only way to appreciate it was to intentionally move the playback slider from start to finish to see how terrible it is to enter an email address and password with a tv remote.

You don't have to play this video. Really.

My videos aren't big, the above is merely 540KB. Some websites literally have 2MB jpegs because marketing can't figure out how to resize what they upload. But that doesn't matter to the browser. It keeps in memory only what it has to at that moment. A video can be small like mine, or it could be a few gigabytes.

No matter how big or small the video is, browsers like Google Chrome take a very conservative approach. It does not download the full video. It does not buffer the full video. It often does not cache parts of the video. Google Chrome is designed to take a slice of video data. Maybe a few seconds, usually under a megabyte. Enough video time to start a new connection to resume playing the video without interrupting the user.

Open the network tab and you'll see not just one request, but multiple when playing this video.

A chrome network tab with three recorded requests for the same endpoint

If you are on a slow connection and skip around the video several times, you will see multiple range requests! Even on a slow connection, I can quickly skip to a section of the video that has not been downloaded yet. That's why this feature exists!

GET https://c.cdyn.dev/PFCc64ah - Ok @ 1/22/2023, 5:21:33 PM
  (log) Got Range request: 0 - : {"range":{"offset":0}}
  (log) Range result: 'bytes 0-540152/540153'
GET https://c.cdyn.dev/PFCc64ah - Ok @ 1/22/2023, 5:21:36 PM
  (log) Got Range request: 491520 - : {"range":{"offset":491520}}
  (log) Range result: 'bytes 491520-540152/540153'
GET https://c.cdyn.dev/PFCc64ah - Ok @ 1/22/2023, 5:21:39 PM
  (log) Got Range request: 32768 - : {"range":{"offset":32768}}
  (log) Range result: 'bytes 32768-540152/540153'
GET https://c.cdyn.dev/PFCc64ah - Ok @ 1/22/2023, 5:21:42 PM
  (log) Got Range request: 262144 - : {"range":{"offset":262144}}
  (log) Range result: 'bytes 262144-540152/540153'

Since I skipped around, the network tab shows only the partial amounts that were downloaded before the request was cancelled mid-transmission. This is fine, and intended behavior.

The first request knows that this endpoint is a video source. So there is an additional request header that it sends: Range, as it may download the media in slices.

Chrome sends a range header on a request to a video source

This header is the first part in Chrome supporting correct video playback.

typing
If you use AWS Cloudfront, Akamai, big company CDNs and so on, or even nginx, this problem is likely solved for you already. The rest of this article is meant to educate why this is happening and how to solve it yourself if you're in a situation like me.
vibrating
I've seen this happen on mastodon servers too. Some do fine, but only if their user content is served from another domain.

Responding to Chrome

The following information will only be helpful if you control the request and response on the edge.

So chrome asks: May I have some range please? 🥺 👉👈

We have to then respond with new headers Content-Range and Accept-Ranges, which tells Chrome how much range there is to get and that of course we handle range requests.

A request with a content-range header in the response

Since we would serve all the content, the response status code is 200 OK. Some may use 206 Partial Content since it is a range request, but that seems weird to me since the request would not be partial by any means.

At this point, Chrome may intentionally slow down the download to match with how fast the user is watching the video. Some servers will close connections that stay alive for too long, Chrome has a workaround for this! Do not assume that all 540153 bytes (in this example) have been served over the network!

It can start new connections when either the original slow request times out, or the user skips ahead to content that is not cached or buffered.

In that case it will make a partial request. The range header looks like bytes=<optional starting offset>-<optional ending offset> Notice that the offset is greater than 0.

A partial request

While the MDN docs for Range include a list of ranges, you do not need to support that for video content on Chrome.

The correct move here is to serve a partial response with status code 206 Partial Content. If the starting offset is greater than zero, skip that far into the file and then read until the ending offset (or the end of the file if blank).

A partial response

Key things to note here: we truly are sending a shorter output, see Content-Length: 146937 which is less than 540153; second we still let the browser know how much content is being served and how much there is to serve with Content-Range.

kernel-panic
Careful! You might think "It should be
bytes 393216-540153/540153, why is it
bytes 393216-540152/540153?"
teaching
This tricked me up too. The second number there is the ending position / offset. There are 540153 bytes, the last byte's position is 540152. Remember how we started at position 0 before? If you're serving the end of the file, you send file_size - 1 as the end position. In other words, if you have a 4 byte file, its content range could be bytes 0-3/4.

ETags

There's also a header called If-Range, where the user agent can condition the request to ensure the same file is served later.

If you are using Cloudflare R2 like me, you will need to strip the quotes off your Etag.

Caching

Partial requests are not cacheable on the edge. If the status code is 206 Partial Content, do not attempt to cache the response.

Progressive Web Apps

If you're serving videos on a PWA, you may find out it doesn't stream right! Turns out, Google's modern web development evangelism brand web.dev documents a way to simulate streaming requests within an offline environment! In fact, this was the final inspiration and reference that got me to implement this feature. Check out PWA with offline streaming (archived).

Conclusion

I get why Chrome would prefer servers that behave this way. Partial responses are clearly superior in respecting the user's bandwidth and integrating with a video player. That said, only Chrome and Chrome derivatives have this problem. Safari used to, Firefox used to. Both solved it.

If you are having this problem on your mastodon instance, you might be able to serve static content through nginx instead of through a ruby process. If your blog is having this issue and you're doing cool™ things on the edge like this blog, then you'll have to do some handiwork to get this working. Should you go this path Chrome will not log any errors when videos break or stop loading. Be prepared to debug with careful observation and thorough testing.