# Content-Range Header

> Learn how the Content-Range header indicates which portion of a resource is being sent in partial content (206) responses for range requests and streaming.

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

> **TL;DR:** `Content-Range` tells you which slice of a file a `206 Partial Content` response carries. `bytes 5-9/10` means bytes 5 through 9 (inclusive, zero-based) of a 10-byte file; `/*` means the total size is unknown. On a `416`, `bytes */10` reports the file size because your range didn't fit. When resuming a download, confirm the status is 206 and the offsets match before appending.

## What is Content-Range?

Content-Range is the server's answer to a [Range](https://howhttpworks.com/headers/range) request. It either names the bytes being returned or, when the range can't be served, reports the full size. [RFC 9110 §14.4](https://www.rfc-editor.org/rfc/rfc9110#section-14.4) defines its meaning on `206 Partial Content` and `416 Range Not Satisfiable`. On a `200` it has no partial-content meaning: a 200 carries the whole representation, whatever headers you add.

## Syntax

The three forms you'll see (all examples on this page are trimmed):

```http
Content-Range: bytes 5-9/10
Content-Range: bytes 5-9/*
Content-Range: bytes */10
```

Offsets are zero-based and the end is inclusive. `/10` is the size of the complete representation, not of this response. `/*` means the server doesn't know the complete size. `*/10` is the form used when the requested range couldn't be satisfied.

A valid byte interval has an end at or after its start, and, when the total is known, an end below the total. `bytes` is the unit you'll meet in practice, but the grammar allows other range units.

## How Content-Range Works

Take a ten-byte file containing `0123456789`. A request with `Range: bytes=5-` gets back:

```http
HTTP/1.1 206 Partial Content
Content-Type: text/plain
Content-Range: bytes 5-9/10
Content-Length: 5

56789
```

`Content-Length` is 5 because it measures this message's body, while the `/10` in Content-Range is the size of the whole file. If the response has a Content-Encoding, the offsets count bytes of the encoded representation, not the decoded one.

A request starting at offset ten asks for bytes that don't exist, so it gets:

```http
HTTP/1.1 416 Range Not Satisfiable
Content-Range: bytes */10
Content-Length: 0

```

A `416` may carry an error page. In that case Content-Length is the error page's size, and the file size stays in Content-Range.

## Common Examples

When a response carries several ranges, Content-Range goes **inside each part**, not in the top-level headers. Here's a multipart response that marks its end by closing the connection:

```http
HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=demo
Connection: close

--demo
Content-Type: text/plain
Content-Range: bytes 0-1/10

01
--demo
Content-Type: text/plain
Content-Range: bytes 8-9/10

89
--demo--
```

If this response had a top-level Content-Length, it would count the boundaries and part headers along with the data, so it would be larger than the sum of the selected byte ranges.

To inspect real multipart framing, use the [Go fixture](https://howhttpworks.com/headers/if-range#server-implementation), which accepts multiple ranges:

```bash
curl -sS -D /tmp/multipart.headers -o /tmp/multipart.body \
  -H 'Range: bytes=0-1,8-9' http://127.0.0.1:8082/sample.txt
cat /tmp/multipart.headers
python3 -c 'from pathlib import Path; print(repr(Path("/tmp/multipart.body").read_bytes()))'
```

The real boundary comes from the response's Content-Type and won't be `demo`. The Python one-liner prints carriage returns and line feeds explicitly, so you can tell the MIME delimiters apart from the data. Look for the two part-level intervals and their `01` and `89` payloads. Any parser you write has to read the declared boundary from Content-Type rather than search for a fixed string.

Servers may merge overlapping or nearby ranges, so the intervals you get back can differ from the ones you asked for. Validate what came back and write each part at the offset it advertises. Concatenating parts in arrival order silently drops gaps or duplicates overlaps; an interval-aware or sparse-file assembler has to track which positions it has covered.

## Server Implementation

Use your server's built-in static-file handler if it supports ranges; the [Range page](https://howhttpworks.com/headers/range#server-implementation) has a reproducible nginx fixture. Hand-rolled implementations go wrong when they set the headers but forget to slice the body. Send exactly the selected inclusive interval, whose length is `end - start + 1`.

Multipart responses also need correct boundaries and framing. Each part gets only its own slice, never a full copy of the file labelled as a range.

In Express, let [`res.sendFile()`](https://expressjs.com/en/5x/api/response/#res.sendFile) do the slicing. Once you've created the fixture from the Range page, add this route to an existing app on port 3000:

```javascript
app.get('/sample.txt', (req, res, next) => {
  res.sendFile('sample.txt', {
    root: '/tmp/http-range-demo',
    acceptRanges: true,
  }, (error) => {
    if (error) next(error);
  });
});
```

sendFile produces the partial body and the matching headers together. A relative filename needs the `root` option; without it, sendFile wants an absolute path. The callback passes transfer errors on to your error handler. Let sendFile own the response: a later `res.send()` or a manual Content-Length set to the full file size breaks it.

If you write your own, watch the off-by-one between HTTP and your file API. HTTP's end offset is inclusive, so bytes five through nine are five bytes. JavaScript's `subarray(5, 10)` has an exclusive end, which means `subarray(5, 9)` drops the last requested byte. Test the actual output against the interval in the header, not just against the file length.

## Getting Content-Range right

Before you stitch downloaded pieces together, check the returned start and end offsets, the complete length if known, and the validator. If an [If-Range](https://howhttpworks.com/headers/if-range) validator no longer matches, the server sends a full `200` instead of a 206; replace the partial file with it rather than appending.

Content-Range isn't a CORS-safelisted response header, so cross-origin browser code can't read it by default. The origin, which must already allow the caller through CORS, has to expose it:

```http
Access-Control-Expose-Headers: Content-Range, Accept-Ranges, ETag
```

This header only makes the listed headers readable. The caller still needs the usual CORS permission for the request itself.

For a small, uncompressed response in the browser, this function checks the single-part form before returning the bytes. It accepts an unknown total (`/*`) and rejects the `*/total` form used by 416:

```javascript
async function readPartial(response, expectedStart) {
  if (response.status !== 206) throw new Error(`Expected 206: ${response.status}`);
  if ((response.headers.get('content-encoding') ?? 'identity') !== 'identity') {
    throw new Error('This reader requires identity-coded bytes');
  }
  const match = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(
    response.headers.get('content-range') ?? '',
  );
  if (!match) throw new Error('Expected a single byte interval');
  const start = BigInt(match[1]);
  const end = BigInt(match[2]);
  const total = match[3] === '*' ? null : BigInt(match[3]);
  if (start !== BigInt(expectedStart) || end < start ||
      (total !== null && end >= total)) {
    throw new Error('Invalid interval');
  }
  const bytes = new Uint8Array(await response.arrayBuffer());
  if (BigInt(bytes.length) !== end - start + 1n) {
    throw new Error('Body does not match Content-Range');
  }
  return { bytes, start, end, total };
}
```

BigInt keeps very large offsets exact; a regular number could round them to a different byte. The whole body sits in memory here, so stream large files instead. This function checks framing only. Your download assembler still has to compare the saved ETag, content coding and complete length before it combines pieces from different responses.

A multipart response has no top-level Content-Range, so this function won't parse it. Use a MIME parser and run the same checks on each part; treating the whole multipart body as one interval would write the boundaries into your file.

With an unknown total (`/*`), you still get a definite interval to validate, but you don't learn the final file size or whether this interval reaches the end. Track completion separately from interval validation.

## Testing Content-Range

Send a GET to the nginx fixture from the [Range page](https://howhttpworks.com/headers/range#server-implementation), which listens on port 8080:

```bash
curl -sS -D /tmp/range.headers -o /tmp/range.body \
  --range 5- http://localhost:8080/sample.txt
cat /tmp/range.headers
wc -c < /tmp/range.body
```

Check for `206` and `bytes 5-9/10` in the headers, then a five-byte body. Use GET here rather than `curl -I`: that sends HEAD, and servers ignore Range on HEAD.

To see the rejection, request a range past the end of the fixture. Saving the headers to a file keeps them even though the request fails:

```bash
curl -sS -D /tmp/unsatisfied.headers -o /tmp/unsatisfied.body \
  --range 10- http://localhost:8080/sample.txt
cat /tmp/unsatisfied.headers
wc -c < /tmp/unsatisfied.body
```

The file size shows up in `bytes */10`. Any error page's size shows up in Content-Length or the message framing, and the two numbers usually differ. If a proxy swaps in its own error page, check whether it kept Content-Range before you conclude the origin didn't report a length.

## Related Headers

- [Range](https://howhttpworks.com/headers/range)
- [Accept-Ranges](https://howhttpworks.com/headers/accept-ranges)
- [If-Range](https://howhttpworks.com/headers/if-range)
