Skip to content
S
distributed systems

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:

Plain text
Input buffer contains unsupported image format

The 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:

  1. Replace the sharp rasterisation path with resvg WASM.
  2. 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.

Open Graph rendering pipeline before and after, replacing the native Sharp and libvips SVG path with an explicit resvg WASM rasteriser


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:

Plain text
GET /profiles/<user>/opengraph-image-<hash>?<build>
-> 404 text/plain 9 bytes

Nine 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:

Open Graph request reaching the route, rendering throwing inside it, and the application returning a nine-byte text 404

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 producing SVG before the renderer chooses Sharp when available or resvg WASM otherwise, with production taking the Sharp path

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:

TypeScript
async function getSharp() {
  if (_sharp) return _sharp;

  try {
    _sharp = (await import("sharp")).default;
  } catch {
    return undefined;
  }

  return _sharp;
}

Then later:

TypeScript
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:

Plain text
primary: 32 failures
fallback: 32 failures

Later:

Plain text
primary: 93 failures
fallback: 93 failures

Failures 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:

Plain text
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:

Console
$ docker run -e VIPS_BLOCK_UNTRUSTED=1 <image> node probe.js

sharp SVG->PNG: FAILED
Input buffer contains unsupported image format

Run the same image without the setting:

Console
$ docker run <image> node probe.js

sharp SVG->PNG: OK
31270 bytes

Same image. Same application. One runtime setting changed the result.

I could confirm the mechanism directly too:

TypeScript
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:

Primary and fallback Open Graph cards both passing through Satori and Sharp, causing both paths to fail when the SVG loader is blocked

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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

Dockerfile
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 typeimage/jpeg
Size425,081 bytes every time
Requests45/45 fallback

After:

Result
Content typeimage/png
Sizeroughly 143KB to 245KB
Requests45/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 ✌️

References

  1. Metadata and OG images - Next.js
  2. Satori - JSX and CSS to SVG
  3. Sharp global properties - block and unblock
  4. libvips VIPS_BLOCK_UNTRUSTED behaviour
  5. resvg-js and the WebAssembly renderer

Stay in the loop

Practical engineering notes, without the inbox noise.

Notes on distributed systems, resilient software, and engineering in the real world - usually once or twice a month.

Unsubscribe anytime. See what you get, or prefer a feed? Subscribe via RSS.