HTTP Range Requests for File Downloads - Partial Content Is a Protocol, Not a String Slice
A large download stops halfway. The local file is still there, so asking for only the missing bytes sounds obvious. But what if the file behind the URL changed while the connection was down? Appending the new file's second half to the old file's first half would produce a result that belongs to neither version.
That is the useful way to think about an HTTP range request: not as a shortcut for cutting a file, but as a small protocol for transferring part of a particular representation. The arithmetic matters, yet representation identity, response status, and validators matter just as much.
This article follows RFC 9110, the current HTTP semantics specification. It focuses on downloads over GET. Upload resumption, partial PUT, object-storage APIs, and vendor-specific CDN behavior are separate subjects.
A range addresses a representation, not an abstract file
A URL identifies a resource, while an HTTP response carries a selected representation of that resource. The distinction becomes visible when content negotiation or content coding is involved. A server might select one language, media type, or encoded form for a request. A byte range then addresses the octet sequence of that selected representation.
RFC 9110 defines range handling for GET. A server is allowed to ignore a Range header, so the header is a request rather than an instruction the client can force. Supporting it is nevertheless useful for recovering from interrupted transfers and retrieving only part of a large representation.
The familiar unit is bytes. Its offsets begin at zero, and both ends are inclusive:
Range: bytes=0-999
Range: bytes=1000-
Range: bytes=-500
The first form asks for 1,000 bytes, not 999. The second starts at byte offset 1,000 and continues to the end. The third asks for up to the final 500 bytes. If a suffix is longer than the representation, the selected range becomes the whole representation. If an explicit final position extends beyond the current length, the server interprets it as the remainder rather than reading past the end.
There is another subtle boundary. If a content coding such as gzip has been applied, the offsets refer to the encoded byte sequence, not to bytes obtained after decoding. A client should therefore treat the response metadata as authoritative instead of calculating offsets from a differently encoded copy.
200, 206, and 416 tell different stories
Three status codes form the practical decision tree. They are not interchangeable:
200 OKmeans the response carries the complete selected representation. A server may return it because it ignored the range, does not support ranges for that resource, or decided a full response was appropriate.206 Partial Contentmeans the server is fulfilling the request with one or more parts of the selected representation.416 Range Not Satisfiablemeans the range set was rejected because no requested range was satisfiable or because an excessive set of small or overlapping ranges was treated as abusive.
For a single-part 206, the response needs a Content-Range that places the body within the complete representation. A request for the first 1,000 bytes of a 10,000-byte representation can produce:
HTTP/1.1 206 Partial Content
Content-Length: 1000
Content-Range: bytes 0-999/10000
[1000 response-body bytes]
Here, Content-Length describes the body in this message. It does not claim that the complete representation is 1,000 bytes long. The denominator in Content-Range supplies the complete length.
If a client asks for bytes=12000- from that 10,000-byte representation, the first position does not overlap it. RFC 9110 says a server generating 416 for a byte-range request should report the current complete length:
HTTP/1.1 416 Range Not Satisfiable
Content-Range: bytes */10000
The word should is important. A server remains free to ignore Range and return a full 200. A download client must inspect the status and response headers before deciding whether to append, replace, or reject the received body. Blindly appending every response to a partial local file is a corruption bug waiting for the right interruption.
Accept-Ranges advertises, but does not promise
A response can include Accept-Ranges: bytes to advertise byte-range support for that target resource. It can use Accept-Ranges: none to advise that no range unit is supported. The field is useful for clients deciding whether to expose a resume action.
It is not a durable promise. The specification explicitly warns clients not to assume that a future request will receive a partial response merely because an earlier response advertised support. The representation, server condition, or intermediary can change. Conversely, a client may try a range even if it has not seen Accept-Ranges.
A HEAD request is a convenient way to inspect metadata without transferring the normal response body. The MDN range request guide demonstrates this approach, but the subsequent GET response still decides what actually happened.
Resuming safely requires representation identity
Suppose a client has stored bytes 0 through 4,999 and wants the remainder. Sending Range: bytes=5000- answers only the offset question. It does not prove that the resource still has the same representation.
If-Range connects those two concerns. With a strong entity tag previously received from the server, a request can look like this:
GET /downloads/archive.tar HTTP/1.1
Host: example.test
Range: bytes=5000-
If-Range: "release-42"
If the validator still matches, the server can process the range and return 206. If it does not match, the server ignores the range and sends the complete current representation, normally as 200. This fallback is deliberate: the client can replace its stale partial copy instead of combining bytes from different versions.
A weak entity tag, recognizable by the W/ prefix, must not be sent in If-Range. An HTTP date is also permitted only under the specification's strong-validator conditions and when the client has no entity tag. In practice, a strong ETag is the clearer option when the application can generate one that changes whenever observable representation data changes.
This does not make every resume race disappear by magic. The server must select and transfer a representation consistently, and the validator must actually describe that representation. Different encoded forms also need distinct strong tags where their representation data differs.
Multiple ranges add a different level of complexity
A client can request more than one interval in one header. A successful response then uses multipart/byteranges, with a boundary and a separate Content-Range inside each body part. It is not a single byte slice with a few gaps removed.
That feature has legitimate uses, but it also increases parsing and response-generation work. RFC 9110 permits a server to ignore, coalesce, or reject egregious sets such as many tiny ranges or more than two overlapping ranges. The security section notes that a small request can otherwise cause disproportionate memory, bandwidth, and processing costs.
For a small download endpoint that only needs pause and resume, supporting one range is often a reasonable engineering boundary. That is a design judgment, not an HTTP requirement. NGINX exposes a max_ranges directive: its official core-module documentation says requests exceeding the configured count are processed as if no byte ranges were specified, while a value of zero disables byte ranges. The appropriate limit depends on the application; there is no universal number to copy without understanding the clients.
Let the application authorize and the file server transfer
Public static files can usually be served directly by a capable web server. Private downloads add an authorization decision: the application needs to decide whether this user may access this resource. That does not necessarily mean PHP should parse every range and stream every byte.
One practical NGINX pattern is an internal location. The application authenticates the request, resolves an opaque resource identifier to a trusted internal path, and, if access is allowed, returns an X-Accel-Redirect response header. NGINX then performs an internal request and serves the file. Its core documentation identifies X-Accel-Redirect as a source of internal requests; an external request cannot directly fetch an internal location.
This split keeps authorization in application code while delegating file-transfer details to the server. It is not a substitute for path safety. A user-controlled filename must not become an unchecked filesystem path or internal URI, and every new download request, including a resumed request, still needs the required authorization.
A hand-written PHP implementation can be justified when storage or transformation requirements prevent delegation. But a production parser has to handle large decimal values without integer overflow, invalid syntax, open-ended and suffix ranges, conditional requests, response metadata, multiple ranges or an explicit policy for them, streaming errors, and the authorization model. A short snippet that covers only bytes=start-end would hide more risk than it explains.
Test behavior, not just one header
The following commands are a compact inspection set. Replace the URL with a non-sensitive test resource:
curl -I 'https://example.test/downloads/sample.bin'
curl -sS -D - -o /dev/null \
-H 'Range: bytes=0-999' \
'https://example.test/downloads/sample.bin'
curl -sS -D - -o /dev/null \
-H 'Range: bytes=999999999-' \
'https://example.test/downloads/sample.bin'
Check the status, Content-Range, Content-Length, ETag, and content coding together. Then test a matching and non-matching If-Range, authorization failure, an empty representation, and any proxy or CDN path used in production. Avoid assuming that a direct-origin result proves intermediary behavior.
For an actual interrupted transfer, the official everything curl documentation describes --continue-at -, which derives the resume offset from the existing destination file:
curl --continue-at - -O 'https://example.test/downloads/sample.bin'
That command is useful only when the server and validators preserve the representation contract. A tool can calculate where local bytes end; it cannot make mismatched remote bytes belong to the same version.
Partial content is worth supporting selectively
Range support is valuable for large immutable downloads, media seeking, and clients that genuinely benefit from partial retrieval. It can be unnecessary for small dynamic responses, generated representations whose identity is hard to keep stable, or endpoints where a full response is simpler and cheap enough.
The reliable mental model is modest: a client asks for part of a selected representation; the server may honor or ignore that request; the response status and metadata tell the client which occurred; and a strong validator prevents a resumed download from quietly crossing representation versions. Once those pieces are treated as one contract, 206 Partial Content stops looking like an optimization trick and starts looking like what it is: a precise statement about which bytes are being sent.
References
- Fielding, R., Nottingham, M., and Reschke, J. RFC 9110: HTTP Semantics, Range Requests. IETF / RFC Editor, June 2022.
- MDN contributors. HTTP range requests. MDN Web Docs, last modified July 28, 2026.
- NGINX. Module ngx_http_core_module. Official documentation.
- Stenberg, D., and contributors. Resuming and ranges. everything curl.
