# Range Header

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

Source: https://howhttpworks.com/headers/range
Last reviewed: 2026-10-05

> **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](https://www.rfc-editor.org/rfc/rfc9110#section-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](https://howhttpworks.com/headers/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.

```http
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:

```http
GET /sample.txt HTTP/1.1
Host: localhost:8080
Range: bytes=5-99
```

gets this response, with five bytes of body:

```http
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](https://howhttpworks.com/headers/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:

```bash
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:

```bash
mkdir -p /tmp/http-range-demo
printf '0123456789' > /tmp/http-range-demo/sample.txt
```

Put this server block inside an existing nginx `http` block:

```nginx
server {
    listen 8080;
    server_name localhost;
    root /tmp/http-range-demo;

    location / {
        max_ranges 1;
    }
}
```

[`max_ranges 1`](https://nginx.org/en/docs/http/ngx_http_core_module.html#max_ranges) 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`](https://caddyserver.com/docs/caddyfile/directives/file_server) also handles partial static-file responses. With the same fixture, save this as `/tmp/range.Caddyfile`:

```caddy
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](https://caddyserver.com/docs/caddyfile/directives/root) 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:

```bash
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:

```bash
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](https://howhttpworks.com/headers/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:

```javascript
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()`](https://nodejs.org/api/stream.html#streamreadablefromwebreadablestream-options) 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](https://howhttpworks.com/headers/if-range#getting-if-range-right) walks through that decision. Getting the right bytes and confirming they belong to the version you saved are two separate checks.

## Related Headers

- [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges)
- [Content-Range](https://howhttpworks.com/headers/content-range)
- [If-Range](https://howhttpworks.com/headers/if-range)
