Build-time optimisation, subsets and bundle budgets

Prune unused icons, generate a font subset or a sprite, measure transferred bytes, and avoid the layout shift that icon fonts are known for.

Finding out what you actually use

// A build script that scans the source tree for icon classes and produces the
// exact list to ship. Run it in CI and fail the build when it changes.
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

const SOURCE_EXT = /\.(html|jsx|tsx|js|ts|vue|svelte|mdx|astro)$/;
const ICON_CLASS = /\bfa-([a-z0-9-]+)\b/g;

// Names that are style or utility classes, not icons.
const STYLE_CLASSES = new Set([
  'fa-solid', 'fa-regular', 'fa-brands', 'fa-duotone', 'fa-sharp',
  'fa-fw', 'fa-spin', 'fa-pulse', 'fa-beat', 'fa-fade', 'fa-bounce',
  'fa-shake', 'fa-flip', 'fa-rotate-90', 'fa-rotate-180', 'fa-rotate-270',
  'fa-flip-horizontal', 'fa-flip-vertical', 'fa-flip-both',
  'fa-ul', 'fa-li', 'fa-layers', 'fa-layers-text', 'fa-layers-counter',
  'fa-inverse', 'fa-stack', 'fa-stack-1x', 'fa-stack-2x'
]);

export async function collectIcons(dir) {
  const used = new Set();

  async function walk(current) {
    for (const entry of await readdir(current, { withFileTypes: true })) {
      const path = join(current, entry.name);
      if (entry.isDirectory()) { await walk(path); continue; }
      if (!SOURCE_EXT.test(entry.name)) continue;

      const source = await readFile(path, 'utf8');
      for (const [, name] of source.matchAll(ICON_CLASS)) {
        if (STYLE_CLASSES.has('fa-' + name)) continue;
        if (/^(rotate|flip|stack|layers|shrink|grow|left|right|up|down)-/.test(name)) continue;
        used.add(name);
      }
    }
  }

  await walk(dir);
  return [...used].sort();
}

const icons = await collectIcons('src');
await writeFile('build/icons.json', JSON.stringify(icons, null, 2));
console.log('icons used:', icons.length);
DeliveryTypical size for 40 iconsNotes
Full free SVG+JS bundle~90 KB gzippedEvery icon in the free set
Full free web font, 3 styles~120 KB across 3 filesSolid, Regular and Brands
Subset web font, 2 styles~12 KBOnly the icons in use
Inline sprite of 40 icons~9 KBPlus markup, and only the ones referenced
Base64 data URI per icon~30% larger than the raw SVGNo separate request, no caching nuance
Framework component, tree-shaken~14 KBDepends on the bundler's dead-code elimination
💡
Subsetting a web font is the single biggest payload win available for a Font Awesome site, and it is also the change most likely to cause a regression: an icon added later is simply missing. Generate the subset from the same script that scans the source, run it in CI, and fail on a mismatch rather than trusting a manual list.

Generating a subset

# The subsetting tool ships with the Font Awesome Pro desktop licence;
# for the free sets, any font subsetter works on the woff2 files.

# Using pyftsubset (part of fonttools) on the free Solid font:
pip install fonttools brotli

# Build a unicode list from the icon definitions:
# each icon's unicode value is a private-use code point such as f07a.
node -e "
  const icons = require('./build/icons.json');
  const map = require('./node_modules/@fortawesome/free-solid-svg-icons/metadata/icons.json');
  const codes = icons.map(n => map[n]?.unicode).filter(Boolean);
  console.log(codes.join(','));
" > build/unicodes.txt

pyftsubset node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2 \
  --unicodes-file=build/unicodes.txt \
  --flavor=woff2 \
  --layout-features='' \
  --desubroutinize \
  --output-file=public/fonts/fa-solid-subset.woff2

ls -l public/fonts/fa-solid-subset.woff2
/* The subset needs its own @font-face, pointing at the new file.
   Use a versioned filename so you can cache it forever. */
@font-face {
  font-family: "FA Subset Solid";
  font-style: normal;
  font-weight: 900;
  font-display: block;
  src: url("/fonts/fa-solid-subset.v3.woff2") format("woff2");
}

/* Then override the family Font Awesome's classes use, in one rule. */
.fa-solid::before,
.fas::before {
  font-family: "FA Subset Solid" !important;
  /* Only the icons in the subset exist. A missing one renders as a box. */
}
// Generating an SVG sprite instead: no font, no FOUT, only what is used.
import { readFile, writeFile } from 'node:fs/promises';

const metadata = JSON.parse(
  await readFile('node_modules/@fortawesome/free-solid-svg-icons/metadata/icons.json', 'utf8')
);
const used = JSON.parse(await readFile('build/icons.json', 'utf8'));

const symbols = [];

for (const name of used) {
  const entry = metadata[name];
  if (!entry) { console.warn('unknown icon:', name); continue; }

  const [width, height, , , pathData] = entry.svg.raw || [];
  const path = Array.isArray(pathData) ? pathData.join(' ') : pathData;
  const viewBox = `0 0 ${entry.svg.width} ${entry.svg.height}`;
  const full = /viewBox="([^"]+)"/.exec(entry.svg.raw)?.[1] ?? viewBox;

  symbols.push(
    `  <symbol id="fa-${name}" viewBox="${full}"><path d="${path}"/></symbol>`
  );
}

const sprite = [
  '<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">',
  ...symbols,
  '</svg>',
  ''
].join('\n');

await writeFile('public/icons/fa-sprite.svg', sprite, 'utf8');
console.log('sprite symbols:', symbols.length);

Layout shift and bundle budgets

<!-- Icon fonts cause layout shift: before the font loads, the fallback
     glyph has a different width. Three fixes, best first. -->

<!-- 1. Reserve the width. The SVG route does this naturally. -->
<svg class="icon" width="16" height="16" viewBox="0 0 512 512" aria-hidden="true">
  <use href="/icons/fa-sprite.svg#fa-bell"></use>
</svg>

<!-- 2. Give the icon a fixed width with CSS so the box never changes. -->
<style>
  .icon-slot {
    display: inline-block;
    inline-size: 1em;
    block-size: 1em;
    text-align: center;
  }
</style>
<span class="icon-slot"><i class="fa-solid fa-bell" aria-hidden="true"></i></span>

<!-- 3. Preload the font file so the shift window is as short as possible. -->
<link rel="preload" href="/fonts/fa-solid-subset.v3.woff2" as="font"
      type="font/woff2" crossorigin>
// Measuring the real cost, in a browser, with the transferred bytes rather
// than the file sizes.
const resources = performance.getEntriesByType('resource')
  .filter((entry) => /awesome|fa-solid|fa-brands|fa-regular|sprite/i.test(entry.name));

let total = 0;
for (const entry of resources) {
  const transfer = entry.transferSize || entry.encodedBodySize || 0;
  total += transfer;
  console.log(
    entry.name.split('/').pop().padEnd(30),
    (transfer / 1024).toFixed(1) + ' KB',
    entry.duration.toFixed(0) + ' ms'
  );
}
console.log('total', (total / 1024).toFixed(1), 'KB');

// Layout shift: the LayoutShift entries tell you whether icons moved things.
const shifts = await new Promise((resolve) => {
  const list = [];
  const observer = new PerformanceObserver((entries) => {
    for (const entry of entries.getEntries()) {
      if (!entry.hadRecentInput) list.push(entry);
    }
  });
  observer.observe({ type: 'layout-shift', buffered: true });
  setTimeout(() => { observer.disconnect(); resolve(list); }, 3000);
});
console.log('layout shift score', shifts.reduce((sum, e) => sum + e.value, 0).toFixed(4));
BudgetTargetFailure mode
Icon payload, subset fontUnder 20 KBAn icon added without re-running the subset
Icon payload, SVG spriteUnder 30 KBEvery icon pasted in rather than pruned
Icon payload, JS bundleUnder 40 KB gzippedImporting the whole library
Layout shift from iconsUnder 0.01A font without font-display and no reserved width
Icon font requestsOne per style usedThree styles loaded for two icons
First icon paintUnder 1.5s on a slow 3G profileAn unsubset font behind a blocking script

A workable CI check: run the icon scanner, compare the result to a committed list, and fail when they differ. That single guard prevents the most common Font Awesome regression — a new icon silently missing because nobody regenerated the subset — and it costs about twenty lines.

FAQ

How much does subsetting actually save?
For a site using around 40 icons in one style, a subset Solid font is roughly 12 KB against 40-50 KB for the full file. Across three styles the saving is larger, because you are no longer shipping three multi-hundred-icon fonts to render a few dozen glyphs.
Is the sprite always better than the font?
For payload and for layout stability, usually yes. The font wins when the icons are used in places where markup cannot be injected — an email template, a pseudo-element, a printed stylesheet — or when the set is large and changes often.

Font Awesome in React, Vue and Angular Accessibility and performance trade-offs

Last refreshed 2026-09-18.