How HTTP Works

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.

Reviewed 11 min readintermediate12 sourcesTry itMarkdown
On this page

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:

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

HeaderBytes returnedNotes
Range: bytes=0-4990 to 499First 500 bytes. Both ends inclusive, offsets start at 0.
Range: bytes=500-500 to 9999Everything from offset 500. This is what a resume sends.
Range: bytes=-5009500 to 9999Suffix range: the last 500 bytes.
Range: bytes=9500-200009500 to 9999An end past the file is clamped to the last byte.
Range: bytes=0-0,-10 and 9999Two ranges: the response becomes multipart.
Range: bytes=10000-noneStarts 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-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 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=-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:

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:

> 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-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 (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:

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.

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

  1. MDN Web Docs: HTTP range requestsdeveloper.mozilla.org
  2. MDN Web Docs: Rangedeveloper.mozilla.org
  3. RFC 9110 Section 14: Range Requestsrfc-editor.org
  4. RFC 9110 Section 13.1.5: If-Rangerfc-editor.org
  5. RFC 9110 Section 15.3.7: 206 Partial Contentrfc-editor.org
  6. RFC 9110 Section 15.5.17: 416 Range Not Satisfiablerfc-editor.org
  7. RFC 9110 Section 17.15: Denial-of-Service Attacks Using Rangerfc-editor.org
  8. curl man page (--range, --continue-at)curl.se
  9. nginx: ngx_http_slice_modulenginx.org
  10. nginx: max_ranges (ngx_http_core_module)nginx.org
  11. Cloudflare: Range request behaviordevelopers.cloudflare.com
  12. Apple: Safari Web Content Guide, Creating Videodeveloper.apple.com

Keep going

Browse /search