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.
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.