Understanding HTTP 206 Partial Content: Range Requests, Resumable Downloads, and Video Seeking
206 Partial Content is the status code that makes resumable downloads and video seeking possible without re-fetching an entire file. It's a success response, but a deliberately incomplete one — the server is telling the client "here's exactly the slice you asked for, not the whole thing." This post covers the Range/Content-Range/Accept-Ranges mechanics behind it, how a single response can carry multiple ranges at once, why it's easy to confuse with chunked transfer encoding, and how to return it correctly.
What Is a 206?
A 206 tells the client that the server is fulfilling a range request — returning only the specific byte range (or ranges) the client asked for via the Range header, not the full representation.
The 206 (Partial Content) status code indicates that the server is successfully fulfilling a range request for the target resource by transferring one or more parts of the selected representation that correspond to the satisfiable ranges found in the request's Range header field. — RFC 9110, Section 15.3.7
In plain English: the server is sending only the portion of the resource you requested via the Range header. This is essential for resumable downloads, video streaming, and loading large files in chunks. RFC 9110 folds range-request semantics in directly and obsoletes the older RFC 7233, which used to define them separately — the mechanics described below are current, not deprecated.
Don't confuse 206 with chunked transfer encoding. 206 is for requesting specific byte ranges of a resource, while chunked encoding is about how the response is transmitted. You can have 200 OK with chunked encoding, or 206 with or without chunking.
The Range Request Mechanics
A range request is a negotiation between three headers across two messages:
Accept-Ranges(response, on the full resource) — the server advertises that it supports range requests for a given unit, almost alwaysbytes. NoAccept-Rangesheader (orAccept-Ranges: none) tells the client not to bother.Range(request) — the client asks for a specific slice:Range: bytes=0-999for the first thousand bytes,Range: bytes=1000-for everything from byte 1000 onward, or even multiple ranges in one request:Range: bytes=0-999,2000-2999.Content-Range(response, only on 206 or 416) — the server confirms exactly which bytes it's sending and the total resource size:Content-Range: bytes 0-999/5000.
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-999/5000
Content-Length: 1000If the requested range can't be satisfied — asking for bytes beyond the end of a file, for instance — the server responds with 416 Range Not Satisfiable instead of 206, still including a Content-Range header (with an asterisk for the unsatisfiable range) so the client knows the actual resource size.
Multiple Ranges: multipart/byteranges
A single Range header can request more than one slice in the same request, and 206 has to handle that too. When multiple ranges are requested, the response can't use a single Content-Range header — instead, the Content-Type becomes multipart/byteranges, and the body is split into parts, each with its own Content-Type and Content-Range:
HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=3d6b6a416f9b5
--3d6b6a416f9b5
Content-Type: application/pdf
Content-Range: bytes 0-999/5000
[first 1000 bytes]
--3d6b6a416f9b5
Content-Type: application/pdf
Content-Range: bytes 2000-2999/5000
[bytes 2000-2999]
--3d6b6a416f9b5--Most real-world range requests — video seeking, resumable downloads — only ever ask for a single contiguous range, so multipart/byteranges shows up less often in practice than the simple single-range case, but it's part of the same mechanism and worth recognizing if you ever see it in a response body.
Why 206 Isn't Chunked Encoding
This is the mix-up the RFC's own gloss warns about directly, and it's common because both mechanisms involve a resource arriving in pieces. They're solving different problems:
- 206 / range requests are about the client asking for a specific subset of the resource's bytes — the total size is known, and the client controls which slice it gets.
- Chunked transfer encoding is about how a full response body is transmitted over the wire — broken into chunks so the server doesn't need to know the total content length up front. The client still receives the entire resource.
A response can be 200 OK with chunked encoding (the whole resource, streamed in pieces), or 206 Partial Content with or without chunking (a portion of the resource, which may itself be streamed). The two axes — how much of the resource, and how the bytes are transmitted — are independent of each other.
206 vs 200 vs 416
| Code | Meaning | Use when |
|---|---|---|
| 200 OK | Full representation returned | No Range header was sent, or the server ignores range requests entirely |
| 206 Partial Content | A satisfiable range returned | The client sent a Range header and the requested bytes exist within the resource |
| 416 Range Not Satisfiable | Requested range is invalid | The Range header asks for bytes outside the resource's actual size |
Common Causes
The situations that legitimately produce a 206:
- Resuming a paused download — a download manager or browser re-requests only the remaining bytes after an interrupted transfer, instead of starting over.
- Video streaming seeking to a specific time — jumping to a timestamp in a video player issues a
Rangerequest for the bytes around that point rather than downloading the whole file. - Loading large files in chunks — clients that fetch large resources incrementally, keeping memory and bandwidth use bounded.
Returning 206 Correctly
Express / Node.js
Express doesn't parse Range headers for you — you either use a library like range-parser (which res.sendFile/send already rely on internally for static files) or parse the header manually for a custom stream:
import fs from "node:fs";
app.get("/videos/:id", (req, res) => {
const filePath = getVideoPath(req.params.id);
const { size } = fs.statSync(filePath);
const range = req.headers.range;
if (!range) {
res.set("Accept-Ranges", "bytes");
return res.sendFile(filePath);
}
const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
const start = Number(startStr);
const end = endStr ? Number(endStr) : size - 1;
res.status(206).set({
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": end - start + 1,
});
fs.createReadStream(filePath, { start, end }).pipe(res);
});Next.js App Router
Next.js has no built-in support for range requests in Route Handlers — video players and download managers that rely on Range need it implemented explicitly, using the Web Streams API:
// app/videos/[id]/route.ts
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { size, stream } = await getVideoStream(id);
const range = request.headers.get("range");
if (!range) {
return new Response(stream, {
headers: { "Accept-Ranges": "bytes", "Content-Length": String(size) },
});
}
const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
const start = Number(startStr);
const end = endStr ? Number(endStr) : size - 1;
return new Response(await sliceStream(stream, start, end), {
status: 206,
headers: {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Accept-Ranges": "bytes",
"Content-Length": String(end - start + 1),
},
});
}NGINX
NGINX supports range requests natively for static files served via sendfile — no application code required:
location /videos/ {
sendfile on;
add_header Accept-Ranges bytes;
root /var/www;
}For video specifically, NGINX's mp4 module adds pseudo-streaming support (seeking by time rather than raw byte offset) on top of the same range-request foundation.
Example response
HTTP/1.1 206 Partial Content
Content-Range: bytes 1000-1999/5000
Content-Length: 1000
Accept-Ranges: bytesCommon Pitfalls
- Not advertising
Accept-Ranges: byteson the full resource. Clients generally won't attempt range requests against a resource unless they know the server supports them — omitting the header silently disables resumable downloads and seeking. - Confusing 206 with chunked transfer encoding. They solve different problems — one is about which bytes are returned, the other is about how bytes are transmitted — and conflating them leads to solving the wrong problem when downloads or streaming misbehave.
- Returning 206 without a
Content-Rangeheader. The client has no way to know which bytes it received, or the total resource size, without it — the status code alone doesn't carry that information. - Ignoring an unsatisfiable range instead of returning 416. Silently returning the whole resource (or an empty one) when a
Rangeheader asks for bytes past the end of the file leaves the client unable to detect the mismatch — return 416 with aContent-Rangeindicating the actual size. - Forgetting that range support has to survive proxies and CDNs. An intermediary that strips
RangeorAccept-Rangesheaders, or that caches only full responses, quietly breaks resumable downloads and seeking even when the origin server implements them correctly.
Wrapping Up
206 exists because sending an entire file just to let someone skip to minute forty is wasteful, and the web has quietly relied on it for decades to make streaming and resumable downloads work. The rules of thumb:
- 206 means the response is a satisfiable slice of the resource, requested via the
Rangeheader — not the whole thing Accept-Ranges,Range, andContent-Rangeare the three headers that make the negotiation work, across both the advertisement and the request/response pair- Multiple requested ranges come back as
multipart/byteranges, though most real traffic only ever needs a single range - It's unrelated to chunked transfer encoding — one is about which bytes, the other is about how bytes are sent
- An unsatisfiable range should get 416, not a silent fallback to the full resource
For more, see our pages on 206 Partial Content, 200 OK, and 416 Range Not Satisfiable.