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.mp4What 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.