Guide
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.
On this page
- The exchange
- Range syntax
- Accept-Ranges is a hint
- Multiple ranges and multipart/byteranges
- 416 Range Not Satisfiable
- If-Range: resume without corrupting the file
- What curl does and does not do
- Why video players and download managers depend on ranges
- Compression and ranges
- CDNs and proxies
- Debugging checklist
- Related
TL;DR: A client sends
Range: bytes=start-end(zero-based, inclusive) and a server that supports it replies206 Partial ContentwithContent-Range: bytes start-end/total. AddIf-Rangewith the strongETagyou saw earlier so a changed file comes back whole with200instead 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:
curl -s -D - -o /dev/null -r 0-99 https://example.com/downloads/app-1.4.2.tar.gz
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: 100is the size of this message’s body, not the file.Content-Range: bytes 0-99/1280111says 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
ETagis 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
curl -sI https://example.com/downloads/app-1.4.2.tar.gz | grep -i -E '^(accept-ranges|content-length|etag|content-encoding):'
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:
curl -s -D - -r 0-99,200-299 https://example.com/downloads/app-1.4.2.tar.gz
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-Rangerather 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 with the real length:
curl -s -D - -o /dev/null -r 99999999- https://example.com/downloads/app-1.4.2.tar.gz
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=-0or an inverted range such asbytes=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:
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/"…") inIf-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-Modifiedexactly, 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’sDate. - ETags must agree across servers. Behind a load balancer, every backend must generate the same ETag for the same file. Otherwise
If-Rangefails 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:
> 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:
# 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:
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 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-Encodingin 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 (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 originalRange. - 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-Rangewith the full size, aContent-Lengthequal to the range, and noTransfer-Encoding. - If Cloudflare has to decompress an encoded response, it ignores
Rangeand sends the complete uncompressed body as 200. - A matching
If-Rangeagainst the cachedETagorLast-Modifiedkeeps 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:
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
- Does the origin do ranges?
curl -s -D - -o /dev/null -r 0-99 <origin-url>. Want206andContent-Range: bytes 0-99/<size>. - 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.
- Is something compressing it? Look for
Content-Encodingin the response. Exclude the content type from compression. - Is the ETag strong and stable?
curl -sIthe URL several times; the ETag should be identical and not start withW/. - Does If-Range work? Repeat step 1 with
-H 'If-Range: "<etag>"'. A 200 means the validator does not match. - Multi-range? If only multi-range requests fail, check
max_rangesor your CDN’s limit. - Resume test. Download part of a file, resume with
curl -C -plusIf-Range, then compareshasum -a 256against a full download.
Related
- Range, Accept-Ranges, Content-Range and If-Range for header-level reference.
- 206 Partial Content and 416 Range Not Satisfiable for the status codes.
- ETag for strong and weak validators.
- HTTP compression for why on-the-fly compression disables ranges.
- HTTP headers and caching for how 206 responses fit into caching.
Frequently asked questions
How do I check if a server supports range requests?
Run curl -s -D - -o /dev/null -r 0-99 https://example.com/file and look at the status line. 206 with Content-Range: bytes 0-99/<total> means ranges work. 200 means something ignored the Range header and sent the whole file. Accept-Ranges: bytes in a curl -I response is a hint, not proof, because RFC 9110 calls it advisory.
How do I resume a download with curl?
Use curl -C - -o file.iso URL. curl reads the size of the partial file and sends Range: bytes=<size>-. curl does not send If-Range on its own, so add -H 'If-Range: "<etag>"' with the ETag from the first download. If the file has changed, the server then replies 200 and curl refuses to resume instead of appending new bytes to old ones.
What does 416 Range Not Satisfiable mean?
None of the requested ranges overlap the file, usually because the start offset is at or past the end. The commonest cause is resuming a download that had already finished. A 416 should carry Content-Range: bytes */<length> so the client can see the real size.
Why does my server return 200 instead of 206?
Something ignored the Range header. Common culprits are on-the-fly compression (nginx removes Accept-Ranges from responses it gzips), an If-Range validator that no longer matches, a weak ETag sent in If-Range, a CDN fetching the full object on a cache miss, application code that streams the body without range support, or a multi-range request above nginx max_ranges.
Why does video seeking not work on my site?
Seeking needs the server to answer range requests with 206 so the player can fetch bytes from the middle of the file. Check that the video URL returns 206 for curl -r 0-99, that it is not being gzipped, and for MP4 that the moov index is at the start of the file (ffmpeg -movflags +faststart), otherwise the player must fetch the end of the file before it can start.
Do range requests work with gzip compression?
Only on a fixed encoded file. RFC 9110 says byte ranges are counted in the encoded bytes, so a pre-compressed .gz file with a stable ETag can serve ranges. On-the-fly compression produces a body whose length is not known in advance, so servers such as nginx drop Accept-Ranges and send the full response with 200.
Sources
- MDN Web Docs: HTTP range requestsdeveloper.mozilla.org
- MDN Web Docs: Rangedeveloper.mozilla.org
- RFC 9110 Section 14: Range Requestsrfc-editor.org
- RFC 9110 Section 13.1.5: If-Rangerfc-editor.org
- RFC 9110 Section 15.3.7: 206 Partial Contentrfc-editor.org
- RFC 9110 Section 15.5.17: 416 Range Not Satisfiablerfc-editor.org
- RFC 9110 Section 17.15: Denial-of-Service Attacks Using Rangerfc-editor.org
- curl man page (--range, --continue-at)curl.se
- nginx: ngx_http_slice_modulenginx.org
- nginx: max_ranges (ngx_http_core_module)nginx.org
- Cloudflare: Range request behaviordevelopers.cloudflare.com
- Apple: Safari Web Content Guide, Creating Videodeveloper.apple.com
Related
206 Partial Content: Range Requests Explained
206 Partial Content returns only the byte range requested. See a curl transcript, Content-Range, If-Range, video seeking and resumable downloads.
Range Header
Learn how the Range header requests partial content from servers to enable resumable downloads, video streaming, and efficient large file transfers.
HTTP Compression: gzip, Brotli and Zstandard Setup
How HTTP compression works: Accept-Encoding negotiation, gzip vs Brotli vs zstd, nginx, Caddy, Express and Cloudflare config, BREACH, and measuring with curl.
Content-Range Header
Learn how the Content-Range header indicates which portion of a resource is being sent in partial content (206) responses for range requests and streaming.