How HTTP Works
2xx · Success
< HTTP/1.1 206 Partial Content

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.

Reviewed 5 min readintermediate5 sourcesTry itMarkdown
Cacheable
Yes, by default
Client action
Stitch the ranges together
Usually sent by
Origin or CDN (Range requests)
Spec
RFC 9110 §15.3.7
Often confused with
200
On this page

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:

curl -s -D - -o /dev/null -r 0-99 https://example.com/files/report.pdf
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

HeaderMeaning
Range: bytes=0-99First 100 bytes
Range: bytes=500-From byte 500 to the end
Range: bytes=-500Last 500 bytes (suffix range)
Range: bytes=0-99,200-299Two 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:

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/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 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:

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.

Frequently asked questions

What does 206 Partial Content mean?

The server understood a Range request and is returning only the requested byte range of the resource. The Content-Range header says which bytes are in the body and how large the full resource is.

Why does my video need 206 to play in Safari?

Safari starts media playback with a small range request such as Range: bytes=0-1 and expects a 206 with a valid Content-Range. A server that answers 200 with the whole file, or that does not support ranges, can leave the video element stuck even though Chrome plays it fine.

What is the difference between 206 and 200 for a Range request?

A server is allowed to ignore a Range header and reply 200 with the full body. A client that sent Range must therefore check the status: only 206 means the body is a slice. Treating a 200 body as a slice corrupts resumed downloads.

How does If-Range work?

If-Range carries a strong ETag or a Last-Modified date. If it still matches the current resource, the server returns 206 with the requested slice. If not, it returns 200 with the whole new resource, which prevents stitching bytes from two different versions of a file.

Can I send multiple ranges in one request?

Yes, as in Range: bytes=0-99,200-299. The server may reply with 206 and a multipart/byteranges body, each part carrying its own Content-Range, or it may collapse or refuse the request. Browsers rarely send multi-range requests, PDF viewers sometimes do.

Is a 206 response cacheable?

Yes, 206 is cacheable by default, and a cache can combine stored ranges of the same representation. In practice many CDNs fetch the whole object or fixed-size slices from the origin rather than relaying arbitrary client ranges.

Sources

  1. MDN Web Docs: 206 Partial Contentdeveloper.mozilla.org
  2. RFC 9110 Section 15.3.7: 206 Partial Contentrfc-editor.org
  3. RFC 9110 Section 14: Range Requestsrfc-editor.org
  4. RFC 9110 Section 13.1.5: If-Rangerfc-editor.org
  5. RFC 9111 Section 3.3: Storing Incomplete Responsesrfc-editor.org

Keep going

Browse /search