# 206 Partial Content: Range Requests Explained

> 206 Partial Content returns only the byte range requested. See a curl transcript, Content-Range, If-Range, video seeking and resumable downloads.

Source: https://howhttpworks.com/status-codes/206
Last reviewed: 2026-10-04

> **TL;DR:** 206 Partial Content is the answer to a request with a `Range` header: the body is just those bytes, and `Content-Range` says where they sit in the full resource. It powers video seeking and resumable downloads, and a server that ignores `Range` replies 200 instead.

## When you will see it

A client sends `Range: bytes=0-99` and the server supports it. You can watch this with curl:

```bash
curl -s -D - -o /dev/null -r 0-99 https://example.com/files/report.pdf
```

```http
HTTP/2 206
content-type: application/pdf
content-length: 100
content-range: bytes 0-99/2457600
accept-ranges: bytes
etag: "5f1c-6032a8b4c1a00"
last-modified: Tue, 15 Sep 2026 08:12:41 GMT
```

Note what changed relative to a 200. `content-length` is 100, the length of this body, not the file. `content-range: bytes 0-99/2457600` reads "bytes 0 through 99 inclusive, of 2,457,600 total". Both ends of a range are inclusive, so `0-99` is 100 bytes, a classic off-by-one when building these by hand.

A server that does not support ranges ignores `Range` and returns `200` with the full body, usually with `Accept-Ranges: none` or no `Accept-Ranges` at all. That is valid per RFC 9110, so clients must check the status code, not assume.

## Where it matters

**Video and audio seeking.** The browser's media stack requests the start of the file (often `bytes=0-` or a small probe), reads the container header, then issues new range requests as you scrub. A video behind a server that cannot do ranges plays from the start but cannot seek. Safari is the strict one: it sends `Range: bytes=0-1` first and refuses to play if the answer is not a correct 206 with `Content-Range`. A video that works in Chrome and stays blank in Safari is almost always a range problem, commonly introduced by a proxy or Node handler that streams the file with `res.send` or `pipe` and no range logic.

**Resumable downloads.** `curl -C -` and `wget -c` work out how many bytes exist locally and send `Range: bytes=<n>-`. The server answers 206 with the remainder.

**Large file parallel fetches.** Download accelerators and tools like `aria2` split a file into ranges and fetch them in parallel. Object stores (S3, GCS) serve ranges natively, which is how Parquet readers pull only the footer and the needed column chunks.

**PDF viewers.** Browsers' PDF viewers fetch the trailer and cross-reference table from the end of the file with a suffix range (`Range: bytes=-1024`) to render page one before the rest has downloaded ("fast web view").

## Range forms

| Header | Meaning |
|---|---|
| `Range: bytes=0-99` | First 100 bytes |
| `Range: bytes=500-` | From byte 500 to the end |
| `Range: bytes=-500` | Last 500 bytes (suffix range) |
| `Range: bytes=0-99,200-299` | Two ranges; the response may be `multipart/byteranges` |

The only range unit defined by RFC 9110 is `bytes`. Ranges apply to `GET` requests; other methods must ignore `Range`.

## Resuming safely with If-Range

If a file changes between the first attempt and the resume, a naive `Range` request gives you the tail of the new file glued to the head of the old one. `If-Range` fixes that:

```http
GET /files/report.pdf HTTP/1.1
Host: example.com
Range: bytes=1048576-
If-Range: "5f1c-6032a8b4c1a00"
```

If the validator still matches, the server replies `206` with the rest. If it does not, the server replies `200` with the complete new file, and the client has to start over. Two gotchas: the validator must be a strong ETag (no `W/` prefix) or a `Last-Modified` date that is exactly the one the server sent; and a client that sends `If-Range` must handle a 200 reply by discarding what it already has.

## Multiple ranges

```http
HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5
Content-Length: 385

--3d6b6a416f9b5
Content-Type: application/pdf
Content-Range: bytes 0-99/2457600

...100 bytes...
--3d6b6a416f9b5
Content-Type: application/pdf
Content-Range: bytes 200-299/2457600

...100 bytes...
--3d6b6a416f9b5--
```

The top-level response has no `Content-Range`; each part carries its own. Servers defend against abusive requests (hundreds of overlapping ranges was a real denial-of-service vector against Apache httpd in 2011), so expect them to reply 200 with the full body or [416](https://howhttpworks.com/status-codes/416) when a multi-range request looks pathological. nginx exposes `max_ranges` to cap this.

## Server-side notes

- **nginx** serves ranges for static files by default. When proxying, ranges pass through to the upstream; to serve cached slices of a large upstream object, look at the `slice` module (`slice 1m;` with `$slice_range` in the cache key).
- **Express** `res.sendFile()` and `express.static` handle `Range` and `If-Range` through the `send` package. `res.send(buffer)` and a manual `fs.createReadStream().pipe(res)` do not.
- **Compression and ranges.** Byte ranges address the encoded representation. If a proxy compresses on the fly, offsets would shift between requests, which is why many servers disable compression for requests with `Range`, or skip range support for dynamically compressed content.
- **`Content-Length` must be right.** For 206 it is the length of the part, not the file. Reverse proxies that rewrite it are a common cause of truncated media.

A minimal Node handler that supports a single range, to show the mechanics:

```javascript
import fs from 'node:fs'
import http from 'node:http'

http.createServer((req, res) => {
  const path = './video.mp4'
  const size = fs.statSync(path).size
  const header = req.headers.range
  res.setHeader('Accept-Ranges', 'bytes')

  if (!header) {
    res.writeHead(200, { 'Content-Length': size, 'Content-Type': 'video/mp4' })
    return fs.createReadStream(path).pipe(res)
  }

  const m = /^bytes=(\d*)-(\d*)$/.exec(header)
  if (!m || (m[1] === '' && m[2] === '')) {
    res.writeHead(416, { 'Content-Range': `bytes */${size}` })
    return res.end()
  }
  let start = m[1] === '' ? size - Number(m[2]) : Number(m[1])
  let end = m[1] === '' || m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1)
  if (start >= size || start > end) {
    res.writeHead(416, { 'Content-Range': `bytes */${size}` })
    return res.end()
  }

  res.writeHead(206, {
    'Content-Range': `bytes ${start}-${end}/${size}`,
    'Content-Length': end - start + 1,
    'Content-Type': 'video/mp4'
  })
  fs.createReadStream(path, { start, end }).pipe(res)
}).listen(8080)
```

## Debugging checklist

1. `curl -I https://host/file` and look for `Accept-Ranges: bytes`. Absence is not proof of no support, but `none` is.
2. `curl -s -D - -o /dev/null -r 0-1 https://host/file`. You want `206` and `Content-Range: bytes 0-1/<size>`. A `200` means a layer is ignoring ranges.
3. Test through and around the CDN to find which layer strips it (compare `Via`, `Age`, `CF-Cache-Status`, `X-Cache`).
4. Check `Content-Length` on the 206 equals end - start + 1.
5. For resume problems, check that the ETag is stable across origin servers behind the load balancer; ETags that differ per node make `If-Range` fail on every other request. nginx and Apache 2.4 build file ETags from modification time and size, so nodes with different file mtimes after a deploy disagree; Apache 2.2's default also included the inode.

## Related

- [200 OK](https://howhttpworks.com/status-codes/200): what you get when the server ignores `Range`.
- [416 Range Not Satisfiable](https://howhttpworks.com/status-codes/416): the requested range is outside the resource.
- [304 Not Modified](https://howhttpworks.com/status-codes/304): the conditional-request sibling.
- [Range](https://howhttpworks.com/headers/range), [Content-Range](https://howhttpworks.com/headers/content-range), [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges), [If-Range](https://howhttpworks.com/headers/if-range), [ETag](https://howhttpworks.com/headers/etag)
