Serving Video with HTTP Range Requests
May 4, 2025
I recently found myself building a simple photo sharing app for friends and family. We had some life events that our people wanted to stay up to date on, and I wasn’t jazzed about relying on cloud services for it.
Instead, I decided to make a small progressive web app with Next.js. I cranked out the first version in a few hours — just a basic self-hosted app that can be pointed at a directory of photos and render them as both thumbnails in a grid and full size images in a shadowbox. I even had push notifications set up for when new photos were added to the directory.
Then, just as I was about to call it quits and label the whole thing a resounding success, I remembered something: I had completely forgotten about video! “Well, no problem,” I thought, “I just have to use the <video>
tag, right?”
The <video>
element
The <video>
element supports a number of attributes, allowing you to configure which controls to show, whether the video should autoplay, etc. Like <img>
elements, <video>
elements can be provided a src
attribute, which specifies the URL of the video to embed.
Because not all browsers support all video formats, <video>
elements are usually created with a list of <source>
elements as children, instead of a single src
attribute. Browsers will automatically go through this list of sources until they find the first source type that they support. So you can serve your super sharp, super small AV1 videos to your users that support it, and fall back to a more widely support WEBM or MP4 encoding for everyone else.
Here’s an example from the MDN docs to demonstrate:
Check out the Network tab of your browser’s development tools to see what’s happening here. Depending on the browser that you use, you’ll see different requests being made. I’m using Zen/Firefox as I write this, which supports WEBM, and so only requests the WEBM video (this should be true of recent versions of all of the major browsers). If I included an AV1
source, older iPhones and macOS devices would skip it and fall back to the WEBM file.
Range requests
If you were looking very carefully, you may have noticed some other interesting things in the Network tab. Firstly, the response code for the flower.webm
request was 206
, rather than the standard 200
success/OK response. Depending on your browser, you may actually have seen multiple requests for the video file, all responding with a 206
. There are also some additional headers, like Accept-Ranges: bytes
and Content-Range: bytes 0-554057/554058
.
Together, these make up an HTTP range request. As the name suggests, range requests allow the client to request a specific range of bytes from the server, rather than the entire resource. This is particularly useful for video, as videos can be arbitrarily long (and therefore, large), and so clients almost always load them in chunks.
Initially, I had no intention of supporting range requests in my little photo sharing app. They have clear utility, but Next.js has no built-in support for them, and I wasn’t looking forward to writing an implementation myself. Plus, all of the videos I was planning on sharing were relatively tiny, only a minute long at most. They would take a few seconds to load, but nothing intolerable. I added an endpoint that served the video files directly with a 200 response code, made a commit, and patted myself on the back for finishing my little side project.
And then I tested the app on my phone.
Safari says no
It turns out, Safari actually requires that video sources support HTTP range requests. It sends a request for the first two bytes, and if it doesn’t get a response with the correct HTTP range headers, it moves on to the next source. If it runs through the whole list without getting a proper range response, it simply won’t render the video at all!
Normally, static assets like images and videos are served from a CDN, or at least using a proper static file server. These all support range requests, so it’s rare that a developer needs to actually think about how they work. But since I was building an app that was specifically intended to be self-hosted on my own hardware, I couldn’t rely on a CDN or a separate static file server. And since it needed to serve user-provided assets, I couldn’t rely on Next.js’s public
folder convention, which does support HTTP range requests.
So, fine. I guess we’re doing this. Maybe it’ll be fun!
Doing this
Luckily, MDN has a relatively useful guide on HTTP range requests. First we need to check the request headers to see whether we’ve gotten a range request. The most pertinent of these is the Range
header, which is the signal that a client is making a range request, and includes the range of bytes that the client is requesting. Technically, the Range header could support units other than bytes in the future, but at the moment, bytes are the only registered units.
Sample Range header:Range: bytes=0-554057
There are a few other headers we need to pay attention to as well. Range requests are also commonly used for resumable downloads, like when a large download pauses when a mobile app is backgrounded and resumes when it’s reopened. In this scenario, it’s crucial that the client can verify that the resource it’s requesting is exactly the same one that it already downloaded part of — otherwise it may end up with a corrupted file. To support this, clients can use the If-Range
header to specify either an ETag (usually a hash of the resource’s contents) or a last modified timestamp. The server can confirm that this validator matches the resource that it’s about to send — if it doesn’t, it can respond with the entire resource from the beginning with a 200 status.
Sample If-Range header (using an ETag):If-Range: "67cf03ca-8744a"
So let’s start by checking for these headers and using them to determine whether to respond with a partial or complete response:
Now we need to parse this byte range and update our stream to only include those bytes. Byte ranges are allowed to omit the start or end, which we can handle by checking for NaN values after parsing.
The file.createReadStream({ start, end })
call is doing a lot of work for us here. It constructs a Node.js ReadStream (which extends the Readable interface) that yields bytes of the file from the start offset to the end offset. Conveniently, it has the same range semantics as the Range header — the start and end are both inclusive — so we can just pass them through directly.
Our implementation is almost functional! The last step is to send the correct headers and status to the client, so that they can actually implement their chunked or resumable download.
First, we need to update our response to include the Accept-Ranges
header, even if we received a standard HTTP request. This allows clients to determine whether they can use range requests in the future (e.g., if a large file download is interrupted). Then, if we are responding to a range request, even if the range requested covers the entire resource, we need to return a 206
status code, rather than the default success code of 200
. We also need to include a Content-Range
header, which specifies which bytes are being sent in this response — this acts as an acknowledgement of the range from the Range
header from the request. And finally, we’ll add Last-Modified
and ETag
headers, so that clients can implement the If-Range
condition with either validator.
And now our videos will finally play on all the major browsers, with just the basic <video>
element! Technically we haven’t implemented multi-range requests, though I haven’t been able to actually find or come up with any use cases for these (and luckily browsers don’t seem to make use of them for file downloads or video buffering). If we wanted to support them, we would need to concatenate multiple readable streams (Deno’s standard library has a nice concatReadableStreams
implementation), joining them with multipart response separators, as described by MDN in their 206 Partial Content status docs. For now, we just ignore requests for multiple ranges, treating them like non-range requests — this is generally acceptable behavior, I think, for servers that don’t support multiple range requests.
Edit: Thanks to u/Pesthuf for pointing out that I had implemented the condition for the 416 incorrectly! The relevant part of the spec actually states (emphasis mine):
For a GET request, a valid bytes range-spec is satisfiable if it is either:
- an int-range with a first-pos that is less than the current length of the selected representation or
- a suffix-range with a non-zero suffix-length.
I had initially checked that the end-pos was less than the current length, which is not a requirement for the range to be satisfiable.