Engineering

Why Safari would not play our video, and the 30-line Worker that fixed it

Cloudflare Workers static assets answer range requests with the whole file, and Safari refuses to play video served that way. How we found and fixed it.

Our homepage hero is a short, looping video of our lighthouse. It worked everywhere we tested in Chrome. Then we checked what Safari and iPhones would receive, and found they would never play it at all. The cause was one HTTP detail: range requests.

The symptom

Safari, and every browser on iPhone, fetches video in pieces. Before playing, it asks for the first couple of bytes:

GET /media/beacon-light-d.mp4
Range: bytes=0-1

A server that supports ranges answers with 206 Partial Content, the two bytes, and a Content-Range header that tells the browser the total size. From there Safari requests the parts it needs, and can seek.

Our site is served by Cloudflare Workers with static assets. We tested it:

curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
  -H 'Range: bytes=0-1' https://thenextbeacon.com/media/beacon-light-d.mp4
# 200 572876

A 200 with the full 572 KB. Chrome copes with that. Safari treats a server that ignores ranges as unable to stream video, and the <video> element never starts. Nothing appears in the console; the poster image just stays.

The fix: let the Worker handle /media/*

By default, static assets are matched before your Worker runs, so the Worker never sees those requests. The run_worker_first option lets you send selected paths through your code first:

assets = { directory = "./public", binding = "ASSETS", run_worker_first = ["/media/*"] }

Then the Worker fetches the full asset and slices it when a Range header is present:

async function serveMedia(request, env) {
  const range = request.headers.get("range");
  const m = range && /^bytes=(\d*)-(\d*)$/.exec(range.trim());
  if (!m || request.method !== "GET") return env.ASSETS.fetch(request);

  const full = await env.ASSETS.fetch(new Request(request.url, { method: "GET" }));
  if (!full.ok) return full;
  const body = await full.arrayBuffer();
  const size = body.byteLength;

  let start, end;
  if (m[1] === "") { start = Math.max(0, size - Number(m[2] || 0)); end = size - 1; } // bytes=-500
  else { start = Number(m[1]); end = m[2] === "" ? size - 1 : Math.min(Number(m[2]), size - 1); }

  const headers = new Headers(full.headers);
  headers.set("accept-ranges", "bytes");
  if (start >= size || start > end) {
    headers.set("content-range", `bytes */${size}`);
    return new Response(null, { status: 416, headers });
  }
  headers.set("content-range", `bytes ${start}-${end}/${size}`);
  headers.set("content-length", String(end - start + 1));
  return new Response(body.slice(start, end + 1), { status: 206, headers });
}

It handles the three forms browsers use (0-1, 100- and the suffix form -500), returns 416 for ranges past the end, and keeps the original caching headers. Our files are under a megabyte each, so reading the whole asset into memory is fine; for large files you would stream instead.

After deploying, the same curl returns 206 2, and the video plays on iPhone.

Lessons

  • Test video with a Range header, not just in a desktop browser. curl -H 'Range: bytes=0-1' takes one second and tells you everything.
  • Chrome is forgiving, Safari is not. If something media-related works in Chrome only, check ranges first.
  • Keep files small anyway. Our loops are 140 to 640 KB each, with a still poster shown first, so even a slow connection gets a good first impression.

We wrote about how the loop itself was made in Seamless video loops with Remotion.

Start a project

Have something you want built?

Tell us about it. We reply to every message with an honest take on whether we are the right fit.

Start a project