Icons rarely show up in a performance budget and regularly account for a surprising share of it. The delivery method matters far more than which set you choose, because the sets are all roughly the same size per glyph — a typical outline icon is 300 to 800 bytes of SVG path data before compression.
Putting the <svg> element directly in the markup means zero additional requests and the icon paints with the first paint. It inherits currentColor, so it follows text colour and dark mode for free, and it can be styled and animated by CSS. The cost is duplication: the same icon in twenty rows is twenty copies in the HTML, which inflates the document and the DOM node count. Fine for a handful of chrome icons; wrong for a data table.
<svg width="0" height="0" style="position:absolute">
<symbol id="i-check" viewBox="0 0 24 24"><path d="..."/></symbol>
</svg>
<svg class="icon" aria-hidden="true"><use href="#i-check"/></svg>
One definition, referenced any number of times. A sprite of 40 icons lands somewhere around 8–20 KB uncompressed and considerably less after Brotli, and each usage costs a few bytes of markup. It keeps currentColor inheritance and CSS styling. If the sprite is an external file rather than inlined, it is a single cacheable request — note that cross-document <use href="sprite.svg#id"> works in modern browsers but must be same-origin.
A full icon font is a single file, often 40–100 KB, and you ship every glyph whether you use six or six hundred. Beyond size, icon fonts have specific problems: they are render-blocking web fonts, so icons appear late or as blank boxes; they are subject to font substitution, which is why a failed load produces squares or, worse, unrelated characters; they anti-alias as text, which shifts alignment by fractions of a pixel; and glyphs mapped into the Private Use Area are announced by some screen readers as garbage characters. The one genuine advantage — a single request — is matched by a sprite sheet without any of the downsides.
Libraries such as Lucide, Heroicons, Phosphor and Tabler ship per-icon React, Vue or Svelte components. The right pattern is a named import from the per-icon path, so tree shaking keeps only what you use:
// good - bundler keeps two icons
import { Check, X } from 'lucide-react';
// bad - some setups pull the whole index
import * as Icons from 'lucide-react';
The failure mode is a barrel import or a dynamic icon lookup by name, which defeats tree shaking and silently adds hundreds of kilobytes. Check your bundle analyser after adding an icon library, not before — this is one of the most common sources of unexplained bundle growth.
Run SVGO over icon files. Default settings routinely cut 20–50 percent by removing editor metadata, comments, redundant groups and excess path precision. Two configuration points matter: keep the viewBox (SVGO can strip it, which breaks scaling), and disable ID mangling if you reference IDs from a sprite. Strip hard-coded fill attributes so currentColor can take over.
Decorative icon next to a text label: aria-hidden="true" and focusable="false". Icon that is the only content of a control: give the control an accessible name with aria-label, or include visually hidden text. An icon-only button with neither is invisible to screen readers and to voice control, and it is the most common accessibility defect in icon usage.
Fewer than about ten icons on a static page: inline. A component framework with a bundler: per-icon imports, verified in the bundle analyser. Anything else, especially server-rendered pages with repeated icons: an SVG sprite. Icon fonts only if you are maintaining something that already uses one and the migration is not worth it yet.