A bug we shipped, and the shape of it
Range requests in Workers: the 200 that should be a 206
R2's range option is a three-arm union — {offset, length?}, {offset?, length}, and {suffix} — and R2 returns a partial body for all three. If your Worker only recognises one arm, the other two fall through to a plain 200 carrying part of the file and no Content-Range. The client cannot tell that from the whole object.
This is a defect we shipped on this platform and fixed on 13 Aug 2026. The interesting part is not the fix, which is six lines — it is that the failure is invisible from every direction you would normally look: the request succeeds, the status is 200, the bytes arrive, no error is logged, and the tests pass. Below is the union, the exact fall-through, what the spec actually requires, and the fix with a test per arm.
The union has three arms, and the docs say so plainly
Cloudflare's Workers API reference for R2 states it directly under Ranged reads: "There are 3 variations of arguments that can be used in a range: An offset with an optional length. An optional offset with a length. A suffix." The type ships the same shape. From @cloudflare/workers-types at version 5.20260811.1, verbatim:
type R2Range =
| {
offset: number;
length?: number;
}
| {
offset?: number;
length: number;
}
| {
suffix: number;
};
The three arms are not exotic. They are the three things an HTTP Range header can ask for, and each maps to a request real clients send every day:
| Request header | What R2 hands back | Who sends it |
|---|---|---|
Range: bytes=0-99 | {offset: 0, length: 100} | Chunked downloaders, most range libraries |
Range: bytes=500- | {offset: 500} — no length | Resumed downloads, "continue from where I stopped" |
Range: bytes=-100 | {suffix: 100} — no offset, no length | Media players reading a trailing index |
The per-field semantics matter for the fix. The same reference defines offset as "The byte to begin returning data from, inclusive", length as "The number of bytes to return", and suffix as "The number of bytes to return from the end of the file, starting from the last byte" — and for both length and suffix it adds the same caveat: "If more bytes are requested than exist in the object, fewer bytes than this number may be returned."
The fall-through, and why nothing catches it
A range-aware handler typically looks at object.range — described in the same reference as "A R2Range object containing the returned range of the object" — and builds a Content-Range from it. The natural first version reads the two fields you think of first:
// The bug. Reads like a null-check; behaves like a silent truncation.
const range = obj.range;
if (hasRange && range && 'offset' in range
&& typeof range.offset === 'number'
&& typeof range.length === 'number') {
headers.set('content-range',
`bytes ${range.offset}-${range.offset + range.length - 1}/${obj.size}`);
return new Response(obj.body, { status: 206, headers });
}
return new Response(obj.body, { headers }); // <-- 200, partial body
That guard is true for exactly one arm. A bytes=-100 request produces {suffix: 100}, which has no offset at all, so the condition is false and execution reaches the last line. bytes=500- produces {offset: 500} with no length, and fails the same guard for a different reason.
The critical detail is that obj.body is already truncated by then. R2 honoured the range; the handler simply failed to say so. The response is a 200 with the last hundred bytes of a file in it and no header indicating that anything was omitted. To the client that is not a partial response — it is a complete file that happens to be a hundred bytes long.
Now consider why this survives review. There is no exception, so nothing appears in logs. The status is 200, so uptime and error-rate dashboards stay green. Byte counts are lower, which reads as good. A browser fetching the whole object never sends a Range header, so manual testing passes. And the failing clients — a video player seeking, a download resuming — degrade in ways that look like their problem: a scrub bar that will not seek, a resumed download that completes instantly and produces a corrupt file. This is the class of bug that gets attributed to the CDN for a month.
Why the canonical example does not show you this
There is a structural reason so many Workers get this wrong. The complete R2 example in Cloudflare's own Workers API usage guide passes range: request.headers into get() — correctly forwarding the client's range — and then returns:
// When no body is present, preconditions have failed
return new Response("body" in object ? object.body : undefined, {
status: "body" in object ? 200 : 412,
headers,
});
Two statuses, 200 and 412. No 206, no Content-Range, anywhere in the example. That is not an error in the docs — the snippet is demonstrating bindings and conditional requests, not range responses, and it says as much by what it covers. But it is the example everyone copies as a starting point, and it accepts a Range header while never producing a range response. Copy it, add nothing, and you have shipped the bug in its purest form: R2 truncates the body and your handler returns 200 for every arm, not just two of them.
The transferable habit here is the one this platform keeps arriving at from different directions: a working example is a claim about what it demonstrates, not a template for what you owe the protocol. The docs are accurate. The gap is between "this compiles and returns bytes" and "this is a conformant HTTP response", and only one of those is visible in a quickstart.
What the spec actually requires
The obligation is not a style preference, and it is short enough to quote. RFC 9110 §15.3.7.1, Single Part:
If a single part is being transferred, the server generating the 206 response MUST generate a Content-Range header field, describing what range of the selected representation is enclosed, and a content consisting of the range.
And the client side, from §15.3.7: "A client MUST inspect a 206 response's Content-Type and Content-Range field(s) to determine what parts are enclosed and whether additional requests are needed." That sentence is why the 200 is worse than an error. A conformant client inspects Content-Range on a 206. Return 200 and it has no reason to look, no way to discover the truncation, and every reason to treat the bytes as the whole representation.
The suffix arm has one more rule worth knowing, from §14.1.2: "A client can refer to the last N bytes (N > 0) of the selected representation using a suffix-range. If the selected representation is shorter than the specified suffix-length, the entire representation is used." So bytes=-5000 against a 1,000-byte object is not an error — it is a legal request for the whole thing. Any arithmetic you write against obj.size has to survive it, which is the clamp in the fix below.
The fix, and where to put it
Handle all three arms, derive both ends from the object size, and clamp. On this platform that lives in src/lib/http.ts, deliberately not in the route:
export function contentRangeFor(
range: { offset?: number; length?: number; suffix?: number },
size: number,
): string {
const hasSuffix = typeof range.suffix === 'number';
const offset = hasSuffix ? Math.max(0, size - range.suffix!) : range.offset ?? 0;
const length = (hasSuffix ? range.suffix : range.length) ?? size - offset;
const end = Math.min(offset + length, size) - 1;
return `bytes ${offset}-${end}/${size}`;
}
Then the route condition collapses to the thing that was actually true all along — if the client asked for a range and R2 returned one, it is a 206:
const range = obj.range;
if (hasRange && range) {
headers.set('content-range', contentRangeFor(range, obj.size));
return new Response(obj.body, { status: 206, headers });
}
Three notes on the details, because each one is load-bearing:
- Both clamps are input validation, not defensive padding.
Math.max(0, size - suffix)keeps a suffix larger than the object from producing a negative offset — and per §14.1.2 that request is legal, so it will arrive.Math.min(offset + length, size)covers the same overshoot from the other end.Rangeis client-controlled input and deserves the treatment you would give any other. - The function is structurally typed rather than taking
R2Range. That is what makes it unit-testable: our test runner cannot import the Workers runtime types, so a helper that namesR2Rangein its signature can only be exercised in an integration test. Taking a plain object shape moves the arithmetic somewhere a fast test can reach it. - It sits behind
src/lib/on purpose. The vendor's union shape is the churn-prone part; if Cloudflare adds an arm, one file changes. The route asks a question about HTTP, not about R2.
The test that would have caught it
One case per arm plus the two clamps. In test/http.test.ts, against a 1,000-byte object:
expect(contentRangeFor({ offset: 0, length: 100 }, 1000)).toBe('bytes 0-99/1000');
expect(contentRangeFor({ offset: 500 }, 1000)).toBe('bytes 500-999/1000'); // bytes=500-
expect(contentRangeFor({ suffix: 100 }, 1000)).toBe('bytes 900-999/1000'); // bytes=-100
expect(contentRangeFor({ suffix: 5000 }, 1000)).toBe('bytes 0-999/1000'); // legal, clamps
expect(contentRangeFor({ offset: 900, length: 5000 }, 1000)).toBe('bytes 900-999/1000');
Note what the original test suite would have looked like without these: green. There was no failing assertion to notice, because the only arm anyone wrote a test for was the arm the code handled. A union type is a checklist of test cases, and a test suite that covers one arm of three is not partial coverage of a feature — it is complete coverage of a misunderstanding. That is the reusable lesson, and it generalises well past R2: whenever a vendor type is a union, enumerate the arms and write one case each before you write the handler.
If you want the same discipline applied to the systems around your models rather than your object storage, that is the shape of the work in our curriculum, and the free sample lesson is a fair test of whether the level suits you.
What this still does not handle
Honesty about scope, since the fix above is deliberately small and you should know what you are still missing:
- 416 responses. RFC 9110 §14.2 says that where "the ranges-specifier is unsatisfiable with respect to the selected representation, the server SHOULD send a 416 (Range Not Satisfiable) response", with a
Content-Range: bytes */1234style unsatisfied-range. The code above clamps instead. For an unsatisfiable range that is a deliberate simplification, not conformance. - Multiple ranges. A
Rangeheader can carry a range set, which requires amultipart/byterangesresponse. R2's binding does not return one, and neither does this. If-Range. Conditional range requests are a separate mechanism and are not wired here.
All three are fine to omit for a media path serving small committed clips. They stop being fine at catalog scale, which is the honest trigger to revisit — and roughly the same trigger that makes the original bug expensive, since the runtime you pick determines how much of this you own versus inherit.
Frequently asked questions
Does R2 return a partial body for all three range arms?
Yes. The range is applied by R2 before the body reaches your handler, for every arm. That is precisely what makes the bug silent — the truncation is already done and correct; only the response metadata is wrong.
Is bytes=-100 the first hundred bytes or the last hundred?
The last hundred. RFC 9110 §14.1.2 defines a suffix-range as "the last N units of the representation data", and R2 maps it to {suffix: 100}. Reading it as an offset is a common inversion and produces a plausible-looking response from the wrong end of the file.
What happens if a client asks for more bytes than the object has?
It is a legal request and the whole representation is used. Cloudflare's reference says fewer bytes than requested may be returned; RFC 9110 says a too-long suffix-range yields the entire representation. Your Content-Range arithmetic has to clamp rather than trust the requested number.
Do I need this if I only serve small files?
Correctness does not depend on file size, but the consequences do. At a megabyte the damage is a player that will not seek. At catalog scale, with clients issuing suffix reads for trailing indexes, it is corrupt downloads that look like whole files.
Changelog
- 2026-08-13 — Initial publication. Written from a defect found and fixed on this platform the same day. The union is quoted from
@cloudflare/workers-types5.20260811.1as installed; the Cloudflare wording from the Workers API reference; the RFC text verbatim from RFC 9110. The as-built fix is insrc/lib/http.tswith the route insrc/index.tsand tests intest/http.test.ts.
- Range arm semantics and the three variations: Cloudflare — R2 Workers API reference, Ranged reads and
R2GetOptions;R2Object.rangedefinition from the same page (verified 13 Aug 2026). - The example that returns only 200/412: Cloudflare — Use R2 from Workers, section 4 (verified 13 Aug 2026).
- Union definition:
@cloudflare/workers-typesversion5.20260811.1,type R2Range, quoted verbatim from the installed package rather than from documentation. - 206 obligations: RFC 9110 §15.3.7.1 (Single Part) and §15.3.7 (206 Partial Content).
- Suffix-range semantics and the shorter-than-suffix rule: RFC 9110 §14.1.2 (Range). Unsatisfiable ranges and 416: §14.2;
Content-Rangefield definition: §14.4. - As-built: the helper is
contentRangeForinsrc/lib/http.ts, called from the media route insrc/index.ts, with one test per arm intest/http.test.ts.
RFC text is quoted verbatim; Cloudflare documentation wording was read on the verification date and vendor docs move. Corrections: hello@aiarch.dev.
The bug was silent. The habit that catches it is teachable.
aiArch teaches senior engineers to build AI systems on evidence — reading the type, reading the spec, and writing the test per arm before the handler — on a platform that is itself a production system, defects and all.
See how aiArch helps senior engineers become AI-native, or compare Professional Membership pricing.
Free sample — no signup · every claim cited · full curriculum is waitlist-only
Subscribe to the Brief — free. This is the newsletter, not the membership waitlist — request an invite here →