Internet Protocols · HTTP
Byte Ranges, Partial Responses, and Range Request Cost
How Range and Content-Range select a byte window, when a server must answer 206 rather than 200, and why many small ranges are a denial-of-service shape.
A range request asks for part of a representation. The obvious use is resuming an interrupted download; the less obvious ones are seeking within a video, fetching a footer from a large file to read an index, and parallelising a transfer across connections. Each use has different tolerance for partial answers, and the specification’s rules are written to make the server’s behaviour predictable in all of them.
The Range grammar and what it selects
The field value is a unit name followed by one or more ranges. Only bytes is defined; other units are permitted by the grammar and must be ignored by a server that does not understand them, which means responding with the full representation rather than an error.
A byte range is first-last with inclusive endpoints, first- meaning from that offset to the end, or -suffix meaning the last N bytes. The three forms interact with the representation’s length in ways that produce edge cases:
bytes=0-0selects one byte.bytes=-500selects the final 500 bytes, or the whole representation if it is shorter than 500.bytes=950-on a 1000-byte representation selects 50 bytes.bytes=1000-on a 1000-byte representation is unsatisfiable, because offsets are zero-based and 1000 is past the last byte.
A range whose first position is greater than or equal to the length is unsatisfiable. A range whose last position exceeds the length is satisfiable and is clamped: bytes=900-2000 on a 1000-byte representation yields bytes 900 through 999. A range where last is less than first is syntactically invalid.
A server that receives a Range field it considers invalid, whether malformed syntax, an unknown unit, or a set of ranges it chooses not to serve, ignores it and returns 200 with the full representation. That is the specification’s default: Range is a hint, and ignoring it is always legal. The exception is the unsatisfiable case, where the server should return 416 Range Not Satisfiable with a Content-Range field of the form bytes */1000, which tells the client the actual length. A client that resumes a download from an offset past the end of a changed file gets a 416 and can restart from zero instead of looping.
206 and the Content-Range field
A successful partial response is 206 with a Content-Range field naming the unit, the selected interval, and the complete length: Content-Range: bytes 900-999/1000. The complete length may be * when unknown, which happens with a dynamically generated representation whose final size is not known in advance. A 206 without Content-Range is malformed, and a client has no way to know which bytes it received.
The Content-Length of a 206 is the length of the selected interval, not of the whole representation. That is the field most often set wrong by naive implementations, and the error is invisible to a client that trusts Content-Range and only shows up when something reassembles the pieces.
Multiple ranges produce a multipart response. The media type is multipart/byteranges with a boundary parameter, and each part carries its own Content-Type and Content-Range. The overall response has no Content-Length in the general case, because computing the multipart envelope’s length requires knowing every part’s headers, and implementations commonly use chunked encoding instead.
A server may coalesce overlapping or adjacent ranges into one, and may serve fewer ranges than requested. RFC 9110 explicitly permits returning a single range when several were requested, as long as the returned part covers one of them. It also permits rejecting a request with too many ranges or ranges that are too small, and this is a security-relevant permission rather than a performance tweak.
The amplification problem
A request for bytes=0-0,2-2,4-4,... repeated thousands of times is a few kilobytes on the wire and produces a multipart response with thousands of parts, each carrying a Content-Range field and a boundary delimiter. The response can be orders of magnitude larger than the request, and the server does the work of building it. This is the classic range-request amplification attack, and the mitigation is a cap on the number of ranges per request: Apache httpd’s MaxRanges directive defaults to 200 and returns the complete resource when a request exceeds it. RFC 9110 also permits a server to ignore or reject a set of many small ranges as an indication of a denial-of-service attempt.
The second amplification is a suffix range on a compressed representation. If the representation is content-coded, the byte offsets refer to the encoded bytes, not the decoded ones, because Range applies to the selected representation as it will be transferred. A client that wants to seek in a gzip-compressed video container is seeking in the wrong coordinate space, and no amount of server correctness fixes it. Servers that serve compressed responses should generally disable range support for them, or serve identity when a range is requested.
The third is a range over a resource whose length is not known until it is generated. Serving bytes=900- from a stream requires generating and discarding the first 900 bytes, which costs as much as generating the whole thing. A server that cannot seek its source cannot serve ranges cheaply, and should either ignore the field or return the full representation.
Caching partial responses
RFC 9111 treats a 206 as storable, but only as an incomplete response, and places conditions on storing one. The cache must support the Range and Content-Range fields and understand the range unit in use, and it must record the response as incomplete. It must not then use the fragment to answer a later request unless the response has been completed or the incoming request asks for a range wholly inside the stored bytes. Completing a fragment means issuing further range requests and combining the results, which is only safe when every piece shares the same strong validator.
The practical consequence is that a lone fragment is worth little to a shared cache. It can satisfy a request only for a window it already covers; a request for any other window is a miss, and the bytes must be fetched again. Range support at an intermediary is therefore cheap when the complete object is present locally and a range is a slice of it, and expensive when it is not.
Revalidation interacts with ranges through If-Range. A client resuming a download sends both Range and If-Range with a validator. If the validator matches the current representation, the server returns 206 for the requested window; if not, the server ignores Range and returns 200 with the whole body. The client must handle the 200 by discarding its partial copy and starting over. Without If-Range, the server has no way to know the client’s prefix is from the same version, and a changed file produces a spliced result that is internally inconsistent.
If-Range requires a strong validator, as noted in conditional requests and entity validators. A weak entity tag or a Last-Modified date with one-second resolution is not sufficient to guarantee byte identity, and a server that honours a range against a weak validator is producing a corrupt file with a plausible-looking status code.
Writing the response correctly
The server-side logic reduces to a handful of ordered checks, and the order matters as much as in precondition handling:
def serve_range(req, body: bytes, tag: str):
if req.range is None or req.method != "GET":
return full_response(body, tag)
if req.if_range is not None and not strong_match(req.if_range, tag):
return full_response(body, tag) # validator changed: ignore Range
ranges = parse_ranges(req.range, len(body))
if ranges is None:
return full_response(body, tag) # malformed or unsupported: ignore
if not ranges:
return Response(416, headers={"Content-Range": f"bytes */{len(body)}"})
if len(ranges) == 1:
first, last = ranges[0]
return Response(206,
headers={"Content-Range": f"bytes {first}-{last}/{len(body)}",
"Content-Length": str(last - first + 1),
"Accept-Ranges": "bytes"},
body=body[first:last + 1])
return multipart_206(body, ranges, tag)
Accept-Ranges: bytes belongs on the 200 response as well, so a client knows ranges are available before it tries one. It is advisory: a server may send it and still refuse a specific range, and a client must not treat its absence as a guarantee that ranges will fail.
The last edge case is a range request for a response that would have no body anyway. Range on a HEAD request has no defined effect, and a 206 to HEAD would be contradictory, since there are no bytes to send. A server should ignore the field for HEAD and for statuses that forbid a body. Likewise, a Range field in a request whose response is a 304 is meaningless, because the client already has a copy it believes is complete.
Range support is one of the few HTTP features where the cost is entirely on the server and the benefit entirely on the client, which is why it is worth enabling only for representations that are large, static, and stored somewhere seekable. A 206 for a 4 KB JSON document saves nothing and adds a code path.
References
- RFC 9110: HTTP Semantics — IETF
- RFC 9111: HTTP Caching — IETF
- RFC 7233: HTTP Range Requests — IETF