# 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.

Source: https://howhttpworks.com/headers/if-range
Last reviewed: 2026-10-05

> **TL;DR:** `If-Range` makes a resumed download safe. Send it alongside `Range` with the strong ETag you saved: if the file hasn't changed, you get `206 Partial Content` with the missing bytes. If it has changed, the server ignores `Range` and usually sends the whole new file with `200`. 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](https://www.rfc-editor.org/rfc/rfc9110#section-13.1.5).

## Syntax

Two alternatives (headers and responses on this page are trimmed examples):

```http
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](https://www.rfc-editor.org/rfc/rfc9110#section-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:

```http
GET /sample.txt HTTP/1.1
Host: example.com
Range: bytes=5-
If-Range: "file-revision-a"
```

If the version still matches:

```http
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](https://howhttpworks.com/headers/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:

```bash
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`](https://pkg.go.dev/net/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:

```go
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`:

```javascript
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:

```bash
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:

```bash
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:

```bash
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

- [Range](https://howhttpworks.com/headers/range)
- [Content-Range](https://howhttpworks.com/headers/content-range)
- [ETag](https://howhttpworks.com/headers/etag)
- [If-Match](https://howhttpworks.com/headers/if-match)
