Handling range requests in a service worker

Make sure your service worker knows what to do when a partial response is requested.

Some HTTP requests contain a Range: header, indicating that only a portion of the full resource should be returned. They're commonly used for streaming audio or video content to allow smaller chunks of media to be loaded on demand, instead of requesting the entirety of the remote file all at once.

A service worker is JavaScript code that sits in between your web app and the network, potentially intercepting outgoing network requests and generating responses for them.

Historically, range requests and service workers haven't played nicely together. It's been necessary to take special steps to avoid bad outcomes in your service worker. Fortunately, this is starting to change. In browsers exhibiting the correct behavior, range requests will "just work" when passing through a service worker.

What's the issue?

Consider a service worker with the following fetch event listener, which takes every incoming request and passes it to the network:

self.addEventListener('fetch', (event) => {
  // The Range: header will not pass through in
  // browsers that behave incorrectly.
  event.respondWith(fetch(event.request));
});

In browsers with the incorrect behavior, if event.request included a Range: header, that header would be silently dropped. The request that was received by the remote server would not include Range: at all. This would not necessarily "break" anything, since a server is technically allowed to return the full response body, with a 200 status code, even when a Range: header is present in the original request. But it would result in more data being transferred than is strictly needed from the perspective of the browser.

Developers who were aware of this behavior could work around it by explicitly checking for the presence of a Range: header, and not calling event.respondWith() if one is present. By doing this, the service worker effectively removes itself from the response generation picture, and the default browser networking logic, which knows how to preserve range requests, is used instead.

self.addEventListener('fetch', (event) => {
  // Return without calling event.respondWith()
  // if this is a range request.
  if (event.request.headers.has('range')) {
    return;
  }

  event.respondWith(fetch(event.request));
});

It's safe to say that most developers were not aware of the need to do this, though. And it wasn't clear why that should be required. Ultimately, this limitation was due to browsers needing to catch up to changes in the underlying specification, which added support for this functionality.

What's been fixed?

Browsers that behave correctly preserve the Range: header when event.request is passed to fetch(). This means the service worker code in my initial example will allow the remote server to see the Range: header, if it was set by the browser:

self.addEventListener('fetch', (event) => {
  // The Range: header will pass through in browsers
  // that behave correctly.
  event.respondWith(fetch(event.request));
});

The server now gets a chance to properly handle the range request and return a partial response with a 206 status code.

Which browsers behave correctly?

Recent versions of Safari have the correct functionality. Chrome and Edge, starting with version 87, behave correctly as well.

As of this October 2020, Firefox has not yet fixed this behavior, so you may still need to account for it while deploying your service worker's code to production.

Checking the "Include range header in network request" row of the Web Platform Tests dashboard is the best way to confirm whether or not a given browser has corrected this behavior.

What about serving range requests from the cache?

Service workers can do much more than just pass a request through to the network. A common use case is to add resources, like audio and video files, to a local cache. A service worker can then fulfill requests from that cache, bypassing the network entirely.

All browsers, including Firefox, support inspecting a request inside a fetch handler, checking for the presence of the Range: header, and then locally fulfilling the request with a 206 response that comes from a cache. The service worker code to properly parse the Range: header and return only the appropriate segment of the complete cached response is not trivial, though.

Fortunately, developers who want some help can turn to Workbox, which is a set of libraries that simplifies common service worker use cases. The workbox-range-request module implements all the logic necessary to serve partial responses directly from the cache. A full recipe for this use case can be found in the Workbox documentation.

The hero image on this post is by Natalie Rhea Riggs on Unsplash.