Skip to content
Control Plane Labs

206 Partial Content

Success response defined by RFC 9110 §15.3.7.

Last updated July 27, 2026

Common causes at a glance

  • A media player seeking within a video or audio file
  • A download manager or curl -C - resuming an interrupted transfer
  • A CDN or proxy fetching an origin object in slices
  • A client probing with Range: bytes=0-0 to discover the total size cheaply

Reproduce it

curl -i -r 0-1023 https://cdn.example.com/video.mp4

What 206 Partial Content means

Every resumable download, video seek, and parallel chunked transfer runs on 206. The client sends Range: bytes=1024-2047, the server answers 206 with Content-Range: bytes 1024-2047/5242880, and only that slice crosses the wire.

The detail that trips people up is that Content-Length on a 206 is the length of the part you were sent; the total size appears only after the slash in Content-Range. A single-range request returns bytes directly, while a multi-range request returns a multipart/byteranges document with its own boundaries and per-part headers, which is why many clients only ever ask for one range.

What the spec says

RFC 9110 §15.3.7 requires the client to inspect Content-Type and Content-Range to work out what it received, and requires the server to carry over Date, Cache-Control, ETag, Expires, Content-Location, and Vary from what a 200 would have sent. Range requests as a whole are §14; Range, Accept-Ranges, and Content-Range are §14.2, §14.3, and §14.4. A server may satisfy fewer ranges than asked. If-Range (§13.1.5) stops a resuming client splicing bytes from two versions of a file: it falls back to a full 200 if the validator no longer matches.

What actually causes it

Range support is the part of HTTP most likely to be broken by whatever sits in front of the origin. A backend that ignores Range and returns the full body as a 200 makes video scrubbing impossible in Safari, which refuses to play media over a connection that cannot serve ranges. Dynamic compression is a classic conflict: a proxy gzipping on the fly cannot honour a byte range against the uncompressed representation, so it drops the range or returns bytes that do not line up. The other recurring bug is a weak ETag on a resumable download. Weak validators cannot be used for range splicing, so an If-Range resume silently restarts from zero and users watch the download go backwards.

How to debug it

Check Accept-Ranges first, then ask for a slice and read Content-Range:

curl -sI https://cdn.example.com/video.mp4 | grep -i accept-ranges
curl -sD - -o /dev/null -r 1024-2047 https://cdn.example.com/video.mp4 | grep -iE '^(HTTP|content-range|content-length)'

A healthy result is 206 with content-range: bytes 1024-2047/5242880 and content-length: 1024. A 200 with the whole file means the range was dropped: retry against the origin to see whether it is the app or the CDN. A 416 means the range is past the end. If ranges work at the origin but not the edge, check compression and body-rewriting filters, then nginx’s slice module.

Working examples

curl

# Does the server advertise range support at all?
curl -sI https://cdn.example.com/video.mp4 | grep -i accept-ranges

# Ask for one slice and read the framing headers.
curl -sD - -o /dev/null -r 1024-2047 https://cdn.example.com/video.mp4 \
  | grep -iE '^(HTTP|content-range|content-length|etag)'

# Resume a partial download; curl sends Range and If-Range for you.
curl -C - -o video.mp4 https://cdn.example.com/video.mp4

Python (requests)

import requests

url = "https://cdn.example.com/video.mp4"

head = requests.head(url, timeout=10)
print(head.headers.get("Accept-Ranges"), head.headers.get("Content-Length"))

resp = requests.get(url, headers={"Range": "bytes=1024-2047"}, timeout=10)
print(resp.status_code, resp.headers.get("Content-Range"), len(resp.content))

if resp.status_code == 200:
    raise RuntimeError("range ignored - something in the path stripped it")

# Resume safely: If-Range makes the server fall back to a full 200 if the file changed.
etag = head.headers.get("ETag")
have = 2048
part = requests.get(
    url,
    headers={"Range": f"bytes={have}-", "If-Range": etag},
    stream=True,
    timeout=30,
)
print(part.status_code)  # 206 to append, 200 to start over

Node (fetch)

const url = "https://cdn.example.com/video.mp4";

const res = await fetch(url, { headers: { Range: "bytes=1024-2047" } });
console.log(res.status, res.headers.get("content-range"));

if (res.status !== 206) {
  console.warn("no partial content - range stripped or unsupported");
}

const chunk = new Uint8Array(await res.arrayBuffer());
console.log("received", chunk.length, "bytes");

// Total size lives after the slash in Content-Range, not in Content-Length.
const total = Number(res.headers.get("content-range")?.split("/")[1]);
console.log("full representation is", total, "bytes");

Reference and tooling

The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.

Other 2xx codes