
Replacing Sharp with WASM: How a Hidden SVG Dependency Broke Our Rendering Pipeline
The failure, the cause, and the fix
Every Open Graph social card on the site was returning a 404 in production, even though the same code worked locally and in CI.
In the version deployed to production, the rendering path depended on sharp, libvips and librsvg to turn Satori’s SVG into PNG bytes. A runtime security setting made libvips’s SVG loader unavailable, so sharp failed with:
Input buffer contains unsupported image formatThe existing fallback failed too because it used the same rendering machinery. It simplified the card content, but it didn’t avoid the broken dependency.
The fix had two parts:
- Replace the
sharprasterisation path withresvgWASM. - Replace the dynamic fallback with a static image read from disk.
The primary path became deterministic across environments, and the fallback stopped sharing the primary renderer’s failure mode.

Want the full debugging story?
The fix is fairly small once you know the cause.
Getting there wasn’t.
I didn’t start this investigation because someone reported broken social cards. I noticed a set of unexplained errors that kept appearing on a Datadog dashboard, with no obvious user-facing symptom attached to them.
I started working backwards from the timestamps. Datadog gave me the error clusters. Splunk gave me the request-level context. Correlating the two eventually led me to the Open Graph image requests and the production-only 404s.
From there I had to work out why the route was failing, why a clean reproduction stubbornly worked, what was different about the production runtime, and why the fallback was dying milliseconds after the primary.
Here’s the rabbit hole.
The 404 was coming from inside the route
The application used Next.js’s file-based Open Graph support with an opengraph-image.tsx route.
A failing request looked roughly like this:
GET /profiles/<user>/opengraph-image-<hash>?<build>
-> 404 text/plain 9 bytesNine bytes mattered.
Not Found is nine bytes.
A genuinely missing route returned the much larger Next.js HTML 404 page. This response was ours, which meant the route was running and something inside it was throwing.
That gave me the first useful boundary in the investigation:

Now I needed to find what was throwing.
The renderer hid an SVG stage
My mental model going into this was basically JSX -> PNG.
That was too simple.
In that production version, the renderer had a runtime branch:

Satori first turns the JSX into SVG. A second stage then has to rasterise that SVG into PNG bytes.
That intermediate format ended up being the most important detail in the incident.
The renderer also selected the rasteriser at runtime. Conceptually, it did something like this:
async function getSharp() {
if (_sharp) return _sharp;
try {
_sharp = (await import("sharp")).default;
} catch {
return undefined;
}
return _sharp;
}Then later:
const svg = await satori(element, { /* ... */ });
if (sharp) {
pngBuffer = await sharp(new TextEncoder().encode(svg))
.resize(width)
.png()
.toBuffer();
} else {
const renderer = new Resvg(svg, { /* ... */ });
}So the useful mental model wasn’t “Next.js renders JSX into an image”. It was JSX -> SVG -> PNG, and those stages have different dependencies and different failure modes.
I ruled out the obvious explanations first
Before I had the useful stack trace, there were plenty of plausible explanations.
I worked through the obvious ones:
- Bad JSX or CSS: rendered fine locally.
- Specific profile data: real production-like data rendered correctly.
- Missing fonts: present in the standalone build.
- Missing WASM binaries: present.
- Incorrect generated URL: the URL was correct.
- Middleware intercepting the request: the request reached the image route.
- Node version differences: the major version matched.
I also managed to manufacture one completely unrelated failure myself.
The generated Open Graph URL contained a build-hash suffix. At one point I requested the bare path instead, and Next.js quite correctly returned its normal HTML 404.
For about twenty minutes I thought I’d reproduced production.
I’d reproduced my typo.
Excellent progress.
The investigation only really started moving when I stopped theorising and followed the errors through the logs.
Correlating Datadog and Splunk exposed the pattern
Datadog was where I first noticed the failure, but Splunk had the request context I needed.
For each cluster of errors in Datadog, I searched the same time window in Splunk and looked at what the application was doing around the failure.
That was how I connected what initially looked like generic rendering errors to the Open Graph image requests.
It also exposed a pattern I couldn’t explain yet.
Primary renders and fallback renders were failing in pairs, milliseconds apart, on the same runtime instance.
For example:
primary: 32 failures
fallback: 32 failuresLater:
primary: 93 failures
fallback: 93 failuresFailures were also spread across different avatar sources. That made an avatar-specific input problem much less likely.
The paired failures would matter later. First, I needed to know where the renderer was dying.
The stack trace moved the failure below Next.js
The line that changed the investigation was this:
Error: Input buffer contains unsupported image format
at Sharp.toBuffer (.../sharp/dist/output.mjs:159:17)
at render (.../@vercel/og/index.node.js:...)That immediately narrowed the search space.
The request was going through sharp.
More importantly, the buffer sharp couldn’t identify wasn’t an uploaded avatar. It was the SVG Satori had just generated.
The failing path was now much clearer: Satori had successfully produced SVG, and the native rasterisation branch was dying inside sharp before PNG output existed.
That changed the question from:
What’s wrong with the profile image?
To:
Why can’t sharp decode a valid SVG in production?
In this path, sharp hands the work to libvips. SVG support depends on the SVG loader being available there. If no loader claims the input, the error you get is the wonderfully specific Input buffer contains unsupported image format.
Now I had something concrete to reproduce.
The working reproduction narrowed the problem
I built a minimal reproduction with the same important pieces:
- framework version
- package manager
- install flags
- standalone output
- Node version
- base architecture
It rendered perfectly, including on two architectures.
That ruled out most of the build. The framework version wasn’t inherently broken, the standalone output wasn’t inherently broken, and the healthy container was taking the same sharp path successfully.
The remaining difference was much narrower: the environment the container was running in.
A runtime security setting reproduced the exact failure
libvips can block operations it considers untrusted. In my reproduction, enabling that restriction made the SVG path fail exactly like production:
$ docker run -e VIPS_BLOCK_UNTRUSTED=1 <image> node probe.js
sharp SVG->PNG: FAILED
Input buffer contains unsupported image formatRun the same image without the setting:
$ docker run <image> node probe.js
sharp SVG->PNG: OK
31270 bytesSame image. Same application. One runtime setting changed the result.
I could confirm the mechanism directly too:
import sharp from "sharp";
sharp.unblock({
operation: ["VipsForeignLoadSvg"],
});Once the SVG loader was allowed again, the render succeeded.
That proved the failure mechanism: the Open Graph path produced SVG, then passed it into a native image stack where SVG parsing was unavailable at runtime.
There is one caveat worth calling out. I never established what introduced that restriction into the production environment. I couldn’t find it in the application source, container image or deployment configuration I investigated.
I could prove the mechanism. I couldn’t prove its provenance.
That uncertainty influenced the fix.
The fallback used the same broken renderer
There was already a fallback renderer.
If the main card failed, the fallback removed most of the complicated inputs:
- no avatar
- no custom fonts
- no profile text
- no dynamic content
The assumption was reasonable: if some unexpected input broke the renderer, render something simpler.
Except the input wasn’t broken.
The renderer was.
The fallback still crossed the same rendering boundary as the primary:

Suddenly the paired error counts made sense.
Every time the primary failed because SVG parsing wasn’t available, the fallback generated another SVG and sent it through the same broken machinery a few milliseconds later.
The fallback had removed complexity from the input. It hadn’t removed the failing dependency.
I made the fallback deliberately boring
The replacement fallback doesn’t render anything.
It reads a pre-generated image from disk:
const FALLBACK_CARD_CONTENT_TYPE = "image/jpeg";
export async function renderFallbackSocialCard(): Promise<Response> {
const bytes = await readFile(
join(process.cwd(), "public/card.jpg"),
);
return new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": FALLBACK_CARD_CONTENT_TYPE,
},
});
}That’s it.
No Satori. No SVG. No sharp. No libvips.
It’s boring on purpose.
If the dynamic rendering stack catches fire, the fallback shouldn’t politely walk back into the fire.
Why I replaced sharp with resvg WASM
For the main renderer, I had two practical options.
Option 1: allow SVG parsing again
Mechanically, this was the quickest path.
But I still didn’t know why the production environment had restricted that capability in the first place. I didn’t want an Open Graph feature to override an environment-level security control just to preserve the existing implementation, especially when I could remove that dependency from the path entirely.
Option 2: remove libvips from this path
The other option was to remove libvips from this path entirely and make resvg WASM the explicit rasteriser. That’s the route I took.
The implementation was relatively small:
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { initWasm, Resvg } from "@resvg/resvg-wasm";
import satori from "satori";
const WASM_PATH =
"node_modules/@resvg/resvg-wasm/index_bg.wasm";
let wasmReady: Promise<void> | undefined;
function initResvg(): Promise<void> {
wasmReady ??= readFile(
join(process.cwd(), WASM_PATH),
).then(initWasm);
return wasmReady;
}
export async function renderElementToPng(
element: ReactNode,
{ width, height, fonts }: RenderOptions,
): Promise<Buffer> {
const svg = await satori(element, {
width,
height,
fonts,
});
await initResvg();
const resvg = new Resvg(svg, {
fitTo: {
mode: "width",
value: width,
},
});
try {
const rendered = resvg.render();
try {
return Buffer.from(rendered.asPng());
} finally {
rendered.free();
}
} finally {
resvg.free();
}
}The important change wasn’t the syntax. It was the runtime behaviour shown in the pipeline comparison at the top of the post: resvg became the one explicit rasteriser.
No native image loader in this path. No environment-dependent SVG decoder. No silent rasteriser selection.
The WASM fix created a packaging problem
Of course, replacing the renderer wasn’t the end of it.
Reading the WASM file via require.resolve() made the bundler try to process it as a module, which failed. Reading the binary by path avoided that problem, but then Next.js’s dependency tracer couldn’t infer that the file was required at runtime.
The standalone output didn’t contain it.
I had to make that dependency explicit:
serverExternalPackages: ["@resvg/resvg-wasm"],
outputFileTracingIncludes: {
"/**": [
"./node_modules/@resvg/resvg-wasm/index_bg.wasm",
],
},Configuration like this is exactly the sort of thing that gets removed six months later because nobody remembers why it’s there.
So I added a build assertion too:
RUN test -f .next/standalone/node_modules/@resvg/resvg-wasm/index_bg.wasm \
|| { echo "resvg wasm missing from standalone output"; exit 1; }Then I verified the assertion in both directions. Remove the tracing configuration and the build fails.
That turns a hidden runtime dependency into something the build can enforce.
One green test had never rendered an image
The migration also exposed a test problem.
There was already a test that appeared to cover image rendering. It created an ImageResponse and asserted against it.
The problem was that ImageResponse rendered lazily. Constructing the response didn’t force Satori to execute.
The test was green because the failing operation had never happened.
Once I changed the path to render eagerly, the test failed immediately.
A test called “render image” that never renders the image is a particularly optimistic form of testing.
I verified the real renderer, not just a successful response
The static fallback introduced one final trap.
Once it shipped, every Open Graph request returned a valid-looking image again. From the outside, the feature looked healthy.
That still didn’t prove the dynamic renderer was working. It could remain completely broken while every request quietly returned the fallback.
So I verified the responses themselves.
Before the renderer fix:
| Result | |
|---|---|
| Content type | image/jpeg |
| Size | 425,081 bytes every time |
| Requests | 45/45 fallback |
After:
| Result | |
|---|---|
| Content type | image/png |
| Size | roughly 143KB to 245KB |
| Requests | 45/45 dynamically rendered |
The fallback responses were byte-for-byte identical.
The dynamic cards varied with each profile. That variation was evidence that I was exercising the real renderer again.
What I’d do earlier next time
A few things from this investigation are going straight into my debugging playbook.
Correlate telemetry before guessing
The Datadog errors weren’t very useful by themselves. Correlating their timestamps with Splunk turned an unexplained counter into an actual request path.
Look for hidden stages
“JSX to PNG” hid an SVG conversion with its own runtime dependencies. The intermediate representation was where the incident lived.
Make implementation selection explicit
A library silently choosing between implementations is convenient until production takes a different path from your laptop. For an important path, the chosen implementation should be observable.
Design fallbacks around failure boundaries
Removing input complexity helps when the input is the problem. It doesn’t help when the capability underneath it has disappeared.
A useful fallback should avoid the failed step wherever practical.
The lesson I kept
Technically, this incident came down to an SVG loader being unavailable inside the production runtime.
That’s not the part I expect to remember.
I started with unexplained errors on a Datadog dashboard, correlated them against Splunk, connected them to broken Open Graph images, followed the failure through Satori and sharp, reproduced the runtime behaviour in isolation, and replaced the native rasterisation path with WASM.
But the most useful discovery was the fallback.
I’d assumed it gave the feature resilience because it rendered something simpler. It didn’t. It depended on the exact capability that had just failed.
The fallback wasn’t independent. It was a smaller version of the same pipeline.
Thanks for reading ✌️