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);| Delivery | Typical size for 40 icons | Notes |
|---|---|---|
| Full free SVG+JS bundle | ~90 KB gzipped | Every icon in the free set |
| Full free web font, 3 styles | ~120 KB across 3 files | Solid, Regular and Brands |
| Subset web font, 2 styles | ~12 KB | Only the icons in use |
| Inline sprite of 40 icons | ~9 KB | Plus markup, and only the ones referenced |
| Base64 data URI per icon | ~30% larger than the raw SVG | No separate request, no caching nuance |
| Framework component, tree-shaken | ~14 KB | Depends 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));| Budget | Target | Failure mode |
|---|---|---|
| Icon payload, subset font | Under 20 KB | An icon added without re-running the subset |
| Icon payload, SVG sprite | Under 30 KB | Every icon pasted in rather than pruned |
| Icon payload, JS bundle | Under 40 KB gzipped | Importing the whole library |
| Layout shift from icons | Under 0.01 | A font without font-display and no reserved width |
| Icon font requests | One per style used | Three styles loaded for two icons |
| First icon paint | Under 1.5s on a slow 3G profile | An 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.
Related
Font Awesome in React, Vue and Angular Accessibility and performance trade-offs
Last refreshed 2026-09-18.