# 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.

Source: https://howhttpworks.com/guides/range-requests
Last reviewed: 2026-10-04

> **TL;DR:** A client sends `Range: bytes=start-end` (zero-based, inclusive) and a server that supports it replies `206 Partial Content` with `Content-Range: bytes start-end/total`. Add `If-Range` with the strong `ETag` you saw earlier so a changed file comes back whole with `200` instead of being spliced into a corrupt resume. Ranges count encoded bytes, so on-the-fly compression and many CDN cache misses quietly turn a 206 into a full 200.

## The exchange

A range request is a normal `GET` with one extra header. Here is the full exchange against a static file served by nginx:

```bash
curl -s -D - -o /dev/null -r 0-99 https://example.com/downloads/app-1.4.2.tar.gz
```

```http
HTTP/1.1 206 Partial Content
Server: nginx
Content-Type: application/octet-stream
Content-Length: 100
Last-Modified: Wed, 23 Apr 2025 11:55:39 GMT
ETag: "6808d53b-13886f"
Content-Range: bytes 0-99/1280111
```

- `Content-Length: 100` is the size of this message's body, not the file.
- `Content-Range: bytes 0-99/1280111` says which bytes these are and how big the whole representation is. A `*` in place of the total means the server does not know it (RFC 9110 §14.4).
- The `ETag` is the validator you must keep if you plan to request more of this file later.

RFC 9110 §15.3.7 requires a 206 to carry `Date`, `Cache-Control`, `ETag`, `Expires`, `Content-Location` and `Vary` whenever a 200 for the same request would have, so a 206 is cacheable on the same terms.

Range handling is defined only for `GET` (RFC 9110 §14.2). A server ignores `Range` on other methods, and it may ignore it on `GET` too, in which case you get a normal `200` with the whole file. A client must always check the status code; seeing bytes arrive proves nothing.

## Range syntax

From RFC 9110 §14.1.2, for a 10,000-byte file:

| Header | Bytes returned | Notes |
| --- | --- | --- |
| `Range: bytes=0-499` | 0 to 499 | First 500 bytes. Both ends inclusive, offsets start at 0. |
| `Range: bytes=500-` | 500 to 9999 | Everything from offset 500. This is what a resume sends. |
| `Range: bytes=-500` | 9500 to 9999 | Suffix range: the last 500 bytes. |
| `Range: bytes=9500-20000` | 9500 to 9999 | An end past the file is clamped to the last byte. |
| `Range: bytes=0-0,-1` | 0 and 9999 | Two ranges: the response becomes multipart. |
| `Range: bytes=10000-` | none | Starts at or past the end: unsatisfiable, so 416. |

The off-by-one that catches everyone: `bytes=0-99` is 100 bytes, not 99. To fetch the first N bytes, request `0-(N-1)`.

## Accept-Ranges is a hint

```bash
curl -sI https://example.com/downloads/app-1.4.2.tar.gz | grep -i -E '^(accept-ranges|content-length|etag|content-encoding):'
```

```text
Content-Length: 1280111
ETag: "6808d53b-13886f"
Accept-Ranges: bytes
```

`Accept-Ranges: bytes` says ranges are supported for this resource; `Accept-Ranges: none` asks clients not to try. RFC 9110 §14.3 is explicit that it is advisory both ways: a client may send `Range` without having seen it, and must not assume that seeing it means the next range request will get a 206, because "a different intermediary might process the next request." Treat `curl -I` as a quick hint and `curl -r` as the real test.

## Multiple ranges and multipart/byteranges

Ask for two ranges and the 206 body becomes a MIME multipart document:

```bash
curl -s -D - -r 0-99,200-299 https://example.com/downloads/app-1.4.2.tar.gz
```

```http
HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=00000000000000000003
Content-Length: 437

--00000000000000000003
Content-Type: application/octet-stream
Content-Range: bytes 0-99/1280111

...100 bytes...
--00000000000000000003
Content-Type: application/octet-stream
Content-Range: bytes 200-299/1280111

...100 bytes...
--00000000000000000003--
```

Rules from RFC 9110 §15.3.7.2 and §14.6:

- There is no top-level `Content-Range`. Each part carries its own.
- A server must not send a multipart response to a single-range request, so clients that only ask for one range never have to parse multipart.
- A server may merge overlapping or nearby ranges and may return them in a different order, so clients must read each part's `Content-Range` rather than assume.

Multi-range requests are also a denial-of-service vector. RFC 9110 §17.15 says servers "ought to ignore, coalesce, or reject" requests for more than two overlapping ranges or many small ranges. nginx's `max_ranges` caps the count: requests over the limit are handled as if they had no `Range` header (a full 200), and `max_ranges 0;` turns byte ranges off entirely. Cloudflare treats a header with more than 300 ranges as a non-range request. If only your multi-range requests fail, look for one of these limits before debugging anything else.

## 416 Range Not Satisfiable

When no requested range overlaps the file, the server answers [416](https://howhttpworks.com/status-codes/416) with the real length:

```bash
curl -s -D - -o /dev/null -r 99999999- https://example.com/downloads/app-1.4.2.tar.gz
```

```http
HTTP/1.1 416 Requested Range Not Satisfiable
Content-Type: text/html; charset=utf-8
Content-Range: bytes */1280111
```

nginx still prints the old reason phrase "Requested Range Not Satisfiable". RFC 9110 calls it "Range Not Satisfiable"; clients look at the number, so the wording does not matter.

The usual causes:

- **Resuming a download that already finished.** The local file is complete, so the resume asks for `bytes=<total>-`. curl handles this: with `-C -` on a complete file it gets the 416 and exits successfully without touching the file.
- **The file shrank** since the client recorded its offset. `Content-Range: bytes */<length>` tells the client the new size.
- **A client bug** producing `bytes=-0` or an inverted range such as `bytes=500-100`.

RFC 9110 also notes that servers are free to ignore `Range` instead, so "clients cannot depend on receiving a 416" even when it is the right answer. Many servers send a 200 with the whole file.

## If-Range: resume without corrupting the file

The dangerous case is a file that changes between the first and second request. You downloaded 500,000 bytes of version A, the file was replaced with version B, and the resume asks for `bytes=500000-`. Without a check, the server sends the tail of B and you glue it onto the head of A. The checksum fails, or worse, nothing checks it.

`If-Range` (RFC 9110 §13.1.5) closes that gap. It means: "if the representation still matches this validator, send the range; otherwise send me the whole new thing." It never produces 412.

Testing the same nginx file with each kind of validator:

```bash
url=https://example.com/downloads/app-1.4.2.tar.gz

# Current strong ETag: the range is honoured
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -r 1000- -H 'If-Range: "6808d53b-13886f"' "$url"
# 206 1279111

# Stale ETag: Range is ignored, the full file comes back
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -r 1000- -H 'If-Range: "stale-etag"' "$url"
# 200 1280111

# Exact Last-Modified date also works as a validator
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' -r 1000- -H 'If-Range: Wed, 23 Apr 2025 11:55:39 GMT' "$url"
# 206 1279111
```

The rules that matter:

- **Only strong validators.** A client must not send a weak ETag (`W/"…"`) in `If-Range`, and the server compares with the strong comparison function, so a weak ETag never matches. Any layer that weakens ETags (on-the-fly compression does) makes every resume fall back to a full download.
- **Dates must be exact and strong.** A date matches only if it equals `Last-Modified` exactly, and a client should use one only when it has no ETag and the date is strong under §8.8.2.2: at least one second older than the response's `Date`.
- **ETags must agree across servers.** Behind a load balancer, every backend must generate the same ETag for the same file. Otherwise `If-Range` fails whenever consecutive requests land on different nodes, and resumes silently become full downloads. nginx builds static ETags from the file's modification time and size, so deploys that preserve mtimes keep them consistent; deploys that touch files at different times do not.

### What curl does and does not do

`curl -C -` works out the offset from the partial file and sends `Range: bytes=<size>-`. In curl 8.7.1, which this page was tested with, it does **not** add `If-Range`:

```text
> GET /downloads/app-1.4.2.tar.gz HTTP/1.1
> Host: example.com
> Range: bytes=500000-
> User-Agent: curl/8.7.1
> Accept: */*
>
< HTTP/1.1 206 Partial Content
< Content-Length: 780111
< ETag: "6808d53b-13886f"
< Content-Range: bytes 500000-1280110/1280111
```

So a plain `curl -C -` will happily append the tail of a changed file. Add the validator yourself:

```bash
# First attempt: note the ETag
curl -s -D headers.txt -o app.tar.gz https://example.com/downloads/app-1.4.2.tar.gz
grep -i '^etag:' headers.txt

# Resume, but only if the file is unchanged
curl -C - -H 'If-Range: "6808d53b-13886f"' -o app.tar.gz https://example.com/downloads/app-1.4.2.tar.gz
```

If the file has changed, the server ignores `Range` and returns 200, and curl refuses to resume rather than corrupt the file:

```text
curl: (33) HTTP server doesn't seem to support byte ranges. Cannot resume.
```

(Over HTTP/2 the same condition can surface with a different exit code.) Delete the partial file and start again. Whatever tool you use, verify a published checksum after any resumed download.

## Why video players and download managers depend on ranges

**Video seeking.** A progressive MP4 or WebM file is one big resource. When you drag the scrubber to minute 40, the player works out the byte offset from the file's index and requests a range starting there. Without 206 support the only way to reach minute 40 is to download everything before it. Apple's Safari documentation is direct about it: "HTTP servers hosting media files for iOS must support byte-range requests, which iOS uses to perform random access in media playback." Its suggested check is `curl --range 0-99 <url> -o /dev/null`: 100 bytes means ranges work; the whole file means they do not.

**The MP4 index.** An MP4's index (the `moov` atom) is normally written at the end of the file. A player that receives the start of the file cannot begin playback until it has fetched that index, which costs an extra range request to the end of the file, or a full download on a server without ranges. ffmpeg's `-movflags +faststart` runs "a second pass moving the index (moov atom) to the beginning of the file". Do that for every MP4 you serve progressively.

**Download managers.** Pause and resume is a range request with `bytes=<downloaded>-`. Parallel "segmented" downloaders open several connections, each asking for a different single range of the same file, and stitch the parts together. Both only work safely if the file has a stable strong ETag.

Adaptive streaming (HLS, DASH) usually splits video into many small segment files, so it needs ranges less, although both formats can also address byte ranges inside a single file.

## Compression and ranges

RFC 9110 §14.1.2 settles which bytes a range counts: "If the representation data has a content coding applied, each byte range is calculated with respect to the encoded sequence of bytes, not the sequence of underlying bytes that would be obtained after decoding."

So `bytes=0-99` of a `Content-Encoding: gzip` response is the first 100 bytes of gzip data, not of the decoded file. That works when the encoded file is fixed on disk, for example a pre-compressed `.gz` sidecar with its own strong ETag. It cannot work for on-the-fly compression, where the encoded bytes and their length do not exist until the compressor runs.

Servers handle this by not offering ranges on compressed responses. nginx's gzip filter (and the third-party Brotli filter) removes `Accept-Ranges`, removes `Content-Length` and weakens the `ETag` on every response it compresses. The practical rules:

- Do not compress video, audio or archives at all. They are already compressed, and compressing them breaks seeking and resumes. See [HTTP compression](https://howhttpworks.com/guides/http-compression) for what to exclude.
- If a large text file such as a log or CSV must be resumable, serve a fixed pre-compressed file or serve it uncompressed.
- When a range request returns 200, check for `Content-Encoding` in the response before blaming the range code.

## CDNs and proxies

A CDN sits between the client's `Range` header and your origin, and it rarely forwards it unchanged.

**Cloudflare**, from its [range request documentation](https://developers.cloudflare.com/cache/reference/range-requests/) (updated September 2026):

- Without the Origin Range Requests setting, on a cacheable miss Cloudflare may strip the client's `Range`, fetch the complete file from the origin, then answer the client's range from it. On a bypassed or uncacheable request it may forward the original `Range`.
- With Origin Range Requests enabled in a Cache Rule, Cloudflare fetches ranges aligned to 1 MiB boundaries and may split one client request into several origin requests, so your origin logs show ranges the client never sent. Those origin requests carry `Accept-Encoding: identity`.
- For Cloudflare to cache an origin 206, it must be unencoded, have an exact `Content-Range` with the full size, a `Content-Length` equal to the range, and no `Transfer-Encoding`.
- If Cloudflare has to decompress an encoded response, it ignores `Range` and sends the complete uncompressed body as 200.
- A matching `If-Range` against the cached `ETag` or `Last-Modified` keeps the range; a mismatch returns the full file.
- In Workers, `cache.put()` throws if given a 206 response.

**nginx as a caching proxy.** By default a cache miss for a range fetches the whole object. For large files, the `slice` module (not built by default; needs `--with-http_slice_module`) splits the object into fixed-size cacheable pieces. This is nginx's documented example:

```nginx
location / {
    slice             1m;
    proxy_cache       cache;
    proxy_cache_key   $uri$is_args$args$slice_range;
    proxy_set_header  Range $slice_range;
    proxy_cache_valid 200 206 1h;
    proxy_pass        http://localhost:8000;
}
```

**Your own application.** Serving a file by piping a stream to the response gives no range support. Use the framework's file helpers, which implement it: Express's `express.static` and `res.sendFile` accept ranges by default (`acceptRanges: true` in `serve-static`), and Go's `http.ServeContent` "handles Range requests properly" along with `If-Range` and the other conditional headers. For files in object storage, pass the client's `Range` through to the storage API or redirect to a signed URL rather than proxying the full body.

## Debugging checklist

1. **Does the origin do ranges?** `curl -s -D - -o /dev/null -r 0-99 <origin-url>`. Want `206` and `Content-Range: bytes 0-99/<size>`.
2. **Does the CDN URL do ranges?** Same command on the public URL. A 200 here but 206 at the origin points at the CDN or a proxy.
3. **Is something compressing it?** Look for `Content-Encoding` in the response. Exclude the content type from compression.
4. **Is the ETag strong and stable?** `curl -sI` the URL several times; the ETag should be identical and not start with `W/`.
5. **Does If-Range work?** Repeat step 1 with `-H 'If-Range: "<etag>"'`. A 200 means the validator does not match.
6. **Multi-range?** If only multi-range requests fail, check `max_ranges` or your CDN's limit.
7. **Resume test.** Download part of a file, resume with `curl -C -` plus `If-Range`, then compare `shasum -a 256` against a full download.

## Related

- [Range](https://howhttpworks.com/headers/range), [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges), [Content-Range](https://howhttpworks.com/headers/content-range) and [If-Range](https://howhttpworks.com/headers/if-range) for header-level reference.
- [206 Partial Content](https://howhttpworks.com/status-codes/206) and [416 Range Not Satisfiable](https://howhttpworks.com/status-codes/416) for the status codes.
- [ETag](https://howhttpworks.com/headers/etag) for strong and weak validators.
- [HTTP compression](https://howhttpworks.com/guides/http-compression) for why on-the-fly compression disables ranges.
- [HTTP headers and caching](https://howhttpworks.com/guides/headers-and-caching) for how 206 responses fit into caching.
