Skip to content

wasm32: toBuffer() resolves with a SharedArrayBuffer-backed Buffer, silently corrupted when used as a fetch body #4576

Description

@pcroarkin

Possible bug

Is this a possible bug in a feature of sharp, unrelated to installation?

  • Running npm install sharp completes without error.
  • Running node -e "import 'sharp'" completes without error.

Are you using the latest version of sharp?

  • I am using the latest version of sharp as reported by npm view sharp dist-tags.latest (0.35.3).

What is the output of running npx envinfo --binaries --system --npmPackages=sharp --npmGlobalPackages=sharp?

  System:
    OS: macOS 15.1
    CPU: (10) arm64 Apple M1 Max
    Memory: 1.39 GB / 64.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 25.8.1 - /opt/homebrew/bin/node
    npm: 11.11.0 - /opt/homebrew/bin/npm
  npmPackages:
    sharp: ^0.35.3 => 0.35.3

Also reproduced on Node v24.18.0 linux-x64 (Vercel).

Does this problem relate to file caching?

  • Adding sharp.cache(false) does not fix this problem.

Does this problem relate to images appearing to have been rotated by 90 degrees?

  • Using rotate() or keepExif() does not fix this problem.

What are the steps to reproduce?

When the wasm32 runtime is in use, toBuffer() resolves with a Buffer that is a zero-copy view into the wasm heap — and because @img/sharp-wasm32 is a threaded Emscripten build, that heap is a SharedArrayBuffer:

buf.buffer instanceof SharedArrayBuffer  // true (byteOffset ~5 MB into a 16 MB backing)

SharedArrayBuffer-backed views are rejected or mishandled by many web APIs (BufferSource without [AllowShared], see nodejs/undici#4495). The concrete disaster case is fetch: Node.js/undici silently coerces such a view to a string, so the request body becomes the lossy UTF-8 decode/re-encode of the image — JPEG magic ff d8 ff db is sent as ef bf bd ef bf bd …, inflated, 100% valid UTF-8, no error thrown (reported on the undici side as nodejs/undici#5596). The native runtimes return a normal ArrayBuffer-backed Buffer and are unaffected.

This matters much more in 0.35 than before, because the wasm32 runtime is no longer opt-in:

  1. @img/sharp-wasm32 0.35.x has no cpu/os constraints, so it installs on every platform (0.34.x had cpu: ["wasm32"] and was skipped unless requested).
  2. dist/sharp.mjs falls back to require('@img/sharp-wasm32/sharp.node') as a silent last resort — no warning is emitted when the native binary fails to load.
  3. Native load failure is currently common on a mainstream stack: Next.js 16 (Turbopack) on Vercel omits libvips-cpp.so from the traced bundle (Turbopack: sharp 0.35.3 fails to load on Vercel (Next.js 16 Server Actions) with ERR_DLOPEN_FAILED: libvips-cpp.so.8.18.3 #4567).

So a production deployment that used the native linux-x64 runtime on sharp 0.34 can upgrade to 0.35 and silently switch to wasm32 — and from that moment every fetch-uploaded toBuffer() result is corrupted, with no error anywhere and no local repro (dev machines load the native runtime). This is how we found it: ~3,300 corrupted image uploads (Next.js 16 on Vercel → supabase-js storage, which passes the Buffer as a raw fetch body) over ~a day in our print-shop MIS.

Steps:

mkdir repro && cd repro && npm init -y
npm install --cpu=wasm32 sharp    # or: npm install sharp, then simulate native-load failure by renaming node_modules/@img/sharp-<platform>* — the silent fallback path
node repro.mjs

What is the expected behaviour?

toBuffer() resolves with a Buffer backed by a normal, non-shared ArrayBuffer on every runtime, so the result is safe to hand to fetch, Blob, streams, etc. On the wasm32 path that presumably means one copy out of the wasm heap before resolving. Independently (or at least): emit a warning when the loader falls back to the wasm32 runtime, so a silent native→wasm degradation in production is visible.

Please provide a minimal, standalone code sample, without other dependencies, that demonstrates this problem

// repro.mjs — run once with the native runtime (clean) and once with wasm32 (corrupt)
import sharp from 'sharp';
import { createHash } from 'node:crypto';
import http from 'node:http';

const sha = (b) => createHash('sha256').update(b).digest('hex').slice(0, 16);

const server = http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const body = Buffer.concat(chunks);
    res.end(JSON.stringify({ len: body.length, sha: sha(body), head: body.subarray(0, 4).toString('hex') }));
  });
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));

const buf = await sharp({
  create: { width: 300, height: 200, channels: 3, background: { r: 200, g: 30, b: 30 } },
}).jpeg().toBuffer();

console.log('SharedArrayBuffer-backed:', buf.buffer.constructor.name === 'SharedArrayBuffer');
console.log('sent    ', { len: buf.length, sha: sha(buf), head: buf.subarray(0, 4).toString('hex') });

const res = await fetch(`http://127.0.0.1:${server.address().port}/`, { method: 'POST', body: buf });
console.log('received', await res.json());
server.close();

Output with the wasm32 runtime (Node v25.8.1; identical corruption signature observed on Node v24.18.0 linux-x64 on Vercel):

SharedArrayBuffer-backed: true
sent     { len: 640, sha: '68000ffc9d46ed3a', head: 'ffd8ffdb' }
received { len: 686, sha: '73a15ff7aa14bfba', head: 'efbfbdef' }

The received bytes are exactly Buffer.from(buf.toString('utf8'), 'utf8') — a lossy UTF-8 roundtrip.

Output with the native runtime (same script, same machine):

SharedArrayBuffer-backed: false
sent     { len: 640, sha: '68000ffc9d46ed3a', head: 'ffd8ffdb' }
received { len: 640, sha: '68000ffc9d46ed3a', head: 'ffd8ffdb' }

Additional context

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions