Skip to content

🐛 Bug Report — Runtime APIs: encodeBody: "manual" is silently dropped when a Response is rebuilt with an object init, double-encoding the body #7066

Description

@oddharsh

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions