i found a 20 kb font, shipped the 2 mb one anyway
dev · Oct 4, 2026 · 4 min read
my site declares a font range of U+0000-00FF — basic latin, the first 256 code points. in practice that means accents, punctuation and not much else. and yet the file behind it was 1,945,520 bytes.
in the same folder there was a second file, GoogleSansFlex-Latin.woff2, weighing 20,076 bytes. same typeface. same designer. 1% of the size.
that is not a rounding error. that file was 0.99% of the size of the one being downloaded, so 99% of the weight of my text font was sitting there unused.
the easy part
swapping the filename in the @font-face rule is a ten-second edit. the page still works, the text still renders, and if you never look at it again you have saved 1.9 mb of bandwidth for every visitor, forever, on every page.
this is the part where a reasonable person stops and ships it.
so i measured it first
before touching anything i wrote a tiny glyph probe and ran it in a real browser, because the size difference is obvious but the useful question is whether the small file actually covers the characters the site uses. you can do it with no libraries at all:
const load = (url) => new Promise((resolve) => {
const f = new FontFace("Probe", `url(${url})`, { weight: "100 1000" });
f.load().then(() => resolve("ok")).catch((e) => resolve("fail " + e.message));
document.fonts.add(f);
});
await load("/fonts/GoogleSansFlex-Latin.woff2");
await document.fonts.ready;
const c = document.createElement("canvas").getContext("2d");
const width = (family, ch) => {
c.font = `50px "${family}"`;
return c.measureText(ch).width;
};
// a character the font does not have falls back, so it measures as the
// notdef box: compare against U+FFFF, which nothing maps to
const base = width("Probe", "\uFFFF");
const missing = [...text].filter((ch) => width("Probe", ch) === base);
the trick is the U+FFFF baseline. the notdef glyph has a real width, and any character the font lacks resolves to it, so an exact match against that width is your missing-glyph test.
i ran it over the whole range i care about — upper and lower case, digits, punctuation, and every accented letter this site needs to look correct in italian: àèéìòùÀÈÉÌÒÙäöüßñç, plus €£¥©®™°·….
result: zero missing glyphs. the 20 kb subset covered everything, including characters well outside the declared range.
then i checked the real thing rather than trusting the probe:
c.font = '50px "Google Sans Flex Local", monospace';
// 264.7 px — not the 300 px the fallback produces, so the real font is live
const rendered = width('"Google Sans Flex Local", monospace', "Handgloves");
const fallback = width("monospace", "Handgloves");
the two differ, so the subset was genuinely active and not silently falling back. by every measurement available to me, the swap was correct.
and then i reverted it
because the numbers were not the whole problem. two things the measurement could not see:
- the shapes are not identical. both files are cut from the same family, but the subset's outlines are not the variable font's outlines at the default axis settings. my page's headings are the largest text on the site, and they were visibly smaller and lighter than what i had designed against.
- the variable axes were gone. the file i shipped is a variable font, and the site sets
wghtandwdththroughfont-variation-settings. a subset cut for latin does not necessarily carry those axes. losingwghtmeans everyfont-blackin the stylesheet silently falls back to whatever the static weight happens to be.
a font-size regression on the hero heading is not something a byte counter is going to tell you about. i saw it, i did not like it, and i put the 1.9 mb file back.
what i actually kept
the number that made the difference is not the size. it is the preload:
<link rel="preload" href="/fonts/GoogleSansFlex-Variable.woff2"
as="font" type="font/woff2" crossorigin />
the site is text-first, so that font is on the critical path. preloading it starts the download during html parsing instead of waiting for the css to arrive and be parsed, which removes a full round trip from the first paint.
crossorigin is the part people forget. fonts are always fetched in cors mode, even same-origin, so a preload without that attribute is discarded by the browser and the file gets fetched a second time anyway. you get the worst of both: the preload cost and no benefit.
the honest conclusion
1.9 mb is too much for a text font and i know how to make it 20 kb. the correct fix is not to hand-pick a latin subset — it is to build the subset myself with pyftsubset, keeping the axis ranges the stylesheet actually uses, and to check the rendered result against the original before shipping.
until that exists, the 2 mb file stays and the site is honest about it.
the useful part of this exercise was not the swap. it was noticing that the two files had been sitting in the same folder the whole time, and that i had no idea which one the browser was actually downloading. now i check transferSize on a reload, which reads 0 for every asset the browser already holds. that number is the only honest performance report i have ever used.