How HTTP Works

Request header

> Range: bytes=0-1023

Range Header

Learn how the Range header requests partial content from servers to enable resumable downloads, video streaming, and efficient large file transfers.

Reviewed 6 min readintermediate3 sourcesTry itMarkdown
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: Range asks for part of a file instead of the whole thing: Range: bytes=0-4 requests the first five bytes. Always check the status. 206 means you got the slice, 200 means the server ignored Range and sent everything, and 416 means 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 fieldSelected bytes when honoredSingle-part Content-Range
bytes=0-00bytes 0-0/10
bytes=5-9956789bytes 5-9/10
bytes=-3789bytes 7-9/10
bytes=-200123456789bytes 0-9/10
bytes=10-None; unsatisfiablebytes */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.

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

  1. MDN Web Docs: Rangedeveloper.mozilla.org
  2. RFC 9110: Rangerfc-editor.org
  3. nginx: max_rangesnginx.org

Keep going

Browse /search