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
Rangeheader, 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.