Serving Video with HTTP Range Requests

Write every MP4 with -movflags +faststart so the moov index sits at the front, serve it from something that answers Range: bytes=start-end with 206 Partial Content, an exact Content-Range, Accept-Ranges: bytes and the correct Content-Length — and if you proxy media through your own code, stream only the requested slice from storage and return 416 for ranges past the end.

Progressive MP4 is still the right delivery format for short clips, previews, downloads and any video under a minute or two, where adaptive streaming’s manifest round trips cost more than they save. But a progressive file only feels instant if the browser can jump to any byte: it fetches the index, then the bytes for the time the user seeks to. Get the headers or the file layout wrong and seeking re-downloads from the start, the scrubber is greyed out, or Safari refuses to play at all. This page belongs to secure media delivery in media processing and delivery pipelines. For longer video, compare with adaptive bitrate video streaming.

When to use this approach

  • Clips are short, or you are serving previews and downloads rather than long-form playback.
  • Media goes through your own code — an authorising proxy, a watermarking step, a Worker in front of a private bucket — and you must implement ranges yourself.
  • Seeking or Safari playback is broken and you suspect headers.

Prerequisites

  1. FFmpeg for remuxing existing files: ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4 rewrites without re-encoding.
  2. Object storage that honours Range on GET (S3, GCS, Azure Blob and R2 all do).
  3. Node 20+ with @aws-sdk/client-s3 v3 if you proxy through Node, as below.
  4. curl for verification.

Why the index position decides everything

An MP4 file is a sequence of boxes. mdat holds the media samples; moov holds the index that says where each frame lives inside mdat. Encoders write moov last by default, because they only know the index after writing every sample. A browser cannot play or seek until it has moov, so with the default layout it must fetch the end of the file first.

MP4 box layout with and without faststart Without faststart the file is ftyp, then a large mdat, then moov at the end, so the browser must request the tail first. With faststart the file is ftyp, moov, then mdat, so the first range request of a few kilobytes contains everything needed to start and to seek. Where moov lives decides how many requests before playback default ftyp mdat — 48 MB of samples moov 1. GET bytes=0- → sees mdat, no index yet 2. GET bytes=50331000- → fetch moov from the tail 3. GET bytes=… → finally the frames it needs faststart ftyp moov mdat — 48 MB of samples 1. GET bytes=0- → index and first frames in the same response seek → one ranged GET straight to the right offset -movflags +faststart moves moov to the front in a second pass; it costs seconds, not a re-encode.
With the index up front, playback and every later seek need exactly one ranged request each.

Implementation

A Node proxy that authorises the viewer and streams exactly the requested bytes from S3. The same structure applies to a Worker reading from R2.

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { S3Client, GetObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import { Readable } from "node:stream";

const s3 = new S3Client({});
const BUCKET = process.env.MEDIA_BUCKET!;
const MAX_CHUNK = 8 * 1024 * 1024;   // cap open-ended ranges so one request cannot pin a whole file

type Range = { start: number; end: number } | "unsatisfiable" | null;

/** Parse a single "bytes=" range. Multipart ranges are rare for media; treat them as full. */
export function parseRange(header: string | undefined, size: number): Range {
  if (!header) return null;
  const m = header.match(/^bytes=(\d*)-(\d*)$/);
  if (!m) return null;
  const [, a, b] = m;
  if (a === "" && b === "") return null;
  let start: number, end: number;
  if (a === "") {                                  // suffix: last N bytes
    const n = Number(b);
    if (n === 0) return "unsatisfiable";
    start = Math.max(0, size - n);
    end = size - 1;
  } else {
    start = Number(a);
    end = b === "" ? Math.min(size - 1, start + MAX_CHUNK - 1) : Math.min(Number(b), size - 1);
  }
  if (start >= size || start > end) return "unsatisfiable";
  return { start, end };
}

async function authorised(req: IncomingMessage, key: string): Promise<boolean> {
  // Replace with a session / entitlement check.
  return Boolean(req.headers.cookie?.includes("session=")) && key.startsWith("videos/");
}

async function serve(req: IncomingMessage, res: ServerResponse): Promise<void> {
  const key = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname.slice(1));
  if (!(await authorised(req, key))) { res.writeHead(403).end(); return; }

  const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key })).catch(() => null);
  if (!head?.ContentLength) { res.writeHead(404).end(); return; }
  const size = head.ContentLength;
  const common = {
    "Accept-Ranges": "bytes",
    "Content-Type": head.ContentType ?? "video/mp4",
    "ETag": head.ETag ?? "",
    "Last-Modified": head.LastModified?.toUTCString() ?? "",
    "Cache-Control": "private, max-age=3600",
  };

  // If-Range: only honour the range if the client's copy is still current.
  const ifRange = req.headers["if-range"];
  const rangeHeader = ifRange && ifRange !== head.ETag ? undefined : req.headers.range;
  const range = parseRange(rangeHeader, size);

  if (range === "unsatisfiable") {
    res.writeHead(416, { ...common, "Content-Range": `bytes */${size}` }).end();
    return;
  }

  const r = range ?? { start: 0, end: size - 1 };
  const obj = await s3.send(new GetObjectCommand({
    Bucket: BUCKET, Key: key, Range: `bytes=${r.start}-${r.end}`, IfMatch: head.ETag,
  }));

  res.writeHead(range ? 206 : 200, {
    ...common,
    "Content-Length": String(r.end - r.start + 1),
    ...(range ? { "Content-Range": `bytes ${r.start}-${r.end}/${size}` } : {}),
  });
  if (req.method === "HEAD") { res.end(); return; }
  const body = Readable.fromWeb(obj.Body!.transformToWebStream() as import("node:stream/web").ReadableStream);
  body.pipe(res);
  req.on("close", () => body.destroy());          // viewer seeked away: stop reading from S3
}

createServer((req, res) => { serve(req, res).catch(() => { if (!res.headersSent) res.writeHead(502); res.end(); }); })
  .listen(8080);

Line-by-line on the headers that matter

  • Accept-Ranges: bytes on every response, including the full 200. Browsers use it to decide whether seeking is possible; without it Chrome disables the scrubber and Safari may refuse to start.
  • Content-Range: bytes start-end/size with the total size. Safari will not play video over HTTP unless the server answers its initial Range: bytes=0-1 probe with exactly a two-byte 206 and a correct total — a server that ignores the range and returns 200 with the full body fails that probe.
  • Content-Length equal to the slice length, not the file size. A mismatch makes the browser wait for bytes that never come, and the player stalls.
  • MAX_CHUNK for open-ended ranges. Browsers routinely send bytes=0- and then abort once they have enough. Capping at 8 MB means the proxy never commits to streaming a 2 GB file that nobody will watch; the browser simply asks for the next range.
  • If-Range and IfMatch. If the object changed between the browser’s first and second request, splicing bytes from two different files produces corrupt video. Honouring If-Range (return the full new file) and passing IfMatch to S3 (fail if it changed under us) prevents that.
  • req.on("close") destroys the S3 stream. Every seek aborts the previous request; without this the proxy keeps downloading bytes nobody will receive.

What a seek looks like on the wire

Range requests during playback and seeking The browser requests bytes 0 onwards and receives a 206 for the first 8 megabytes containing the index. Playback continues with further ranges. When the user seeks to 3 minutes the browser aborts the current request and asks for the byte offset of the nearest keyframe, receiving another 206. Play, then seek to 3:00 browser server Range: bytes=0- 206 · Content-Range: bytes 0-8388607/50331648 Range: bytes=8388608- (playing on) user seeks to 3:00 — request aborted Range: bytes=31457280- (offset from moov) 206 · frames from the 3:00 keyframe The offset comes from the moov index the first response carried — no guessing, no extra round trip.
A seek is an abort plus one new ranged request; the index is what turns "3:00" into a byte offset.

Configuration gotchas

Seeking works in Chrome, video will not start in Safari. Your server ignores Range on the first request and returns 200. Test with curl -H 'Range: bytes=0-1': the response must be 206, Content-Length: 2, Content-Range: bytes 0-1/<size>.

Compression middleware breaks ranges. gzip or brotli on a video response changes its length, so Content-Range no longer matches the bytes. Exclude video/* and audio/* from compression — they are already compressed.

CDN serves 200 to every range request. Some CDNs fetch the full object from origin on a range miss and then serve ranges from cache; others pass ranges through. Either is fine if the CDN returns 206 to the browser. A CDN configured to strip Range before caching must still answer ranges from its cached copy — check curl -I -H 'Range: bytes=0-1' against the CDN hostname.

416 Range Not Satisfiable on the last seek. The player asked for bytes past the end because the size it learned earlier is stale — the file was replaced in place. Serve replacements under a new URL so a player’s cached size is always right.

Fixing a library of files encoded without faststart

Enabling +faststart in the encoder fixes new uploads; the files already in storage still have moov at the end. Remuxing them is cheap because nothing is re-encoded — FFmpeg copies the streams and rewrites the container — but at library scale it is still a batch job worth planning.

First, find the affected files without downloading them. The first 64 KB of a faststart file contains the moov box; a file without it will show mdat there instead. A ranged GET of that prefix per object is enough to classify it, costing one small request each. Second, remux into a new key rather than overwriting: ffmpeg -i in.mp4 -c copy -map 0 -movflags +faststart out.mp4, uploaded under the asset’s next version, then move the asset’s pointer. Overwriting in place breaks any viewer mid-playback, whose player has cached the old byte offsets, and it invalidates cached ranges at the CDN in ways that are hard to predict.

Third, keep the check in your pipeline permanently. Add an assertion after every encode that reads the first boxes of the output and fails the job if moov does not come before mdat. Encoders change, flags get dropped in refactors, and a single missing option can quietly regress seeking for every upload until someone notices the scrubber is greyed out.

Cost of getting it wrong

Bytes transferred for a watch-and-seek session under three setups A viewer who watches 20 seconds, seeks to the middle and watches 20 more seconds of a 48 megabyte clip transfers about 3.4 megabytes with faststart and correct ranges, about 5.1 megabytes without faststart, and the whole 48 megabytes twice when ranges are unsupported. Watch 20 s, seek to middle, watch 20 s (48 MB clip) faststart + ranges 3.4 MB ranges, no faststart 5.1 MB, slower start no range support 96 MB — whole file, twice Without ranges every seek restarts the download; most players then disable seeking entirely. Remux old uploads with -c copy -movflags +faststart — a few seconds per file, no quality loss.
Range support is worth an order of magnitude in egress for anyone who scrubs; faststart shaves the rest.

Verification

U=https://media.example.com/videos/9c1f/720.mp4

# Safari's probe: exactly two bytes, 206, correct total.
curl -s -o /dev/null -D - -H 'Range: bytes=0-1' "$U" | grep -Ei '^(HTTP|content-range|content-length|accept-ranges)'
# HTTP/2 206
# content-range: bytes 0-1/50331648
# content-length: 2
# accept-ranges: bytes

# Past-the-end range is refused properly.
curl -s -o /dev/null -w '%{http_code}\n' -H 'Range: bytes=999999999-' "$U"
# 416

# moov is at the front (faststart).
curl -s -H 'Range: bytes=0-65535' "$U" | grep -c moov
# 1

Frequently Asked Questions

Does the video element send range requests on its own?

Yes. Every modern browser’s media stack issues Range requests automatically for <video src> and <audio src>; you do not write any client code. What you control is whether the server and the file layout let those requests succeed.

Do I need range support if I use HLS?

Not for normal HLS: each segment is a small, complete file fetched whole. It matters again for byte-range HLS (EXT-X-BYTERANGE), where segments are ranges inside one large file — the same server behaviour described here then applies to every segment request.

Should previews use a separate low-bitrate MP4?

Yes. Hover previews and feed autoplay should use a small, short, muted rendition — a few hundred kilobytes — rather than ranging into the full-quality file. Generate it alongside the main encode; generating animated previews from video covers the options.