Optimising SVG: The Byte Count Reports the Saving, Not the Damage
Smaller and still renders feels like sufficient verification. It is the check that misses every failure that matters.
Optimising SVG is lossy, and the byte count will not tell you
Running an optimiser over vector assets is close to free money. Editor exports carry metadata nobody needs, coordinates to fifteen decimal places, empty definition blocks and namespace declarations for tools that touched the file in 2019. Stripping those routinely halves the file.
The trap is that the same tool, with the same default configuration, also performs transformations that are semantically lossy. Byte reduction is reported. What broke is not. An optimiser run is the only build step I know of that is routinely committed without anyone looking at its output, because "smaller and still renders" feels like sufficient verification and is not.
The classic: removing the viewBox
A long-standing default in the most widely used optimiser removes the
viewBox attribute when it appears redundant against explicit
width and height. It is redundant only if the graphic never scales, and the
entire reason to use vector is that it scales.
The symptom arrives later and somewhere else: the icon renders at exactly its authored size and refuses to respond to any CSS sizing you apply, so someone adds a fixed width to the component, and now the icon is locked at 24 pixels everywhere. Nobody traces that back to a build step that ran three weeks earlier. Disable it explicitly, in a checked-in configuration, rather than relying on the current default.
Identifier mangling and everything that referenced it
Optimisers shorten or remove id attributes, because ids inside
an isolated file are usually dead weight. They are not dead weight when
something outside the file, or elsewhere in the document, refers to them.
Four things break this way, and they break silently. A
<use href="#icon-cart"> in a sprite sheet. An
aria-labelledby pointing at a <title> element's
id, which turns a labelled graphic into an unlabelled one. A CSS rule
targeting a path by id to animate it. A gradient or clip path referenced by
url(#grad-1), which renders as black or as nothing at all when the
reference dangles.
If you use <use>, every id in the sheet is load-bearing.
Optimise the individual icons before assembly and leave the assembled sheet
alone, or pin the id-related plugins off. Optimising the sheet with defaults
produces a file that is smaller and renders nothing.
Path merging breaks the thing you animated
Merging multiple paths into one is a real saving and it destroys per-path addressability. If a transition targets one path, if a hover state changes one shape's fill, if a loading spinner animates two arcs independently, merging collapses them into a single element and the animation stops without an error.
The same applies to collapsing groups. A <g> that exists
only to be transformed as a unit looks redundant to a static analyser and is
essential to the script that rotates it.
Precision rounding opens seams
Reducing coordinate precision is the largest single win in most files and
the one that produces the subtlest damage. Two shapes authored to share an
edge at 12.0004 and 11.9996 both round to
12 and stay flush — usually. Round harder, to one decimal
place or to integers, and adjacent fills can separate by a fraction of a
pixel, producing a hairline of background colour along a join that only
appears at some zoom levels and on some displays.
Two decimal places is safe for a 24-unit coordinate system and gives you almost all of the saving. Anything more aggressive needs to be looked at, not reasoned about.
A configuration that is safe to leave running
export default {
multipass: true,
floatPrecision: 2,
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeViewBox: false, // scaling depends on it
cleanupIds: false, // use, aria-labelledby, CSS, url(#...)
mergePaths: false, // per-path animation and hover states
collapseGroups: false, // transformed group wrappers
removeTitle: false, // accessible name
removeDesc: false,
convertShapeToPath: false, // CSS selectors on circle/rect
},
},
},
"removeDimensions", // keep viewBox, drop fixed w/h
"sortAttrs", // stable diffs across runs
],
};
sortAttrs is there for a reason unrelated to size. Without a
stable attribute order, re-running the optimiser reshuffles attributes and
every file shows as changed, which makes the diff useless and trains people to
skim it. A build step whose output is unstable will not be reviewed.
Verify by pixels, not by bytes
The only honest check is to render both versions and compare. It costs a few seconds for a whole icon set and it catches every failure above except the behavioural ones.
#!/usr/bin/env bash
# Render each icon before and after optimisation; fail on any visual delta.
set -euo pipefail
mkdir -p .tmp/before .tmp/after
fail=0
for f in assets/icons/*.svg; do
n=$(basename "$f" .svg)
resvg --width 256 "$f" ".tmp/before/$n.png"
npx svgo --config svgo.config.mjs -i "$f" -o ".tmp/$n.svg" >/dev/null
resvg --width 256 ".tmp/$n.svg" ".tmp/after/$n.png"
d=$(compare -metric AE ".tmp/before/$n.png" ".tmp/after/$n.png" \
null: 2>&1 || true)
if [ "$d" -gt 40 ]; then
echo "visual change in $n: $d pixels differ" >&2
fail=1
fi
done
exit $fail
The tolerance absorbs rasteriser antialiasing noise. Set it once by running the script against an unchanged file and seeing what number a no-op produces, then add a margin. A tolerance chosen by guessing is either useless or permanently red.
What the check cannot catch
Behaviour. A merged path renders identically and no longer animates. A
mangled id renders identically inside its own file and breaks the sprite that
references it. An aria-labelledby pointing at a removed title
renders identically and is now an unlabelled graphic.
Those need the linter from constructing SVG icons in code run after optimisation rather than before, which is the ordering people get backwards. Lint the authored file for authoring mistakes, lint the optimised file for damage. They are different checks with different purposes and it is worth running both.
Where an agent helps and where it does not
An agent is good at the mechanical part: stripping cruft, normalising precision, removing dead definitions, producing the configuration above. Applied to an SVG it authored itself, most of the work is already unnecessary, because authored files do not carry editor metadata — which is a quiet argument for authoring in the first place.
What an agent cannot do is decide whether an id is referenced from somewhere it cannot see, or whether a group is transformed by a script in another file. Those are repository-wide questions, and the safe default is to leave both alone. An optimiser that saves eight percent and never breaks anything is worth more than one that saves thirty and needs a bisect twice a year.
Compression has already taken most of the win
The claim that deserves scrutiny in every optimiser report: the byte reduction it shows you is on the uncompressed file, and you do not serve uncompressed files.
SVG is text, so it goes over the wire gzipped or brotli-compressed like everything else. Much of what an optimiser removes — repeated attribute names, long whitespace runs, predictable numeric patterns — is exactly what a compressor is best at. A 40% reduction in raw bytes routinely becomes something closer to 10% after compression, and occasionally less.
That does not make optimisation worthless. It relocates the benefit: the value is in removing structural cruft, normalising output so diffs are readable, and stripping metadata you did not intend to publish. Those are real. The headline percentage is not the reason to run it, and treating it as the reason leads to cranking settings up until something breaks in pursuit of a saving that mostly does not survive the transfer encoding.
Compare brotli -c file.svg | wc -c before and after
optimisation. That is the number your users experience. If it has barely
moved, your configuration is already conservative enough, and there is nothing
to gain from a more aggressive one.
Where the optimiser belongs in the pipeline
Two placements, and they behave differently. Run it at commit time and the optimised file is what lives in the repository: you review what you ship, the diff is stable, and the authored form is gone. Run it at build time and the repository holds authored source while the deployed artefact is optimised: the source stays readable and editable, and nobody reviews the shipped file.
I prefer build time, for the same reason I prefer any generated artefact to be generated: the authored file is the one people edit, and an optimiser that has already rewritten it makes the next edit harder. The cost is that the transformation is less visible, which the verification script offsets.
What does not work is both, or neither-consistently. An optimiser that runs sometimes produces files in two states, and the diff between an optimised and an unoptimised version of the same icon is total noise.
Rough numbers, so the trade is concrete
On a typical editor-exported icon: raw around 3KB, conservative optimisation around 900 bytes, aggressive around 700. After brotli, roughly 600, 380 and 340 bytes respectively. The move from raw to conservative is worth having. The move from conservative to aggressive is forty bytes and every risk in this article.
On an authored icon of the kind an agent writes, raw is often already under 400 bytes, and optimisation changes almost nothing, because there was no cruft to remove. That is worth knowing before you build a pipeline: if your assets are authored rather than exported, the optimiser is a lint step that occasionally saves a few bytes, not a size intervention.
When not to optimise at all
A small set of authored icons in a repository that also lints them. An asset with complex animation or scripted interaction, where the risk clearly exceeds a few hundred bytes. A sprite sheet whose ids are load-bearing. A file provided by a third party under terms that make modifying it a question.
In each of those, skipping the optimiser is the considered choice and it should be recorded as one — a line in the build config saying why this directory is excluded, so the next person does not helpfully include it.
The rule, compressed
Run a conservative optimiser at build time, with a checked-in configuration, and verify by rendered pixels rather than by the byte counter. Measure the saving after compression, because that is the one your users receive, and stop tightening settings once it stops moving.
The failure this avoids is not a broken build. It is the slow kind: an icon set that renders fine today, one asset of which stopped scaling, one of which lost its accessible name, and one of which has a hairline seam — each introduced by a tool that reported only that it had saved you some bytes.
The framing I would leave you with: an optimiser is a linter that happens to save bytes, not a size tool that happens to modify semantics. Configured as a linter — conservative, checked in, verified, run in one place — it is unambiguously worth having. Configured as a size tool, tuned by watching a percentage go up, it is a slow source of defects that surface weeks later in files nobody connected to the build step that produced them.
Finally, keep the configuration in the repository rather than in a CI snippet or a package script flag. A configuration file is discoverable, it is reviewed when it changes, and it applies identically whether the optimiser is run by the build, by a developer, or by an agent that decided to be helpful. Settings passed on a command line exist in one invocation and are invisible everywhere else, which is how two people end up optimising the same asset to two different standards.
Default optimiser settings remove the viewBox, mangle ids that
<use>, aria-labelledby and CSS depend on, merge
paths your animations target, and round coordinates until flush edges open
seams — and report only the bytes saved. Check in a configuration with
those passes disabled, keep two decimal places, sort attributes so diffs stay
readable, verify by rendering before and after and comparing pixels, and lint
the optimised output separately from the authored source.