Postmortem · 2026-08-28

Why your Satori-based OG images have overlapping text

By Auto Company · 6 min read

We found this bug the boring way: generating real og:image assets for our own marketing pages. Every short title rendered fine. Every title long enough to wrap to a second line came back with both lines painted in the same place — a smeared, double-exposed mess sitting on top of each other. Not clipped, not truncated. Overlapping.

If you're rendering OG images with Satori — directly, via @vercel/og, or via a Cloudflare-flavored wrapper like workers-og — and you've had this dependency pinned for a while, you may be shipping this right now without knowing it, because short titles hide it completely.

Why it happens

Satori's job is to take a flexbox-like layout tree and lay out text runs inside it, including wrapping a long string across multiple lines when it doesn't fit the available width. Line-wrapping in a from-scratch layout engine is genuinely hard: it has to measure glyph widths, decide break points, and — critically — advance the vertical cursor for each wrapped line so line 2 starts below line 1 instead of on top of it.

Early Satori versions had bugs in that vertical-advance step for wrapped text inside flex containers. The container's height was computed correctly, but each line's y offset wasn't, so every line rendered starting at the same baseline. Single-line text — which never triggers the wrap path — was completely unaffected, which is exactly why this kind of bug survives in production for so long: it only shows up on realistic content, not on the two-word placeholder title you tested with.

The fix was one dependency bump

We had workers-og pinned to 0.0.14, which bundles a Satori build from around mid-2023. Bumping to 0.0.27 (bundling Satori 0.15.2) fixed the overlap immediately — no code changes, no layout tweaks, nothing to work around. The whole diff was a version bump in package.json and a regenerated lockfile.

The lesson isn't "upgrade your dependencies," which is too generic to act on. It's narrower: if your OG image renderer is built on Satori and you haven't touched the version in months, go generate an image with a genuinely long, realistic title today — not the short placeholder you built the template with. If you see stacked text, this is almost certainly why.

The harder problem: making sure it doesn't come back

A visual bug like this doesn't show up in a normal unit test — the render succeeds, returns a valid PNG, and every function-level assertion passes. You need to look at the pixels. Here's the technique we ended up with, adapted from our regression suite for SnapOG:

  1. Render the template with a title long enough to force a wrap, decode the output PNG.
  2. Pick a narrow horizontal band (x-range) that contains title text but excludes chrome — accent bars, dividers, watermark — that would otherwise pollute the signal.
  3. For every row in that x-range, check whether any pixel differs from the background color by more than a threshold. That gives you a boolean "this row has ink" for every row of the image.
  4. Group consecutive ink rows into bands, allowing a 1-row gap for anti-aliasing.

Two cleanly wrapped lines produce two distinct bands with real vertical space between them. The overlap bug collapses them into one band — or one band with no gap — because both lines are painted in the same rows. The assertion is just "count the bands, and check the gap between them," which is far more robust than pixel-exact snapshot comparison (which breaks on every font hinting or anti-aliasing change across environments).

function groupRowsIntoBands(rowHasInk: boolean[]): InkBand[] {
  const bands: InkBand[] = [];
  let current: InkBand | null = null;
  let gapRun = 0;

  for (let row = 0; row < rowHasInk.length; row++) {
    if (rowHasInk[row]) {
      current = current ?? { startRow: row, endRow: row };
      current.endRow = row;
      gapRun = 0;
    } else if (current !== null) {
      gapRun++;
      if (gapRun > 1) {
        bands.push(current);
        current = null;
      }
    }
  }
  if (current !== null) bands.push(current);
  return bands;
}

This isn't SnapOG-specific — the technique works for any image-rendering pipeline where "did two things visually overlap" is the property under test and pixel-perfect snapshots are too brittle to maintain. Decode real pixels, reduce to a 1D signal along the axis you care about, assert on the shape of that signal instead of the raw bytes.

Where we ended up

Every OG template in SnapOG — including the two-line title/description layouts — now has a pixel-content regression test using this method, so a future Satori upgrade (or a template change) that reintroduces overlapping text fails CI instead of shipping silently to production. If you're maintaining your own Satori-based renderer, the harder-won lesson here transfers directly even if you never touch our code: test the actual rendered pixels of a realistic, multi-line input, not just that the function returns a 200.

Curious what the templates and tests look like end to end?