Node Buffer response body re-encoded as UTF-8 on Vercel functions (high bytes become EF BF BD)

Summary

A Next.js App Router route handler (export const runtime = 'nodejs') that returns binary data via new Response(buffer) where buffer is a Node Buffer (e.g. the return value of sharp(...).toBuffer()) delivers corrupted bytes when deployed on Vercel: the body appears to be decoded as UTF-8 text and re-encoded, so every byte sequence that is not valid UTF-8 is replaced with EF BF BD (U+FFFD). In our case a 31,054-byte WebP image arrived as 56,700 bytes and the browser failed with EncodingError.

The identical build served locally with next start returns the body byte-perfect, which is why this looks like a Vercel function egress issue rather than a Next.js one.

Environment

  • Next.js 16.2.10 (App Router, Turbopack build)
  • Route handler with export const runtime = 'nodejs'
  • Vercel Node.js functions, Node 24
  • Observed 2026-07-28, regions fra1 / iad1

Minimal reproduction

// app/api/repro/route.ts
export const runtime = 'nodejs'

export async function GET() {
  // 16 bytes, several of them invalid as UTF-8
  const bytes = Buffer.from([
    0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
    0xff, 0xd8, 0xff, 0xe0, 0xc3, 0x28, 0x80, 0xbf,
  ])
  return new Response(bytes, {
    headers: { 'Content-Type': 'application/octet-stream' },
  })
}

Check with:

curl -s https://<deployment>/api/repro | od -A x -t x1
  • Local next start: exactly the 16 constructed bytes.
  • On Vercel: high bytes replaced by ef bf bd, body longer than 16 bytes.

Expected

Byte-identical delivery of the response body regardless of whether the BodyInit is a Buffer or a plain Uint8ArrayBuffer is a Uint8Array subclass and a valid BodyInit per the Fetch spec.

Workaround that fixes it

Copying the Buffer into a plain Uint8Array and setting an explicit Content-Length:

headers.set('Content-Length', String(buf.byteLength))
return new Response(new Uint8Array(buf), { headers, status: 200 })

Both changes were applied together, so I cannot yet say whether the fresh copy or the explicit Content-Length is the effective part.

Open question

Are ReadableStream bodies whose chunks are Node Buffers (e.g. Readable.toWeb(nodeStream)) affected as well? We now defensively copy each stream chunk into a plain Uint8Array, but could not verify the streaming path on Vercel yet.

Happy to provide deployment IDs / timestamps privately if that helps debugging.

Hi Elmar,

Nice minimal repro. Since local next start is byte-perfect and the deployed Node function changes invalid UTF-8 byte sequences into ef bf bd, I’d keep treating this as a response serialization boundary issue, not a sharp or image-specific issue.

To narrow it down, I’d split the workaround into separate deployed variants:

// A: Buffer + Content-Length
return new Response(buf, {
  headers: {
    "Content-Type": "application/octet-stream",
    "Content-Length": String(buf.byteLength),
  },
})

// B: plain Uint8Array copy, no Content-Length
return new Response(new Uint8Array(buf), {
  headers: {
    "Content-Type": "application/octet-stream",
  },
})

// C: ArrayBuffer slice, no Content-Length
return new Response(
  buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
  {
    headers: {
      "Content-Type": "application/octet-stream",
    },
  }
)

Then compare each one with:

curl -sS https://<deployment>/api/repro | wc -c
curl -sS https://<deployment>/api/repro | od -A x -t x1

If A still corrupts but B/C don’t, that points specifically at Buffer being handled differently from plain web binary body types. If A works, then Content-Length may be the decisive part.

For the streaming case, I’d keep defensively converting each Buffer chunk to a plain Uint8Array before it crosses the Response boundary. One extra useful data point would be whether the same repro behaves differently on Node 22 vs Node 24 in Vercel, since that would make the report easier to triage.