DEV Community

Cover image for Don't trust your SVG optimizer. Render-diff its output.
Patu
Patu

Posted on

Don't trust your SVG optimizer. Render-diff its output.

SVG optimizers are wonderful right up to the day one quietly breaks an icon.

Run SVGO (or any optimizer) across a big icon set and you'll eventually hit it: a <g> gets collapsed, a path gets rewritten into a mathematically "equivalent" form, and in most renderers it looks identical, but in one specific renderer, or at one specific size, it's subtly wrong. A shape that was outlined is now filled. A stroke disappears. You don't notice until it's in production, because it didn't show up on the five icons you spot-checked.

Here's the uncomfortable part: most optimizers guarantee "smaller," not "renders the same." Those are different promises. An optimizer can shave bytes and, on an edge case, change what the file draws. The transforms are usually safe. "Usually" is the problem.

The fix isn't a better optimizer. It's verification.

Instead of trusting that a transform preserved rendering, check it. After optimizing, rasterize the original SVG and the optimized SVG, compare the two bitmaps, and if they diverge beyond a small tolerance, throw the optimized version away and fall back.

The guarantee flips from "these transforms are provably safe" (hard, and false in the edge cases) to "the pixels didn't change" (easy to check, and exactly what you care about).

A sketch of the idea:

opt := optimize(original)         // your SVG minifier of choice
a := rasterize(original, 512)     // longest edge 512px is plenty
b := rasterize(opt, 512)
if sameSize(a, b) && diffFraction(a, b, tolerance) < 0.02 {
    use(opt)                      // pixels match: trust it
} else {
    use(fallback(original))       // diverged: don't ship it
}
Enter fullscreen mode Exit fullscreen mode

A few things that matter in practice:

  • Flatten onto an opaque background before comparing. A shape that lost its fill and fell back to transparent should count as a difference, not get masked by alpha.
  • Allow a small per-channel tolerance (say 16/255). Anti-aliasing shifts sub-pixels when path data is rewritten; that's fine. A filled-vs-unfilled shape is not.
  • A small render is enough. You're catching gross divergence (a dropped stroke, a flipped fill), not fine detail, so a 512px raster keeps the check cheap.

You render twice, which isn't free, but for build-time optimization of an icon set you pay it once and never ship a broken icon.

The tools

The optimizer I use is silk, a pure-Go SVG optimizer in the spirit of SVGO (path data, structural passes). It ships as a single static binary, no Node toolchain, which is why it drops into a Go service or a minimal CI image where a JS toolchain would be a pain. Like most optimizers, it guarantees smaller, not identical, so the render-diff above is the layer I put around it: it lives in Patu, the asset service these came out of, and falls back to a conservative minifier whenever the render moved.

There's a sibling for fonts, woffify: TTF/OTF/WOFF to WOFF2 plus glyph subsetting (via HarfBuzz), one binary. A raw TTF shipped instead of a subsetted WOFF2 is usually two-thirds-plus wasted bytes.

woffify -subset-text "$(cat page.txt)" < Inter.ttf > Inter.woff2
Enter fullscreen mode Exit fullscreen mode

If you optimize SVGs at scale, steal the render-diff idea even if you never touch these tools. It's the cheapest insurance against the one broken icon you won't catch by eye.

Top comments (0)