encodeBody: "manual" is write-only Response init with no getter, so a wrapper that
rebuilds a response cannot carry it forward and cannot tell that it lost it. The
content-encoding header stays visible on the rebuilt response, so the runtime
encodes the body a second time to match, and the response goes out double-encoded.
The surprise is that it depends on the SHAPE of the init. Passing the original
Response as init preserves the flag. Building a plain init object drops it, and
building an init object is what you do the moment you want to add a header.
Versions
workerd 1.20260811.1 via wrangler 4.123.0 / miniflare 5.20260811.1-alpha,
compatibility_date = "2026-08-01", wrangler dev --local.
Repro
// 55 bytes of brotli that decode to 17600 bytes of "the quick brown fox ..."
const B64 = "G79EiJwJNk6odze8JjNjo9MgZ3h68g0trEHK5vLCBhw4JJC3Edyh0wpnHx4nSik4wjvxANQHUQ==";
const bytes = () => Uint8Array.from(atob(B64), (c) => c.charCodeAt(0));
const original = () =>
new Response(bytes(), {
headers: { "content-encoding": "br", "content-type": "text/plain" },
encodeBody: "manual",
});
export default {
async fetch(request) {
const p = new URL(request.url).pathname;
if (p === "/a") return original();
if (p === "/b") { const r = original(); return new Response(r.body, r); }
if (p === "/e") { const r = original(); return new Response(r.body, { status: r.status, headers: r.headers }); }
if (p === "/f") {
const r = original();
const h = new Headers(r.headers); h.set("x-added", "1");
return new Response(r.body, { status: r.status, headers: h });
}
if (p === "/g") {
const r = original();
const out = new Response(r.body, r);
out.headers.set("x-added", "1");
return out;
}
},
};
Results
layers is how many times the wire bytes have to be brotli-decoded before the
plaintext appears.
| route |
how the response is rebuilt |
wire |
layers |
/a |
not rebuilt |
55 B |
1 |
/b |
new Response(r.body, r) |
55 B |
1 |
/e |
new Response(r.body, { status, headers }) |
59 B |
2 |
/f |
same, plus one added header |
59 B |
2 |
/g |
new Response(r.body, r), then out.headers.set(...) |
55 B |
1 |
Every one of them sends content-encoding: br. So on /e and /f, a client that
decodes once, which is what the header instructs, gets compressed bytes:
/a: 17600 bytes, readable text = true first bytes: "the quick brown fox jump"
/e: 55 bytes, readable text = false first bytes: "\x1b\xbfD\t6N\xa8w7\xbc&3c\xa3\xd3 gxz\xf2\r-"
There is also no way to see the flag from JavaScript. On the response returned by
original():
{"encodeBody_own": false, "encodeBody_value": null, "in_prototype": false, "keys": []}
Why this is worth a change
A wrapper that adds a header to every response is an ordinary thing to write, and
the object-init form is the obvious way to write it. That form silently converts a
correct precompressed response into a broken one. Nothing throws, the status is
200, the header is right, content-length is plausible, and the body is garbage
for any client that honours the encoding.
It is also hard to attribute. The byte counts move by single digits (55 to 59
here, and 13051 to 13047 on a real 47 KB asset, since brotli-of-brotli barely
grows), so the size tells you nothing. The visible symptom is a client failing to
decode a response whose headers look correct, which points at the client, the CDN,
or the compressor before it points at the wrapper.
Suggested fix
Expose encodeBody as a readable property on Response. That is the smallest
change that makes this fixable in userland: a wrapper can read it and pass it on,
and a test can assert it survived. Right now the only way to be safe is to know
that the /b and /g shapes preserve it, which is not written down anywhere I
could find.
Preserving the flag through an object init that carries a content-encoding
header would also work, though it is a bigger behavioural change and I assume
there are cases that want the current semantics.
Failing both, documenting the asymmetry between /b and /e would at least make
it findable. Happy to send a docs PR for that if you would rather start there.
Related
encodeBody: "manual"is write-only Response init with no getter, so a wrapper thatrebuilds a response cannot carry it forward and cannot tell that it lost it. The
content-encodingheader stays visible on the rebuilt response, so the runtimeencodes the body a second time to match, and the response goes out double-encoded.
The surprise is that it depends on the SHAPE of the init. Passing the original
Response as init preserves the flag. Building a plain init object drops it, and
building an init object is what you do the moment you want to add a header.
Versions
workerd
1.20260811.1via wrangler4.123.0/ miniflare5.20260811.1-alpha,compatibility_date = "2026-08-01",wrangler dev --local.Repro
Results
layersis how many times the wire bytes have to be brotli-decoded before theplaintext appears.
/a/bnew Response(r.body, r)/enew Response(r.body, { status, headers })/f/gnew Response(r.body, r), thenout.headers.set(...)Every one of them sends
content-encoding: br. So on/eand/f, a client thatdecodes once, which is what the header instructs, gets compressed bytes:
There is also no way to see the flag from JavaScript. On the response returned by
original():{"encodeBody_own": false, "encodeBody_value": null, "in_prototype": false, "keys": []}Why this is worth a change
A wrapper that adds a header to every response is an ordinary thing to write, and
the object-init form is the obvious way to write it. That form silently converts a
correct precompressed response into a broken one. Nothing throws, the status is
200, the header is right,
content-lengthis plausible, and the body is garbagefor any client that honours the encoding.
It is also hard to attribute. The byte counts move by single digits (55 to 59
here, and 13051 to 13047 on a real 47 KB asset, since brotli-of-brotli barely
grows), so the size tells you nothing. The visible symptom is a client failing to
decode a response whose headers look correct, which points at the client, the CDN,
or the compressor before it points at the wrapper.
Suggested fix
Expose
encodeBodyas a readable property on Response. That is the smallestchange that makes this fixable in userland: a wrapper can read it and pass it on,
and a test can assert it survived. Right now the only way to be safe is to know
that the
/band/gshapes preserve it, which is not written down anywhere Icould find.
Preserving the flag through an object init that carries a
content-encodingheader would also work, though it is a bigger behavioural change and I assume
there are cases that want the current semantics.
Failing both, documenting the asymmetry between
/band/ewould at least makeit findable. Happy to send a docs PR for that if you would rather start there.
Related
encodeResponseBody: "manual"option. #3669, where the option was implementedcontent-encodingand auto-encoding interact