Invisible Banding in Generated OG Images: Two satori Traps, and One I Got Wrong

Generated Open Graph cards have a nasty property: nobody looks at them. They are rendered on a server, cached, and shipped to Slack and Twitter and iMessage, where they appear at 400 pixels wide next to a link. When one of them is subtly wrong, there is no error. There is no warning. There is a slightly odd edge in a gradient that reads as JPEG compression, and it stays there for every post you ever publish.

I generate cards with satori, which turns a React-shaped element tree into SVG, and then rasterize that SVG to PNG. Over the past week I found two rendering behaviours in that pipeline that are not documented anywhere I can find, and that are invisible until you decode the output and go looking.

I also found a third. I documented it, shipped code to work around it, and it was wrong. Checking that one led me to two more claims of my own that did not survive contact with a second renderer. That part is at the end, and it is the more useful half of the post.

How you find a defect nobody can see

The technique is the whole game, so start here. Render the image, decode it to raw pixels, and walk a line looking for row-to-row or column-to-column jumps. Gradients are smooth by construction — a step of more than 2 or 3 out of 255 between adjacent pixels means something cut the gradient.

measure.mjs
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';

const svg = await satori(tree, { width: 1200, height: 630, fonts });
const img = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render();
const data = img.pixels; // hoist: .pixels is a getter, don't call it per pixel
const px = (x, y) => {
  const i = (img.width * y + x) * 4;
  return [data[i], data[i + 1], data[i + 2], data[i + 3]];
};

// Walk a column; report the largest jump between adjacent pixels.
let worst = { step: 0, y: -1 };
for (let y = 1; y < img.height; y += 1) {
  const step = Math.abs(px(600, y)[1] - px(600, y - 1)[1]);
  if (step > worst.step) worst = { step, y };
}
console.log(worst); // { step: 50, y: 115 }

resvg's RenderedImage exposes raw RGBA through .pixels, so you never need a PNG decoder. That is thirty lines between "the card looks a bit off" and a number you can act on.

That output is real: y=115 is the top edge of a glow circle on the card I ran it against, and a 50-out-of-255 jump between two adjacent pixels is not something a gradient does. It is the first trap.

Two things this bought me immediately.

Trap 1: radial-gradient overshoots a round clip

The obvious way to draw a soft glow is a square element with a radial gradient and borderRadius: 50%:

{
  width: 400, height: 400, borderRadius: 200,
  background: 'radial-gradient(circle at 50% 50%, rgba(78,205,196,0.9) 0%, rgba(78,205,196,0) 100%)',
}

That renders a circle with a hard ring around it. The cause is a CSS default that is easy to forget: radial-gradient sizes itself to farthest-corner unless you say otherwise.

In a square element, the farthest corner sits at size/√2 ≈ 0.707 × size from the centre, but the borderRadius: 50% clip cuts at size/2. So the clip lands at 70.7% of the gradient's length — where the gradient is still meaningfully opaque. You are not fading to nothing and then clipping; you are clipping a gradient mid-fade.

Measured on a 400px circle with a three-stop gradient — worst horizontal step at the circle's edge (the exact figure moves with your stops; the 50/255 above is the same trap on a two-stop gradient, walked vertically):

Extent next/og (@vercel/og 0.11.1) satori 0.26 + resvg-js 2.6
default (farthest-corner) 31/255 32/255
closest-side 2/255 2/255

closest-side makes the fade complete exactly where the round clip happens, and the discontinuity drops into the noise. One keyword:

background: 'radial-gradient(closest-side circle at 50% 50%, …)'

This reproduces on both pipelines I can test, which is worth stating explicitly — for reasons that will become clear.

Trap 2: your two-tone wordmark is silently losing its space

Brand lockups often want two colours in one wordmark: lans.cloud in white, Blog in red. The natural encoding is two runs:

{ type: 'div', props: { style: { display: 'flex' }, children: [
  { type: 'span', props: { children: 'lans.cloud ' } },
  { type: 'span', props: { style: { color: '#FF6B6B' }, children: 'Blog' } },
]}}

That renders lans.cloudBlog.

The trailing space is gone, because in a flex container each span is a flex item, not an inline text run — and a flex item shrink-wrapping to its content trims its trailing whitespace. The space is in your source, in your element tree, and in the SVG's text content. It is not in the picture.

It is a 7-pixel difference, which is why you will not catch it by eye. Rightmost inked pixel on a 400×60 canvas:

rightmost ink
one flat string 'lans.cloud Blog' 220
two runs, no whiteSpace 213
two runs, whiteSpace: 'pre' 220
two runs, whiteSpace: 'pre-wrap' 220

So whiteSpace: 'pre' fixes it. And introduces a worse bug, because pre also turns wrapping off. Same runs, a wordmark long enough to need two lines, on a 400×200 canvas:

rightmost ink lowest ink
one flat string 352 101 (wrapped)
runs, whiteSpace: 'pre' 399 (clipped at the canvas edge) 35 (one line)
runs, whiteSpace: 'pre-wrap' 329 65 (wrapped)

pre ran the text off the edge of the card and stayed on a single line. The answer is pre-wrap, which preserves the whitespace and keeps wrapping. Note the wrapped widths differ slightly between the flat string (352) and the runs (329) — multi-run text breaks at the run boundary, so the layouts are similar but not identical.

Can you catch this without rendering?

Yes — but only if you diff the right thing, and I had this wrong too. My own notes said the emitted SVG was identical between the broken and fixed versions, and that only a pixel diff could catch it. That is false. The difference is sitting in the glyph coordinates:

- d="M162.0 29L153.2 29L153.2 7.2L161.5 7.2Q163.9 7.2 165.5 7.9…"   # no whiteSpace
+ d="M169.0 29L160.2 29L160.2 7.2L168.5 7.2Q170.9 7.2 172.5 7.9…"   # pre-wrap

The second run's path starts 7 pixels further right — exactly the width of the space that was trimmed. The first run's path is byte-identical. So a diff does find it; it is one number inside a d= attribute, which a glance will never catch and diff will.

(A related detail worth knowing: for a lockup that already fits on one line, 'pre' and 'pre-wrap' really do emit byte-identical SVG. For one that has to wrap, they don't — that is the whole bug.)

The turn: my third trap was not a trap

Alongside those two, I had written down and shipped a third:

satori does not support repeating-linear-gradient — it renders nothing at all, silently.

That claim went into a package README, into the module documentation, and into the justification for a function that draws a dot-matrix texture as one absolutely-positioned div per dot — around 400 divs per card — instead of one cheap repeating gradient.

While fact-checking this post, I measured it. It is false:

repeating-linear-gradient   distinct colours across a scanline: 2
repeating-radial-gradient   distinct colours across a scanline: 2
two crossed (dot grid)      distinct colours across a scanline: 2

satori emits <linearGradient … spreadMethod="repeat"> and the renderer honours it. Correct bands, on both pipelines, including the exact crossed-gradient dot pattern the 400 divs were replacing.

I do not know what the original observation was. Most likely a different property combination, or a layer that was invisible for an unrelated reason and got attributed to the gradient. What I do know is that it entered the documentation as a measured fact with no measurement recorded beside it, and nothing downstream could tell the difference between that and a real one.

And once I started pulling, it kept coming. The "only a pixel diff catches the whitespace trimming" claim in the section above: also mine, also wrong, and the counter-evidence was one diff away. A set of numbers describing the wrapping behaviour: quoted as pixel coordinates, except one of them was 859 on a 400-pixel-wide canvas, which is not a place a pixel can be. They were satori layout units, copied into a sentence that said pixels. Nobody caught it because a number that specific reads as authoritative.

And the first trap was only half true

The other claim I had documented was this: an element's background is clipped to canvas-sized bounds measured from that element's own origin. An orb bigger than the canvas gets a hard straight edge exactly canvasHeight pixels below its own top.

That one is real. It is also pipeline-specific, and I had written it down as "satori does X".

Measured on next/og (@vercel/og 0.11.1, resvg-wasm):

Orb Step Where
1000×1000 on a 1200×800 canvas, centred 42/255 exactly local y=800
1000×1000, positioned at (0.87, 0.12) 42/255 exactly local y=800
1600×1600 170/255 exactly local y=800

The edge lands at local y = canvas height every time, at different absolute positions — that is about as clean a confirmation of a mechanism as pixel measurement gets.

On satori 0.26 + @resvg/resvg-js 2.6, the same five geometries all peak at 1/255. There is no edge. And satori 0.26 emits the gradient with patternUnits="objectBoundingBox" — a pattern sized to the element, which is exactly right.

So the claim was true for the renderer I measured it on, false for the other one, and written down as a property of satori in general.

The rule this cost me

A measured rendering fact is only true for the pipeline it was measured on. Record the renderer and its version next to the number.

Generalized, with nothing to do with any of this: when you write down an empirical fact about a tool, the version and the surrounding stack are part of the fact, not context you can drop. A number without its apparatus is folklore with a decimal point — and it is more dangerous than folklore, because the decimal point makes everyone downstream stop checking.

Note the failure was asymmetric in a way I did not expect. The claim that was narrowly true did more damage than the one that was flatly false, because it generalized so plausibly. "satori clips backgrounds to canvas bounds" is a sentence nobody questions. It survived a full adversarial review of the package, because the reviewer verified that the code matched the documentation — which it did. Nobody was checking whether the documentation matched reality. Those are different checks, and I was only running one of them.

What I changed, and what I still don't know

I kept both workarounds and rewrote both explanations. The orb clamp stays because it is load-bearing on next/og, which is what one of my sites actually renders through, and free on a pipeline that renders correctly. The 400 divs stay because every already-published card was rendered with them, and switching to a gradient would change live images for no functional gain — a cheaper implementation is not worth a silent visual change to things already in circulation. Both now carry the measurement, the renderer, and the version.

Still open: I have not isolated which layer produces the next/og clipping. It could be the older satori bundled inside @vercel/og, or resvg-wasm; both bundles contain both patternUnits variants, so grepping the dist files settled nothing. Isolating it needs the same SVG rasterized by both renderers, which I have not done.

If you generate images programmatically and you have never decoded one and walked a line of pixels: do it once. Thirty lines, and you will find out whether you have been shipping an artifact for months.