Request header
> Range: bytes=0-1023Range Header
Learn how the Range header requests partial content from servers to enable resumable downloads, video streaming, and efficient large file transfers.
- Direction
- Request
- Category
- Range requests
- Spec
- RFC 9110 §14.2
- JS can set it
- Yes
- CORS-safelisted
- No: may trigger a preflight
On this page
TL;DR:
Rangeasks for part of a file instead of the whole thing:Range: bytes=0-4requests the first five bytes. Always check the status.206means you got the slice,200means the server ignored Range and sent everything, and416means none of the requested range exists.
What is Range?
Range asks the server for one or more slices of a resource. It’s what powers resumable downloads and video seeking. Servers are allowed to ignore it, and RFC 9110 §14.2 requires them to ignore it on anything other than GET. That means a HEAD request with Range tells you nothing about partial-body handling.
Accept-Ranges advertises support, but clients can send Range without ever seeing it, and the next response is what really tells you whether it worked.
Range Syntax
Requests and responses on this page are examples.
Range: bytes=0-4
Range: bytes=5-
Range: bytes=-3
Range: bytes=0-1,8-9
In order: an inclusive interval, everything from offset 5 onward, the last three bytes, and two intervals at once. Offsets start at zero and both ends are inclusive. A suffix of zero bytes is unsatisfiable. If you ask for a longer suffix than the file has, you get the whole file.
An end offset past the end of the file gets clamped to the last byte; that alone is not a reason for 416. A start offset at or beyond the length is different: there are no bytes to select.
How Range Works
Say the file contains exactly 0123456789. This request:
GET /sample.txt HTTP/1.1
Host: localhost:8080
Range: bytes=5-99
gets this response, with five bytes of body:
HTTP/1.1 206 Partial Content
Content-Type: text/plain
Content-Range: bytes 5-9/10
Content-Length: 5
56789
When Content-Encoding is present, offsets count the encoded bytes, not positions in the decompressed file. Keep the representation and its content coding the same across resume requests.
Server Response Patterns
A single-part 206 includes Content-Range. A multipart 206 uses multipart/byteranges, with a Content-Range in each part and none at the top level.
Servers can merge overlapping ranges or refuse a request with lots of tiny ones. A 416 for a byte range should include Content-Range: bytes */length. Downloaders sometimes treat 416 as “already finished”, but it only says none of the requested ranges could be satisfied. Compare that length with your local file before you call the download complete.
Test a precondition failure separately from an unsatisfiable range:
curl -sS -D - --range 0-4 \
-H 'If-Match: "not-the-fixture-tag"' \
http://localhost:8080/sample.txt
A handler that evaluates If-Match should answer 412 when the tag doesn’t match, before it looks at Range at all. That’s a 412, not a 416, and no bytes. If-Range behaves differently: a mismatch drops the Range and the request carries on. A downloader relies on these differences to decide whether to retry, replace its partial file, or report a version conflict.
When you’re tracing how a deployment compresses responses, compare requests with and without Accept-Encoding: identity, and note Content-Encoding and Vary alongside Content-Range. The total length of a gzip response tells you nothing about offsets in the uncompressed one. Test through the real proxy path as well as directly against the origin, because something downstream may change the bytes the client actually receives.
Server Implementation
Let a static-file handler do the range framing for you. To try nginx locally, create a fixture with known contents:
mkdir -p /tmp/http-range-demo
printf '0123456789' > /tmp/http-range-demo/sample.txt
Put this server block inside an existing nginx http block:
server {
listen 8080;
server_name localhost;
root /tmp/http-range-demo;
location / {
max_ranges 1;
}
}
max_ranges 1 allows one range per request. Anything asking for more is handled as if Range weren’t there. max_ranges 0 turns byte ranges off entirely. Adding an Accept-Ranges header by hand implements none of this; the file handler does the work.
Caddy’s file_server also handles partial static-file responses. With the same fixture, save this as /tmp/range.Caddyfile:
http://localhost:8083 {
root * /tmp/http-range-demo
file_server
}
Run it with caddy run --config /tmp/range.Caddyfile --adapter caddyfile, then request http://localhost:8083/sample.txt. The explicit http:// keeps this local example on plain HTTP. The root directive only picks the directory; file_server is what serves files. Leave Content-Range and Content-Length to file_server, which computes them for whichever range it selected.
Testing Range Requests
After loading that nginx configuration:
curl -sS -D - --range 0-4 http://localhost:8080/sample.txt
curl -sS -D - --range '-3' http://localhost:8080/sample.txt
curl -sS -D - --range 10- http://localhost:8080/sample.txt
Look at the status, Content-Range, and how many body bytes actually arrived. Use a GET for this; curl -I sends HEAD, which ignores Range.
With the ten-byte fixture, these cases cover the boundaries:
| Range field | Selected bytes when honored | Single-part Content-Range |
|---|---|---|
bytes=0-0 | 0 | bytes 0-0/10 |
bytes=5-99 | 56789 | bytes 5-9/10 |
bytes=-3 | 789 | bytes 7-9/10 |
bytes=-20 | 0123456789 | bytes 0-9/10 |
bytes=10- | None; unsatisfiable | bytes */10 on 416 |
Asking for the last twenty bytes of a ten-byte file returns all ten, still as a 206. A two-range request is different: with max_ranges 1, nginx handles it as if Range weren’t there:
curl -sS -D - --range 0-1,8-9 http://localhost:8080/sample.txt
So you should see a full 200 response. If you lift the limit to test multipart handling, read each part’s own Content-Range, and remember that the boundaries and part headers in a multipart body are framing, not file bytes.
The fixture uses printf without a newline so the file is exactly ten bytes. Create it with echo and you get an eleventh byte, a trailing newline, which shifts every total length and suffix offset in the table.
Resumable Downloads and Video Streaming
Save a strong ETag from the response that supplied your partial file, then send it in If-Range with the next Range request. If the validator no longer matches, the server ignores Range and sends the full file as a 200. Replace your partial file when that happens; appending a full body to it corrupts the download.
Byte offsets aren’t video timestamps or PDF page numbers. A media player reads the format’s index to work out which offsets to request.
In Node, Fetch gives you a Web ReadableStream, so response.body.pipe(...) won’t work; that’s the Node stream API. This standalone probe fetches the second half of the nginx fixture and writes just that slice to disk:
import { createWriteStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const response = await fetch('http://localhost:8080/sample.txt', {
headers: { Range: 'bytes=5-', 'Accept-Encoding': 'identity' },
});
if (response.status !== 206 ||
response.headers.get('content-range') !== 'bytes 5-9/10' ||
(response.headers.get('content-encoding') ?? 'identity') !== 'identity') {
await response.body?.cancel();
throw new Error('Expected the identity-coded fixture range');
}
await pipeline(
Readable.fromWeb(response.body),
createWriteStream('/tmp/range.part'),
);
if ((await stat('/tmp/range.part')).size !== 5) {
throw new Error('Incomplete range body');
}
Save it as range-probe.mjs and run node range-probe.mjs. Readable.fromWeb() converts the stream, and pipeline propagates transfer failures. It writes a separate fragment rather than appending to an existing download. If the transfer fails partway, the fragment is incomplete, so treat it as scratch until the size check passes.
For a real resume, store the ETag alongside the first fragment, send it in If-Range, and check both the returned interval and the version before you append. The If-Range page walks through that decision. Getting the right bytes and confirming they belong to the version you saved are two separate checks.
Related Headers
Frequently asked questions
What is the Range header?
Range asks for part of a resource on a GET request, such as bytes=0-499. The server can honor it with 206 Partial Content, ignore it and send the whole thing with 200, or answer 416 if none of the requested ranges exist.
How do I request a byte range?
Use bytes=0-4 for the first five bytes, bytes=5- for bytes from offset five onward, or bytes=-3 for the final three bytes. End offsets are inclusive.
How do I resume a download?
Send Range starting at the size of your partial file, plus If-Range with the ETag you saved when you started the download. Append the body only if you get a matching 206. A full 200 means the file changed, so replace your partial copy.
What if the server does not support ranges?
Then it ignores Range and returns 200 with the full body. Accept-Ranges hints at support, but the status code and Content-Range on the actual GET response are what count, and clients are free to try Range without seeing Accept-Ranges first.
Sources
Related
HTTP Range Requests: 206, Resume and Video Seeking
How HTTP range requests work: Range, Accept-Ranges, 206 and Content-Range, multipart/byteranges, 416, If-Range for safe resumes, and CDN and compression traps.
Accept-Encoding Header
Learn how Accept-Encoding tells servers which compression formats (gzip, br, deflate) your client supports to reduce bandwidth and speed up page loads.
If-Match Header
Learn how the If-Match header makes requests conditional based on ETag matching. Prevent conflicts and lost updates in concurrent editing scenarios.
If-Modified-Since Header
Learn how the If-Modified-Since header requests resources only if modified since a specific date. Reduce bandwidth with efficient conditional caching.