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
- FFmpeg for remuxing existing files:
ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4rewrites without re-encoding. - Object storage that honours
Rangeon GET (S3, GCS, Azure Blob and R2 all do). - Node 20+ with
@aws-sdk/client-s3v3 if you proxy through Node, as below. curlfor 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.
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: byteson 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/sizewith the total size. Safari will not play video over HTTP unless the server answers its initialRange: bytes=0-1probe 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-Lengthequal to the slice length, not the file size. A mismatch makes the browser wait for bytes that never come, and the player stalls.MAX_CHUNKfor open-ended ranges. Browsers routinely sendbytes=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-RangeandIfMatch. If the object changed between the browser’s first and second request, splicing bytes from two different files produces corrupt video. HonouringIf-Range(return the full new file) and passingIfMatchto 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
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
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.