ich fand eine 20-kb-schrift und habe trotzdem die 2-mb-geschickt
dev · Oct 4, 2026 · 4 min read
meine seite deklariert einen schriftbereich U+0000-00FF — grundlatein, die ersten 256 codepunkte. praktisch bedeutet das akzente, satzzeichen und sonst nicht viel. und doch war die datei dahinter 1.945.520 bytes groß.
im selben ordner lag eine zweite datei, GoogleSansFlex-Latin.woff2, mit 20.076 bytes. dieselbe schriftfamilie. derselbe designer. 1% der größe.
das ist kein rundungsfehler. diese datei war 0,99% der größe derjenigen, die heruntergeladen wurde — also lagen 99% des gewichts meiner textschrift ungenutzt da.
der einfache teil
den dateinamen in der @font-face-regel zu tauschen ist eine änderung von zehn sekunden. die seite funktioniert weiter, der text wird weiter gerendert, und wenn du nie wieder hinschaust, hast du jedem besucher für immer, auf jeder seite, 1,9 mb bandbreite gespart.
das ist der teil, an dem ein vernünftiger mensch aufhört und es ausliefert.
also habe ich zuerst gemessen
bevor ich irgendetwas angefasst habe, habe ich eine winzige glyph-sonde geschrieben und in einem echten browser laufen lassen, denn der größenunterschied ist offensichtlich, aber die nützliche frage ist, ob die kleine datei die zeichen, die die seite benutzt, überhaupt abdeckt. das geht ganz ohne bibliotheken:
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;
};
// ein zeichen, das die schrift nicht hat, fällt zurück und misst sich als
// notdef-kasten: vergleiche gegen U+FFFF, worauf nichts abbildet
const base = width("Probe", "\uFFFF");
const missing = [...text].filter((ch) => width("Probe", ch) === base);
der trick ist die U+FFFF-grundlinie. der notdef-glyph hat eine echte breite, und jedes zeichen, das der schrift fehlt, löst sich zu ihm auf — eine exakte übereinstimmung mit dieser breite ist dein fehlende-glyphen-test.
ich habe ihn über den gesamten bereich laufen lassen, der mich interessiert — groß- und kleinbuchstaben, ziffern, satzzeichen und jeden akzentbuchstaben, den diese seite braucht, um im deutschen korrekt auszusehen: àèéìòùÀÈÉÌÒÙäöüß, dazu €©®™°·….
ergebnis: null fehlende glyphen. das 20-kb-subset deckte alles ab, einschließlich zeichen weit außerhalb des deklarierten bereichs.
dann habe ich das echte ding geprüft, statt der sonde zu vertrauen:
c.font = '50px "Google Sans Flex Local", monospace';
// 264.7 px — nicht die 300 px, die der fallback erzeugt, also ist die echte schrift aktiv
const rendered = width('"Google Sans Flex Local", monospace', "Handgloves");
const fallback = width("monospace", "Handgloves");
die beiden unterscheiden sich, also war das subset wirklich aktiv und nicht stillschweigend auf den fallback zurückgefallen. nach jeder messung, die mir zur verfügung stand, war der tauschkorrekt.
und dann habe ich es zurückgenommen
denn die zahlen waren nicht das ganze problem. zwei dinge, die die messung nicht sehen konnte:
- die formen sind nicht identisch. beide dateien sind aus derselben familie geschnitten, aber die konturen des subsets sind nicht die konturen der variablen schrift bei den standard-achsen-einstellungen. die überschriften meiner seite sind der größte text der seite, und sie waren sichtbar kleiner und heller als das, wogegen ich entworfen hatte.
- die variablen achsen waren weg. die datei, die ich ausgeliefert habe, ist eine variable schrift, und die seite setzt
wghtundwdthüberfont-variation-settings. ein für latein geschnittenes subset trägt diese achsen nicht zwangsläufig.wghtzu verlieren heißt, dass jedesfont-blackim stylesheet stillschweigend auf die statische schnittstärke zurückfällt, die gerade gilt.
eine schriftgrößen-regression in der hero-überschrift sagt dir kein bytezähler. ich habe sie gesehen, sie gefiel mir nicht, und ich habe die 1,9-mb-datei zurückgelegt.
was ich tatsächlich behalten habe
die zahl, die den unterschied gemacht hat, ist nicht die größe. es ist der preload:
<link rel="preload" href="/fonts/GoogleSansFlex-Variable.woff2"
as="font" type="font/woff2" crossorigin />
die seite ist text-zuerst, also liegt diese schrift auf dem kritischen pfad. sie vorzuladen startet den download schon beim parsen des html, statt zu warten, bis das css ankommt und geparst ist — das nimmt eine volle hin- und rückfahrt aus dem ersten paint.
crossorigin ist der teil, den leute vergessen. schriften werden immer im cors-modus geladen, auch bei gleicher herkunft — ein preload ohne dieses attribut wird vom browser verworfen, und die datei wird trotzdem ein zweites mal geholt. man bekommt das schlechteste aus beidem: die preload-kosten und kein nutzen.
das ehrliche fazit
1,9 mb sind zu viel für eine textschrift, und ich weiß, wie ich sie auf 20 kb bringe. die richtige lösung ist nicht, ein latein-subset von hand auszuwählen — es ist, das subset selbst zu bauen mit pyftsubset, die achsenbereiche beizubehalten, die das stylesheet tatsächlich benutzt, und das gerenderte ergebnis vor der auslieferung gegen das original zu prüfen.
bis das existiert, bleibt die 2-mb-datei, und die seite ist ehrlich darüber.
der nützliche teil dieser übung war nicht der tausch. es war die erkenntnis, dass die beiden dateien die ganze zeit im selben ordner gelegen hatten und dass ich keine ahnung hatte, welche der browser tatsächlich herunterlädt. jetzt prüfe ich transferSize bei einem neuladen, was 0 für jede ressource liest, die der browser bereits hält. diese zahl ist der einzige ehrliche leistungsbericht, den ich je benutzt habe.