Range Requests and Partial Content Caching
TL;DR: A range request asks for part of a representation rather than the whole of it. The server advertises support with Accept-Ranges: bytes, the client asks with Range: bytes=0-1048575, and the answer is 206 Partial Content carrying a Content-Range header. Caching this traffic is genuinely harder than caching ordinary responses: a cache must decide whether to store a fragment, how to combine fragments, and whether the representation underneath has changed since the last fragment was fetched. Many caches decline the problem entirely, which is why range-heavy traffic behaves unlike anything else at the edge.
GET /media/lecture-04.mp4 HTTP/2
Range: bytes=2097152-4194303
HTTP/2 206 Partial Content
Content-Range: bytes 2097152-4194303/524288000
Content-Length: 2097152
Accept-Ranges: bytes
ETag: "5f8a3c1b9e0d2"
Cache-Control: public, max-age=604800
Mechanism and RFC Alignment
Range requests are defined in RFC 9110 §14, and their caching consequences in RFC 9111. The exchange has three parts.
The server advertises capability with Accept-Ranges: bytes. This is optional but important: without it, a well-behaved client will not attempt a range request, and a client that tries anyway must be prepared for the server to ignore the header and return the full body with 200.
The client requests a range with Range: bytes=start-end. Several forms exist — an open-ended bytes=1000-, a suffix bytes=-500 meaning the final 500 bytes, and multiple ranges in one request, which produce a multipart/byteranges body. Multi-range requests are rare in practice and poorly handled by intermediaries; most real traffic uses a single range.
The server answers 206 Partial Content with a Content-Range header giving the byte offsets and the total representation length. Critically, Content-Length on a 206 describes the fragment, not the resource — a distinction that trips up logging and metrics pipelines constantly.
RFC 9111 §3.3 governs storage. A cache MAY store a 206, but only if it understands how to combine partial content — both with other stored ranges and with a complete stored representation. The specification also requires that combination only be attempted when the fragments share a strong validator, because merging bytes from two different versions of a file produces a corrupt result that no checksum downstream will catch.
Scope and Precedence
Range handling sits underneath the ordinary caching rules rather than beside them. The freshness and validation machinery described in freshness vs validation models applies unchanged; what ranges add is a second dimension to the stored entry — not just is this current but how much of it do I have.
The If-Range precondition is where precedence becomes explicit. When a client holds a partial body and wants to continue it, sending Range alone is unsafe: the file may have changed since the first fragment was fetched. If-Range carries a validator alongside the range, and instructs the server to honour the range only if that validator still matches. If it does not, the server ignores Range entirely and returns 200 with the complete current body.
RFC 9110 §13.1.5 requires strong comparison for If-Range. A weak validator asserts semantic equivalence, not byte equality, and appending bytes from a semantically-equivalent-but-differently-encoded representation produces corruption. This is the most practically important consequence of the strong/weak distinction covered in strong vs weak ETags: a weak ETag silently disables download resumption.
Implementation Patterns
Serve media as segments where you can. The most effective way to cache media is to avoid whole-file range serving altogether. HLS and DASH split a stream into segments of a few seconds, each with its own URL. Every segment is then an ordinary complete object that caches exactly like an image, and range logic disappears from the problem entirely.
# Each segment is a complete, independently cacheable object
GET /media/lecture-04/seg-00042.m4s HTTP/2
HTTP/2 200
Content-Length: 1874320
Cache-Control: public, max-age=31536000, immutable
ETag: "seg42-5f8a3c"
Where whole-file serving is required, align the fetches. A player seeking through a large file issues arbitrary ranges, and forwarding each one to the origin produces a request pattern the origin cannot cache against. Slicing converts arbitrary client ranges into fixed-size aligned blocks that the cache can store and reuse.
Emit a strong validator on anything rangeable. Without one, If-Range always fails and every resumed download restarts from zero.
Do not compress rangeable content. Range offsets refer to transferred bytes. If a response is compressed, the offsets refer to compressed positions, and any change in encoding — a different Brotli level, a proxy switching to gzip — invalidates every offset the client holds. Media formats are already compressed, so the loss is negligible and the correctness gain is large. Where compression must apply, no-transform prevents an intermediary from re-encoding and shifting the offsets underneath a client, as covered in implementing no-transform for compressed assets.
Server and CDN Configuration
Nginx caches ranges through the slice module, which rewrites arbitrary client ranges into aligned block requests upstream:
proxy_cache_path /var/cache/media levels=1:2 keys_zone=media:32m
max_size=200g inactive=30d use_temp_path=off;
location /media/ {
slice 1m;
proxy_cache media;
proxy_cache_key $uri$is_args$args$slice_range;
proxy_set_header Range $slice_range;
proxy_cache_valid 200 206 30d;
proxy_http_version 1.1;
add_header Accept-Ranges bytes always;
}
The essential detail is that $slice_range appears in the cache key. Without it every block would collide on one entry; with it each aligned megabyte is a separate, reusable object, and a player seeking to the middle of a file fetches only the blocks it needs.
Apache serves ranges natively and needs no special configuration, though the byte-range filter’s limits are worth reviewing on media hosts:
Header always set Accept-Ranges bytes
Header always set Cache-Control "public, max-age=604800"
# Reject pathological multi-range requests rather than serving them
MaxRanges 10
MaxRangeOverlaps 5
At the CDN, range caching is usually an explicit feature rather than a default. Fastly’s segmented caching and Cloudflare’s equivalent both convert client ranges into aligned origin fetches; without them enabled, every range request is forwarded, and a popular video generates origin traffic proportional to viewer count rather than to file size.
Interaction with Related Directives
Range handling interacts with validation more than with lifetime. A 206 carries the same Cache-Control and ETag as the full response, so freshness is computed exactly as described in how to calculate cache freshness lifetime. What changes is that a stale partial entry is far more expensive to correct than a stale complete one, because revalidating a fragment tells you nothing about the fragments you do not hold.
Vary multiplies the problem. A variant dimension over a rangeable object means separate partial stores per variant, each accumulating its own blocks. For media this is usually avoidable, since the content is not compressed and rarely negotiated.
no-store disables range caching along with everything else, and on large media that is an expensive choice: every seek becomes an origin fetch.
The Content-Range header is also a common source of confusion in cache debugging. A 206 with Content-Length: 2097152 against a 500 MB file is correct, not truncated — the length describes the fragment. Metrics pipelines that sum Content-Length to estimate egress will under-report range traffic badly unless they read Content-Range instead.
Verification Workflow
Step 1 — confirm range support is advertised.
curl -sI https://example.com/media/lecture-04.mp4 | grep -i 'accept-ranges\|etag\|content-length'
Accept-Ranges: bytes should be present, along with a quoted, non-weak ETag.
Step 2 — request a single range and check the response shape.
curl -s -o /dev/null -D - -H 'Range: bytes=0-1023' \
https://example.com/media/lecture-04.mp4 | head -8
Expect HTTP/2 206, a Content-Range: bytes 0-1023/<total> line, and Content-Length: 1024.
Step 3 — verify the range is actually cached. Request the same range twice and compare Age:
for i in 1 2; do
curl -sI -H 'Range: bytes=1048576-2097151' https://example.com/media/lecture-04.mp4 \
| grep -iE '^age|cf-cache-status|x-cache'
done
A second request with a non-zero Age confirms the fragment or its containing block is stored. A permanent MISS means ranges are being forwarded to the origin every time.
Step 4 — verify If-Range behaves correctly. Send the current validator, then a deliberately wrong one:
ETAG=$(curl -sI https://example.com/media/lecture-04.mp4 | awk -F'"' '/[Ee][Tt]ag/{print $2}')
# Matching validator — expect 206
curl -s -o /dev/null -w '%{http_code}\n' \
-H "If-Range: \"$ETAG\"" -H 'Range: bytes=0-1023' \
https://example.com/media/lecture-04.mp4
# Stale validator — expect 200 with the full body
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-Range: "stale-value"' -H 'Range: bytes=0-1023' \
https://example.com/media/lecture-04.mp4
206 then 200 is the correct pair. 206 both times means the precondition is being ignored, which is a correctness problem waiting for a content change.
Why Range Traffic Distorts Every Cache Metric
Byte-range traffic breaks most of the intuitions built on ordinary request patterns, and a media-serving site that reads its cache dashboard the usual way will consistently reach the wrong conclusions.
The first distortion is in request counting. A single viewer watching a 500 MB file through a player that fetches in one-megabyte windows generates roughly five hundred requests for one object. Request-based hit ratio is therefore dominated by whichever files are being watched right now, and a handful of concurrent viewers can swing the site-wide figure by several points. The same viewer counted as one request against one URL would barely register.
The second distortion is in miss cost. An ordinary miss costs one origin fetch of one object. A range miss costs an origin fetch of one block, and a player seeking backwards and forwards through a file can produce dozens of them for a file the edge already largely holds. Origin request rate and origin byte rate decouple almost completely, and a dashboard tracking only one of them will show a healthy picture while the other saturates.
The third distortion is in eviction behaviour. Blocks of the same file are independent entries, so an LRU pass does not evict a file — it evicts the parts of a file nobody has watched recently. The result is a partially-resident object whose hit ratio depends on where in the timeline viewers happen to seek, which is why the opening minute of a popular video reliably hits while the middle reliably misses. This is normal and not worth fixing, but it does mean per-object hit ratio is a misleading number for media.
The practical response is to measure media separately from everything else. Give ranged traffic its own hit ratio, its own origin-byte metric, and its own store where the cache supports it. Folded into the site-wide figures, media both flatters and obscures — flatters, because block hits are numerous and cheap; obscures, because the expensive misses are buried in a request count that is dominated by something else entirely.
Failure Modes and Gotchas
-
A weak
ETagon rangeable content.If-Rangerequires strong comparison, so every resumption attempt degrades to a full download. The symptom is enormous egress on large files with no obvious cause. -
Range requests bypassing the cache silently. Varnish and several proxies pass
206traffic through without storing it. Origin load scales with viewers rather than with catalogue size, and the cache dashboard shows nothing unusual because the requests never reach the store. -
Slicing without the slice range in the key. Every block collides on a single entry, producing corruption or a permanent miss depending on the implementation.
-
Compression shifting the offsets. A proxy re-encoding a response between two range requests invalidates every offset the client holds.
no-transformis the guard. -
Multi-range requests handled inconsistently.
multipart/byterangesis poorly supported by intermediaries. Limit or reject multi-range requests rather than relying on them. -
Metrics summing
Content-Lengthon206responses. The value describes the fragment. Egress calculated this way is wrong by whatever fraction of traffic is ranged. -
Origin range support disabled behind the CDN. If the origin does not honour
Range, the CDN’s slicing feature cannot function and quietly falls back to full-object fetches per request.
Frequently Asked Questions
Can a cache store a 206 Partial Content response?
It may, provided it can correctly combine that fragment with other stored ranges or with a complete stored copy, and only when they share a strong validator. Many caches decline to implement the combination logic and simply pass range traffic through.
Why does If-Range require a strong validator?
Because the client is appending bytes to a body it already holds. That is only safe when the representation is byte-for-byte identical, and a weak validator asserts semantic equivalence rather than octet equality.
What happens when an If-Range precondition fails?
The server ignores Range and returns 200 with the full representation. The client discards its partial copy and starts again — a safe fallback rather than an error.
Does compression interfere with range requests?
Yes. Offsets refer to transferred bytes, so a change in encoding between requests makes previously requested offsets meaningless. Serve rangeable media uncompressed, and use no-transform where an intermediary might re-encode.
How should I cache video at the edge?
Segment it. Each segment becomes a complete, independently cacheable object and the range problem disappears. Where whole-file delivery is required, enable the CDN’s range-caching or slicing feature so the origin sees aligned block fetches.
Guides in This Topic
Each guide below works through one concrete task or question from this topic, end to end.
- Caching Video Byte Ranges at the Edge — Segment the stream or slice the file.
- Debugging 206 Partial Content Caching — Range requests that never hit, Content-Range values that look wrong, and metrics that under-report egress.
- Using If-Range to Resume Downloads — Resumption depends on a precondition most origins get subtly wrong.
Related
- ETag vs Last-Modified: which validator to use matters more here than anywhere, because
If-Rangewill not accept a weak one. - Cache storage, eviction and capacity explains why large media is evicted first and how object-size ceilings silently disable media caching.
- How CDN cache keys are generated covers the key composition that slicing depends on for correctness.
- Serving stale content with stale-while-revalidate describes the grace behaviour that keeps large-object revalidation off the critical path.