How HTTP Works

Response header

< Content-Range: bytes 0-1023/4096

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.

Reviewed 6 min readintermediate3 sourcesTry itMarkdown
Direction
Response
Category
Range requests
Spec
RFC 9110 §14.4
On this page

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 request. It either names the bytes being returned or, when the range can’t be served, reports the full size. RFC 9110 §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):

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/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/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/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, which accepts multiple ranges:

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 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() do the slicing. Once you’ve created the fixture from the Range page, add this route to an existing app on port 3000:

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

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:

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, which listens on port 8080:

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:

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.

Frequently asked questions

What is Content-Range?

It is the response header that says which bytes of a file a partial response contains, and how big the whole file is. It appears on single-part 206 responses, inside each part of a multipart 206, and on 416 responses to report the full size.

How do I read Content-Range values?

In bytes 5-9/10, offsets five through nine are included and the complete representation has ten bytes. An asterisk after the slash means the complete length is unknown.

When is Content-Range sent?

It appears at the top level of single-part 206 responses and in each part of multipart responses. A 416 byte-range response should include the unsatisfied form.

What does Content-Range: bytes */5000 mean?

In a 416 response, bytes */5000 means the requested range did not fit and the full representation is 5000 bytes. Compare that with your local file size and validator before you mark a resumed download as complete.

Sources

  1. MDN Web Docs: Content-Rangedeveloper.mozilla.org
  2. RFC 9110: Content-Rangerfc-editor.org
  3. Fetch Standard: CORS response headersfetch.spec.whatwg.org

Keep going

Browse /search