Cloud Infrastructure · Cloud Storage
Byte-Range Reads: Partial Responses, Alignment, and Parallel Fetch
A Range header turns one object into many concurrent reads. What the 206 response guarantees, why alignment matters for throughput, and where range requests break.
A GET that returns part of an object
Range requests are an HTTP feature that predates object storage by a decade. A client sends a GET with a Range header naming a byte interval, and if the server honours it, the response is a 206 Partial Content whose Content-Range header states both the interval returned and the object’s total length. A response of 200 instead of 206 means the server ignored the header and is sending the whole object. That distinction matters, because a client that assumes partial content and reads only the first n bytes will silently drop the rest of a full response.
The interval syntax is inclusive on both ends: bytes=0-99 is one hundred bytes. An open-ended form, bytes=100-, runs to the end. A suffix form, bytes=-500, means the last five hundred bytes. Multiple intervals in one request are permitted by the specification and answered with a multipart/byteranges body, but object stores generally accept only a single interval; a request naming several typically receives the whole object or an error. The practical rule is one interval per request.
Three behaviours distinguish a conforming server. A range entirely beyond the object’s length yields 416 Range Not Satisfiable with a Content-Range header stating the object’s actual length, which is the only way to learn the length of an object when a HEAD is not permitted. A range that starts inside the object but extends past its end is clipped to the end, and the returned Content-Range reports the clipped interval rather than the requested one, so a client must read the header rather than assume its request was honoured exactly. And a range on a zero-byte object has no satisfiable interval at all.
Why the store can serve this cheaply
An object is an ordered list of immutable extents, and the store’s metadata records each extent’s length. Serving a range therefore requires locating the extents that overlap the interval and reading only those, with a partial read of the first and last extent. No copy is made, no assembly step is needed, and the work is proportional to the bytes requested rather than to the object’s size.
That structure explains why ranged reads are the basis for random access over what is nominally an immutable blob. A reader that needs record number k of a sorted file does not fetch the file; it fetches the index that maps keys to offsets, then a range covering the record. The index itself is often fetched as a range. Formats built for this pattern store a fixed-size trailer at the end of the object, readable with a suffix range, that names the offsets of the metadata blocks, which are in turn fetched by range. The whole-object read is never issued.
The cost of this pattern is request count. Each range is a separate HTTP request with its own latency, and at small sizes the round-trip time dominates the transfer. A read of a thousand 4 KB records as a thousand range requests spends most of its time waiting; the same read as ten ranges of 400 KB, each covering many records, is bounded by bandwidth instead. This is the axis on which the design turns: range size trades request overhead against over-fetch, and the optimum depends on the ratio of round-trip latency to per-byte transfer time.
Alignment is about the extent, not the page
The store reads whole extents internally, so a range that begins in the middle of an extent causes the storage layer to read the extent and discard a prefix. If extents are large, a 512-byte read can cost a 4 MiB read underneath. The published object-store interface does not expose extent size, so alignment advice is necessarily approximate: aligning ranges to boundaries in the low megabytes, and sizing them at or above a few megabytes, keeps the internal read amplification near one.
There is a second alignment concern that is entirely under the client’s control: the boundaries used by a parallel fetch should match the boundaries used when the object was written, when those are known. A multipart upload with 64 MiB parts produces an extent list at those offsets, so a download split at 64 MiB boundaries maps one range to one extent and avoids partial-extent reads at every split. The part-size arithmetic that produces such boundaries is in multipart upload part sizing. When the boundaries are unknown, an arbitrary split still works but pays a partial read at each boundary, which for a few dozen ranges is negligible and for a few thousand is not.
Concurrency has a ceiling set by the connection, not by the object. A single HTTP connection multiplexes poorly under range requests because each response is a separate stream; separate connections cost handshakes and, under TLS, key agreement. The useful parallelism for a download over a wide-area link is usually in the low tens of concurrent ranges, since beyond that the link is saturated and the added requests only increase the chance of a timeout.
A fetcher that verifies each piece before assembling:
import hashlib
from concurrent.futures import ThreadPoolExecutor
def fetch_ranges(client, key, size, chunk, workers=8):
"""client.get_range(key, start, end) -> bytes, end exclusive."""
spans = [(s, min(s + chunk, size)) for s in range(0, size, chunk)]
def one(span):
start, end = span
data = client.get_range(key, start, end)
if len(data) != end - start:
raise IOError(f"short read at {start}: {len(data)}")
return start, data
out = bytearray(size)
with ThreadPoolExecutor(workers) as pool:
for start, data in pool.map(one, spans):
out[start:start + len(data)] = data
return bytes(out)
Two details in that block are the ones that fail in practice. The length check catches a server that ignored the range and sent the whole object, which would truncate silently when written into a fixed-size buffer. And the assembly writes each piece at its absolute offset, so the pieces need not arrive in order; a retry of one span does not disturb the others.
Retries and the immutability assumption
A ranged read is only coherent if the object does not change between requests. Nothing in the protocol enforces that: if the object is overwritten midway through a parallel fetch, later ranges may come from the new bytes and the assembled result is a mixture of two versions with no error signalled. Some responses carry the object’s entity tag or generation, and a client that captures it from the first response can send it as a precondition on subsequent requests, which turns the mixture into a detectable failure. Without that, the only defence is to fetch from a key that is immutable by construction, a generation-suffixed key, as described in atomic commit over object stores.
Retries interact with this. A span that times out and is retried may return bytes from a newer version, so the retry policy should be bounded and the whole fetch discarded on a version mismatch rather than patched. Since a range request is idempotent with respect to a fixed version, retrying is otherwise safe: the same interval of the same version always returns the same bytes.
Archival tiers break ranged reads in a different way. An object in a restore-required class cannot be read at all until a restore completes, and the restore is a whole-object operation whose completion time is measured in minutes to hours depending on the retrieval option. A range request against such an object before the restore finishes fails; after it finishes, the object is readable for a limited period, after which it returns to its archived state. The tier arithmetic that decides whether an object belongs there at all is in storage tiers and minimum durations, and the practical consequence is that any workload built on ranged access must not place its data in a restore-required class.
Where range reads replace an index
The pattern worth internalising is that a range read converts an object into an addressable file without any service-side index. A columnar file uses this to read one column: its footer names the offset and length of each column’s data, and the reader fetches only those ranges. A log archive uses it to seek to a time window when the writer recorded the offsets of each block in a trailer. A checkpoint format uses it to load only the shards a process needs. In each case the writer pays once to record offsets and the reader pays per access for exactly the bytes it touches.
The alternative, splitting the data into many small objects and listing them, moves the addressing burden into the keyspace. That is a legitimate design, and it makes each piece independently replaceable, but it multiplies the request count and makes discovery depend on listing, which has its own pagination and cost behaviour. The choice between a few large ranged objects and many small objects is really a choice between two addressing mechanisms, and the range-read version keeps the atomicity of the object intact.
References
- RFC 9110: HTTP Semantics — IETF
- Amazon S3 API Reference — GetObject — Amazon Web Services
- Google Cloud Storage documentation — Ranged downloads — Google Cloud