Request header
> If-Range: "33a64df5"If-Range Header
Learn how the If-Range header requests partial content only if the resource is unchanged. Efficiently resume downloads without re-fetching entire files.
- Direction
- Request
- Category
- Conditional requests
- Spec
- RFC 9110 §13.1.5
- JS can set it
- Yes
- CORS-safelisted
- No: may trigger a preflight
On this page
TL;DR:
If-Rangemakes a resumed download safe. Send it alongsideRangewith the strong ETag you saved: if the file hasn’t changed, you get206 Partial Contentwith the missing bytes. If it has changed, the server ignoresRangeand usually sends the whole new file with200. Append only on 206; on 200, throw the partial file away and keep the new body.
What is If-Range?
Without If-Range, resuming a download that changed in the meantime takes two round trips: one to discover the mismatch, another to fetch the full file. If-Range folds that into one request. It also behaves differently from If-Match: a failed If-Range doesn’t produce 412. The server just takes the normal full-response path. The definition is in RFC 9110 §13.1.5.
Syntax
Two alternatives (headers and responses on this page are trimmed examples):
If-Range: "file-revision-a"
If-Range: Mon, 05 Oct 2026 09:00:00 GMT
Send exactly one entity tag or one HTTP-date. Lists and * aren’t allowed, and clients must not send a weak ETag. Use a date only when there’s no entity tag and the date counts as a strong validator under RFC 9110 §8.8.2.2. If all you have is a weak ETag, that doesn’t make whatever Last-Modified value you have a safe substitute.
How If-Range Works
Take a ten-byte file containing 0123456789. You already have the first five bytes and you saved its strong ETag. The resume request:
GET /sample.txt HTTP/1.1
Host: example.com
Range: bytes=5-
If-Range: "file-revision-a"
If the version still matches:
HTTP/1.1 206 Partial Content
Content-Range: bytes 5-9/10
Content-Length: 5
ETag: "file-revision-a"
56789
If the validator doesn’t match, the server ignores Range, and a normal successful GET returns 200 with the entire current representation. Other preconditions and ordinary errors still apply, so you can get something other than 200 or 206.
Common Examples
A date comparison is an exact match against the representation’s Last-Modified value, and the date has to qualify as a strong validator. It isn’t a “modified before this date” test. That less-than-or-equal comparison belongs to If-Unmodified-Since.
The table makes the difference concrete:
| Saved condition | Current validator | If-Range result |
|---|---|---|
"a" | "a" | Process Range |
"a" | "b" | Ignore Range |
| Date at 09:00:00 | Strong Last-Modified at 09:00:00 | Process Range |
| Date at 09:00:01 | Last-Modified at 09:00:00 | Ignore Range |
The last row is a mismatch even though the saved date is later. When the response has an ETag, use the saved strong tag rather than picking a convenient date.
Sending If-Range without Range tests neither the resume nor the fallback:
curl -i -H 'If-Range: "not-the-fixture-tag"' \
http://127.0.0.1:8082/sample.txt
The server simply ignores If-Range on that request.
Server Implementation
Evaluate the ordinary preconditions first, then If-Range, but only when the Range header would apply. Skip If-Range entirely if there’s no Range header or the resource doesn’t support range requests. When If-Range fails, drop the range selection. A partial body carrying a changed ETag is exactly what the header exists to prevent.
Once the condition passes, handle the range as usual: clamp an overlong end offset, support suffix ranges, and return 416 for an unsatisfiable range. A matching validator doesn’t make an invalid range valid.
Go’s http.ServeContent handles Range, If-Range and the other conditional request fields when you hand it a seekable representation. For tag-based resume, set a strong ETag yourself. This standalone fixture derives its tag from the same immutable bytes it serves:
package main
import (
"bytes"
"crypto/sha256"
"fmt"
"log"
"net/http"
"time"
)
func main() {
data := []byte("0123456789")
tag := fmt.Sprintf("\"%x\"", sha256.Sum256(data))
modified := time.Date(2026, 1, 18, 0, 0, 0, 0, time.UTC)
http.HandleFunc("/sample.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", tag)
w.Header().Set("Content-Type", "text/plain")
http.ServeContent(w, r, "sample.txt", modified, bytes.NewReader(data))
})
log.Fatal(http.ListenAndServe("127.0.0.1:8082", nil))
}
Save it as range-demo.go and run go run range-demo.go. Each request gets its own reader, so concurrent seeks never share a cursor. If you serve from a file instead, open it per request and keep the validator, modification time and served bytes in sync. If the file is replaced between computing its tag and opening its content, you’ll pair one version’s tag with another version’s bytes.
Getting If-Range right
Save the validator from the same response whose bytes you kept. If the download breaks and you then fetch a fresh ETag with HEAD, you may be attaching the new version’s validator to the old version’s bytes.
On 206, check that Content-Range starts exactly where your partial file ends and belongs to the representation you’re assembling. On 200, replace the partial file. On 416, check the reported length and version before deciding anything; a rejected range doesn’t mean your file is complete.
A browser client can make the append decision before it touches its partial buffer. This example is for a small, identity-coded representation already in memory, and etag must come from the response that supplied partial:
async function resume(url, partial, etag) {
if (!etag || etag.startsWith('W/')) throw new Error('Strong ETag required');
const response = await fetch(url, {
cache: 'no-store',
headers: { Range: `bytes=${partial.length}-`, 'If-Range': etag },
});
if (response.status === 200) {
return new Uint8Array(await response.arrayBuffer());
}
if (response.status !== 206) throw new Error(`Resume failed: ${response.status}`);
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(
response.headers.get('content-range') ?? '',
);
if (!range) throw new Error('Expected a single byte range with known length');
const [start, end, total] = range.slice(1).map(Number);
if (![start, end, total].every(Number.isSafeInteger) ||
start !== partial.length || end < start || end >= total ||
response.headers.get('etag') !== etag) {
throw new Error('Range or version mismatch');
}
const next = new Uint8Array(await response.arrayBuffer());
if (next.length !== end - start + 1) throw new Error('Body length mismatch');
const joined = new Uint8Array(partial.length + next.length);
joined.set(partial);
joined.set(next, partial.length);
return joined;
}
The Go fixture above serves the identity-coded bytes this function expects. For cross-origin requests, expose Content-Range and ETag through CORS. With a gzip-coded representation, the range offsets count encoded bytes, so the browser’s decoded buffer lengths won’t line up. For large downloads, stream into a temporary file and commit the append only after the same checks pass. This in-memory version isn’t meant to be a disk download manager.
Store the partial bytes and their validator together. If a crash lands between saving the bytes and updating their metadata, recover conservatively instead of attaching whatever ETag a new HEAD returns. Give 416 its own recovery path too: compare the stored version and complete length, or restart, rather than treating every rejected resume as success.
If-Range vs If-Match
Use If-Match when a version mismatch should stop the request with 412. Use If-Range when a mismatch should get you the complete current representation instead. If-Range offers no protection for PUT or DELETE.
Testing If-Range
Pick a range-capable endpoint and keep the actual ETag from your first partial response:
curl -sS -D /tmp/first.headers -o /tmp/first.part \
-H 'Accept-Encoding: identity' \
-H 'Range: bytes=0-4' https://example.com/sample.txt
Confirm it returned 206. Then paste the strong ETag, quotes included, into this command:
curl -sS -D /tmp/next.headers -o /tmp/next.part \
-H 'Accept-Encoding: identity' \
-H 'Range: bytes=5-' -H 'If-Range: "file-revision-a"' \
https://example.com/sample.txt
The tag shown is a placeholder for your captured value. Check the status and Content-Range before joining the files. Then repeat with a deliberately wrong tag and confirm you get a full response.
With the Go fixture running on port 8082, you can test the mismatch without copying anything:
curl -sS -D - -H 'Range: bytes=5-' \
-H 'If-Range: "not-the-fixture-tag"' \
http://127.0.0.1:8082/sample.txt
You should see a 200 with the full 0123456789 body. Next, grab the hash ETag from a plain GET, send it with the same range, and check for a five-byte 206 body. A matching tag with Range: bytes=10- still can’t select a byte from this ten-byte file, so test that 416 path separately from the mismatch path.
For segmented downloads, every fragment has to come from the same representation. Send the same saved strong tag with every request, validate each Content-Range, and reject any full 200 that shows up where you expected a fragment. Checking only the final size isn’t enough: two equal-length versions can mix into a file of exactly the right size with the wrong bytes. Keep fragments in temporary storage until their ranges and version metadata agree.
If a later fragment fails, resist fetching the current ETag and relabeling the earlier fragments with it. Restart with the new complete representation, or keep asking for the old version if the server offers a versioned URL for it. If-Range checks the validator on the current resource; it doesn’t ask the server to keep old versions around.
Related Headers
Frequently asked questions
What is If-Range?
If-Range makes range processing conditional on a matching strong validator. A mismatch causes the recipient to ignore Range and handle the request as a full request.
When should I use If-Range?
Use it with a saved validator when resuming a partial representation. Check for 206 before appending, and replace the partial copy if a full 200 response arrives.
What values can If-Range contain?
It accepts one strong ETag or, when no ETag is available, an HTTP-date that qualifies as a strong validator. Date comparison requires an exact Last-Modified match.
What is the difference between If-Range and If-Match?
A failed If-Range condition discards Range rather than producing 412. A failed If-Match condition normally produces 412 and can be used for reads or writes.
Sources
Related
If-Match Header
Learn how the If-Match header makes requests conditional based on ETag matching. Prevent conflicts and lost updates in concurrent editing scenarios.
If-Modified-Since Header
Learn how the If-Modified-Since header requests resources only if modified since a specific date. Reduce bandwidth with efficient conditional caching.
If-None-Match Header
Learn how the If-None-Match header makes conditional requests using ETags. Avoid downloading unchanged resources and reduce bandwidth with cache validation.
If-Unmodified-Since Header
Learn how the If-Unmodified-Since header makes requests conditional on resources not being modified. Prevent conflicts in concurrent update scenarios.