Prev post Archive

Making Servoiardi show me a video

Today, I'm going to explore getting my webserver to serve a video of my cat. To test, I've taken a 15 second video, and repeated it to make a 1 GB file.

Servoiardi (my friendly webserver) can then easily serve this single file:

servoiardi --serve-files test/loop-cat.mp4

So does it work? Well it sort of does, in some browsers at least. I'm serving the file on my laptop, and I've sat here watching the video loading for five minutes. It's just finally started to play. Other websites can serve video to me conveniently and immediately, so clearly I'm doing something wrong!

Classic HTTP works well for downloading whole files at once. You request a single file, wait for it to download, and then show it to the user. This must be what my phone is doing---downloading the entire video as a file, and only then starting to play it. It's possible that with this approach, a browser could begin to show the start of the video as it still downloads, but if I wanted to skip to halfway through the video, without waiting for it to all download then that would simply be impossible, right?

Not quite. Luckily, there exists an extension to HTTP that sorts out this exact problem, described in RFC 7233. In this blog post, we'll go over implementing this RFC into Servoiardi, so that I can play a 1 GB cat video.

If you're interested, the diff is on gitlab.

New HTTP Concepts

RFC 7233 describes new HTTP headers and responses that allow a client to request and receive only particular portions of a response. MP4 files contain stts atoms and stco atoms that allow byte offsets to be associated with timestamps in the file. (Thanks mort for explaining this) It should in theory be possible then for a browser to skip forward to a particular point in a video, look up what bytes it needs to find for that time, and stream just the parts of the video it needs.

The Accept-Ranges response header and Range request header

To implement this RFC the first thing we should do is to advertise that we support it. We can do this by adding the Accept-Ranges: bytes header to our responses.

This tells clients that our server supports requests for ranges of bytes, and it changes how Google Chrome behaves when requesting our video.

Rather than sitting and waiting to download the whole thing: (Server log...)

[I] [0] <- GET / 127.0.0.1:47508 (src/http/request/mod.rs:199)
[I] [0] Serving file "test/loop-cat.mp4" (src/service/files/mod.rs:226)
[I] [0] -> 200 OK (src/http/response/rendered.rs:291)
[I] [0] Sent 1078490608 bytes of content. (src/conn/mod.rs:100)

When we add Accept-Ranges: bytes to our response, Chrome immediately terminates the connection so that it can re-make the same request as a range request.

[I] [0] <- GET / 127.0.0.1:50926 (src/http/request/mod.rs:199)
[I] [0] Serving file "test/loop-cat.mp4" (src/service/files/mod.rs:226)
[I] [0] -> 200 OK (src/http/response/rendered.rs:291)
[E] [0] Error sending content: Connection reset by peer (os error 104) (src/conn/mod.rs:105)
[E] Error handling connection: Connection reset by peer (os error 104) (src/server/server_task.rs:50)

If we look at the subsequent request that chrome sends, we'll see a new and exciting header it's sent:

[I] [1] <- GET / 127.0.0.1:43974 (src/http/request/mod.rs:199)
[D] [1] <- Header "Host": "127.0.0.1:8080" (src/http/request/mod.rs:227)
[D] [1] <- Header "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0" (src/http/request/mod.rs:227)
[D] [1] <- Header "Accept": "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5" (src/http/request/mod.rs:227)
[D] [1] <- Header "Accept-Language": "en-GB,en;q=0.9" (src/http/request/mod.rs:227)
[D] [1] <- Header "Range": "bytes=1076133888-" (src/http/request/mod.rs:227)
...

The new Range header means that Chrome has, after aborting its original request, now requested a sub-range of our document.

There are a few ways it can make that request:

In this case, it's asking for the one billion, seventy-six million, one-hundred and thirty-three thousand eight hundred and eighty-eighth byte, and onwards. This corresponds to the final ~2.24 MiB of the MP4 file, which is where MP4 moov atom is stored. This atom contains all the information about the video file, including the stts and stco atoms that might interest Chrome for seeking through the video. The fact that this information is at the end of the file may well explain why browsers cannot even begin playing the video before the whole thing is downloaded.

 $ atomicparsley ../server/test/loop-cat.mp4 -T | grep 'moov\|stts\|stco'
Atom moov @ 1076153847 of size: 2336761, ends @ 1078490608
  Atom stts @ 1076154442 of size: 46648, ends @ 1076201090
  Atom stco @ 1076536410 of size: 313096, ends @ 1076849506
  Atom stts @ 1076849917 of size: 3224, ends @ 1076853141
  Atom stco @ 1078177365 of size: 313092, ends @ 1078490457

"206 - Partial Content" status and the Content-Range response header.

RFC 2733 introduces a new HTTP status code for responding with partial content. The 206 status accompanies either a single or multiple sub-ranges of a resource.

Each sub-range of the resource is also accompanied by a Content-Range header, describing what subset of bytes is being sent.

We'll start by using this response and header to return a single range. We just make a normal HTTP response, but with a 206 status, a Content-Range header and with only the requested bytes.

To make this work, I split up Servoiardi's http::response::Response into two steps:

To serve a single sub-range of bytes, we simply seek forward in the rendered content, and then send only a limited number of bytes through the HTTP connection. Luckily, all content currently served by Servoiardi is seekable, but in the future we may need to reject range requests for non-seekable resources.

Let's see what our response to Chrome's request for the final 2MiB of our MP4 file looks like:

[I] [1] -> 206 Partial Content (src/http/response/rendered.rs:291)
[D] [1] -> Header "Content-Type": "video/mp4" (src/http/response/rendered.rs:298)
[D] [1] -> Header "Content-Disposition": "inline" (src/http/response/rendered.rs:298)
[D] [1] -> Header "Connection": "close" (src/http/response/rendered.rs:298)
[D] [1] -> Header "Last-Modified": "Mon, 16 Mar 2026 20:39:17 +0000" (src/http/response/rendered.rs:298)
[D] [1] -> Header "Content-Length": "2356720" (src/http/response/rendered.rs:298)
[D] [1] -> Header "Date": "Sat, 4 Apr 2026 20:00:41 +0000" (src/http/response/rendered.rs:298)
[D] [1] -> Header "Cache-Control": "max-age=60, stale-while-revalidate=60" (src/http/response/rendered.rs:298)
[D] [1] -> Header "Accept-Ranges": "bytes" (src/http/response/rendered.rs:298)
[D] [1] -> Header "Server": "Tiramisusan" (src/http/response/rendered.rs:298)
[D] [1] -> Header "Content-Range": "bytes 1076133888-1078490607/1078490608" (src/http/response/rendered.rs:298)
[I] [1] Sent 2356720 bytes of content. (src/conn/mod.rs:100)

You can see we've added a Content-Range in the format Start-End/Total, where Start and End are an inclusive range of bytes, and Total is the number of bytes in the whole MP4 file, and we've only sent Chrome the 2.4 MiB of data that it asked for.

Supporting multiple ranges (multipart/byteranges)

So far, we've gone over how Servoiardi responds to a single range, but RFC 7233 also tells us how to respond to multiple range requests. When we receive a request with multiple ranges in it, we need to construct a multipart/byteranges response (Appendix A). that contains each individual sub-range as its own part, each with an individual Content-Range header.

If you've ever dealt with multipart documents before (RFC 2046/S. 5.1), then you'll know how upsetting they are to use. Rather than each part of a multipart document having a known length, the parts are separated by a known boundary string. Of course this means that the receiver needs to scan carefully through the whole document to find the special string, and even worse, the sender needs to scan through the whole document to find a string that never occurs in it.

There's not any reason why anyone would even need to get multiple ranges in a single HTTP request either. It would be easier for both client and server to simply make multiple requests. They could even be served in parallel!

Upsettingly, while the RFC explicitly mentions that a client may not support multiple range requests, it doesn't have such strong language for a server not supporting multiple ranges. It does allow the server to merge multiple ranges together, so a valid server could just respond to multiple ranges by merging them all into one big super-range. But not supporting multiple range requests feels against the spirit of the RFC, even if no one will use them. It's always important, of course, to implement RFCs by vibe.

Finding a boundary

The first step to produce a multipart response is determining an appropriate boundary. We need to find an arbitrary sequence of non-special bytes that doesn't occur anywhere in any part the content we want to send. Since our boundary needs to be known so we can send it in our response headers, we need to find it before we can send any response at all.

We could of course just use a completely random number, and as long as it's big enough, there's practically zero chance that it'll be found in the data. This just doesn't feel nice though, so I decided to go and actively check whether candidate boundaries are in the data that we're going to send.

To do this, we seek to each range in our response and read the range of bytes into a buffer chunk by chunk. Since we already support receiving multipart requests, we have a mechanism to find boundaries, so I just reused that. It works similarly to the KMP Algorithm, though I didn't work out how to make constructing the DFA O(n) rather than O(n^2). (I'll need to look at upgrading that now I've read the Wikipedia article)

One issue to watch out for would be a partial boundary at the end of the sequence. For example, if our boundary was "CATCAT", and our content was "BOBCAT". Then placing the boundary immediately after the content would cause a match three bytes to soon, even though the boundary itself never appeared in the content. Luckily that isn't an issue for multipart content, since the boundary is always preceded by "\r\n", and "\r\n" cannot reoccur within the boundary.

As a little joke, the initial boundary candidate is "i_hope_this_isnt_in_the_data", but all subsequent candidates are just random bytes. We try 5 times to find a boundary, and with 16 random bytes each time, it's very unlikely that we'll fail to find one.

Assembling a multipart response

Once we know what our boundary is, we need to add a Content-Type header to our response containing the boundary, so that the client knows what boundary to search for before it begins splitting up the response.

Multipart syntax consists of lines separated by "\r\n", the same delimiter used for separating header lines. After an initial preamble which can be discarded, the first boundary is followed by headers applicable only to that part of the multipart response:

This is header is terminated by an empty line, and then followed by the selected range of the underlying content. After another newline, the boundary repeats and the next part is sent. The entire response is terminated with a boundary immediately followed by two dashes.

Overall, a multipart response to a range request looks like this:

 $ curl -iH Range:bytes=75-122,723-764 127.0.0.1:8080/blog/making-servoiardi-show-me-a-video
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Type: multipart/byteranges; boundary=i_hope_this_isnt_in_the_data
Connection: close
Server: Tiramisusan
Date: Sun, 5 Apr 2026 21:44:19 +0000
Cache-Control: no-store

--i_hope_this_isnt_in_the_data
Content-Range: bytes 75-122/15099
Content-Type: text/html

<title>Making Servoiardi show me a video</title>
--i_hope_this_isnt_in_the_data
Content-Range: bytes 723-764/15099
Content-Type: text/html

<h1>Making Servoiardi show me a video</h1>
--i_hope_this_isnt_in_the_data--

I am yet to find anything that actually handles a multipart range response, so I used the multer package to test whether I'm at least sending a valid multipart response. Even if it wasn't useful at all to implement this, it is at least fun.

"416 - Range Not Satisfiable" response

When we can't or don't want to supply a particular range, we can respond with a 416 status. We do this for ranges that don't exist in the data, for example:

 $ curl -iH Range:bytes=2000000000- 127.0.0.1:8080
< HTTP/1.1 416 Range Not Satisfiable
< Cache-Control: no-store
< Content-Type: text/plain
< Date: Sat, 4 Apr 2026 20:36:41 +0000
< Content-Range: bytes */1078490608
< Connection: close
< Accept-Ranges: bytes
< Content-Length: 85
< Vary: Accept
< Server: Tiramisusan
<
=== 416 - Range Not Satisfiable ===

RangeNotSatisfiable { content_len: 1078490608 }
* shutting down connection #0

A Content-Range header is still included with a 416 response, but the range being returned is returned with an * if it doesn't exist.

Refusing suspicious ranges

In addition to simply invalid ranges, the multipart range requests can be a potential DOS vector. Remember how we had to check through the content we're about to send to determine whether it contained a boundary? If we're sending a lot of content, then that can take a while. Making a request for Range: bytes=0-,0- on my 1 GB cat video would lock up a thread in the server for tens of seconds!

This isn't an issue with normal ranges, since there's no need for the server to look at the data before sending it down the TCP connection.

To prevent abuse of ranges, I've added two new server-wide settings:

These two limits should prevent anything too nasty happening with multipart ranges. We also use the 416 response to reject suspicious or weird range requests. For example, the following exceeds the maximum number of ranges per request:

 $ curl -H Range:bytes=1-1,2-2,3-3,4-4,5-5,6-6 127.0.0.1:8080
=== 416 - Range Not Satisfiable ===

RangeNotSatisfiable { content_len: 2515 }

A working cat video

After all that, my cat video finally works. I can even skip forward to an arbitrary point and keep watching. My downloads should now be pausable and resumable, and whatever else range requests are used for should work.

It's always fun implementing new RFCs. It feels meditative, and it's one of the few software development activities with a definite endpoint. In contrast, I always struggle to write things like this blog post, they never feel right or finished. I guess I need to try and be more authoritative about telling myself that I need to keep working on something until it's done, and telling myself what exactly constitutes "done". Maybe life would be easier in general if it was all written down for us in RFCs.

Written by Francis Wharf
Prev post Archive