A range request is a GET with a Range: bytes=START-END header. If the server supports it, it replies 206 Partial Content with just those bytes and a Content-Range header saying where they sit in the whole file. If it doesn't, it ignores the header and sends a normal 200 with everything. That one fallback is why most range bugs are silent: nothing errors, you just download the whole file again.
Range requests are the mechanism behind video seeking, curl -C - resuming, PDF viewers that show page 1 before the file finishes, and parallel download managers. They also sit right next to the caching headers from the ETag and 304 post: If-Range is a conditional request in disguise.
The exchange, on the wire
The server advertises support on any normal response:
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 10485760
ETag: "v3-9f2c"Then the client asks for the second megabyte:
GET /movie.mp4 HTTP/1.1
Host: cdn.example.com
Range: bytes=1048576-2097151
HTTP/1.1 206 Partial Content
Content-Range: bytes 1048576-2097151/10485760
Content-Length: 1048576
Content-Type: video/mp4Both offsets are inclusive and zero-based, so bytes=0-0 is one byte, and the length is end - start + 1. The number after the slash is the full size, and it's what lets a client draw a progress bar.
| Range header | Meaning |
|---|---|
bytes=0-499 | First 500 bytes |
bytes=500- | From byte 500 to the end (resume a download) |
bytes=-500 | The last 500 bytes (a suffix, not "up to 500") |
bytes=0-99,200-299 | Two ranges, answered as multipart/byteranges |
The suffix form is the one people forget. Zip files keep their central directory at the end, which is how tools list an archive's contents with a couple of small requests instead of downloading it.
416: asking past the end
If the requested start is beyond the file, the server answers 416 Range Not Satisfiable and reports the real size:
GET /movie.mp4 HTTP/1.1
Range: bytes=99999999-
HTTP/1.1 416 Range Not Satisfiable
Content-Range: bytes */10485760This is the classic resume bug. A download tool saved 10 MB, the file on the server was then replaced by a smaller one, and every retry gets a 416. The */size is your cue to throw away the partial file and start over.
If-Range: don't stitch two different files together
Resuming is only safe if the file is the same one you started. If-Range makes the range conditional on a validator:
GET /movie.mp4 HTTP/1.1
Range: bytes=5242880-
If-Range: "v3-9f2c"If the ETag still matches, you get a 206. If it changed, the server ignores Range and sends a fresh 200 with the whole new file. Either way the client gets something consistent, with no extra round trip to check first. Without If-Range, a deploy in the middle of a download gives you a Frankenstein file whose first half is v2 and second half is v3.
Use a strong ETag or an exact
Last-Modifieddate inIf-Range. RFC 9110 says a weak ETag (W/"...") must not match here, because a byte range of a "semantically equivalent" file is not necessarily the same bytes.
Why Safari refuses your video
Safari (and iOS in particular) will not play a media file from a server that can't answer range requests. It starts with Range: bytes=0-1 to probe, and if it gets back a 200 with the full body instead of a 206, playback fails or never starts. Chrome shrugs and plays anyway, which is how this ships to production unnoticed.
The usual culprits:
- A dev server or custom route that streams a file with
res.sendorfs.createReadStream(...).pipe(res). It never looks atRange. - A proxy or CDN rule that strips
Rangeor caches the full object under a key that ignores it. - On-the-fly compression, below.
The compression trap
Ranges apply to the bytes of the representation as sent. If you gzip a response, byte 1000 means byte 1000 of the compressed stream, which a server can't produce without compressing the whole thing first. So many servers simply skip compression when Range is present, and some skip Accept-Ranges when compression is on. The compression post covers the Content-Encoding side; video, zip and already-compressed formats shouldn't be gzipped anyway, so exclude them from your compression rules and ranges just work.
A Node handler that gets the edge cases right
Express's res.sendFile and most static servers handle all of this. When you roll your own, this is the minimum:
import fs from "node:fs";
export function serveFile(req, res, path) {
const size = fs.statSync(path).size;
res.setHeader("Accept-Ranges", "bytes");
const header = req.headers.range;
const m = header && /^bytes=(\d*)-(\d*)$/.exec(header);
if (!m || (m[1] === "" && m[2] === "")) {
res.writeHead(200, { "Content-Length": size });
return fs.createReadStream(path).pipe(res);
}
let start, end;
if (m[1] === "") { // suffix: last N bytes
start = Math.max(0, size - Number(m[2]));
end = size - 1;
} else {
start = Number(m[1]);
end = m[2] === "" ? size - 1 : Math.min(Number(m[2]), size - 1);
}
if (start >= size || start > end) {
res.writeHead(416, { "Content-Range": `bytes */${size}` });
return res.end();
}
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Content-Length": end - start + 1,
});
fs.createReadStream(path, { start, end }).pipe(res);
}Two deliberate choices: a malformed Range falls back to a plain 200 (the spec lets servers ignore a header they can't parse), and end is clamped to size - 1 because bytes=0-99999999 on a small file is valid and should return the whole thing as a 206. It skips multi-range and If-Range; add the latter before you serve anything that gets redeployed.
Test it with curl
# Does the server advertise ranges?
curl -sI https://example.com/movie.mp4 | grep -i accept-ranges
# Fetch bytes 0-99 and show headers
curl -s -D - -o /dev/null -r 0-99 https://example.com/movie.mp4
# Resume a partial download
curl -C - -O https://example.com/movie.mp4A healthy server returns HTTP/1.1 206 and Content-Length: 100 for the second command. If you see 200, the server ignored you. The curl command builder can assemble the header flags if you'd rather not remember them, and the HTTP header analyzer will parse a pasted response so you can eyeball Content-Range against Content-Length.
Common mistakes
- Off-by-one on the end offset.
Content-Range: bytes 0-1024/…is 1025 bytes, not 1024. - Returning
200with aContent-Rangeheader. AContent-Rangebelongs on a206(or416). A client that sees200assumes it got everything. - Dropping
Accept-Rangesbehind a proxy. Clients can still try, but many download managers won't until they see it. - Caching
206s by URL alone. A shared cache must either understand ranges or never store partial responses. Mixed-up caches serve a 1 MB slice as the whole file. - No validator on a mutable URL. Resuming
/latest.zipwithoutIf-Rangeis how corrupt downloads happen.
FAQ
Is a Range request cacheable?
Yes, per RFC 9111, a cache may store a 206 and combine it with other parts of the same representation. In practice behavior varies by CDN: some fetch the whole object on the first range request and serve slices from it, others cache per range. Check yours before assuming.
What happens if I ask for a range and the server ignores it?
You get a 200 with the full body. Always check the status code, not just the presence of data, or your "resume" will append the entire file to the partial one.
Can I use units other than bytes?
The spec allows custom range units, but bytes is the only one in practical use. Servers that don't support a unit should ignore the header.
Do ranges work over HTTP/2 and HTTP/3?
Identically. They're part of HTTP semantics, not the wire protocol; only the framing changes.
The short version for your next media bug: send Range: bytes=0-1 with curl before you touch the player. If you don't get a 206 back, no amount of front-end debugging will fix it.
Cover photo: Pexels.
