CORS und Font-Preloads – ein Zusammenspiel, das oft für Kopfzerbrechen sorgt. Wenn wir Schriften mit <link rel="preload" as="font">
vorladen, brauchen wir immer auch das crossorigin
-Attribut. Wenn wir es vergessen, kann es zu unerwarteten Problemen führen, wie wir später sehen. Doch selbst wenn wir dieser Regel folgen, gibt es Fallstricke. Doch der Reihe nach:
Was ist CORS?
Vereinfacht gesagt, ist es ein Protokoll, welches das Teilen von Ressourcen unterschiedlichen Ursprungs (Domain, Port, oder Protokoll) regelt. Die Server, von dem Ressourcen geladen werden sollen, deklarieren in HTTP-Headern, von welchen Ursprüngen (Webseiten) diese Ressourcen geladen werden dürfen. Wer mehr über CORS und dessen Ursprung (pun intended) lesen will, den verweise ich auf eine hervorragende Erklärung von Jake Archibald.
Im Rahmen dieses Posts ist wichtig sich zu merken, dass laut CSS Spezifikation Font-Anfragen vom Browser immer mit CORS gemacht werden. Dies gilt übrigens auch für Font-Anfragen vom selben Ursprung (same-origin
). Die Fonts werden dann mit der CORS same-origin
-Policy geladen. Warum das so ist, ist nicht einfach zu beantworten. Aber es ist wie es ist.
Das crossorigin
-Attribut
Preloads werden nur mit CORS gemacht, wenn man das explizit verlangt. Dies tut man über das crossorigin
-Attribut, denn die Existenz des Attributs macht die zugehörige Anfrage zu einer CORS-Anfrage und setzt gleichzeitig den zugehörigen Credentials-Modus. Da Font-Anfragen wie beschrieben laut Spezifikation immer mit CORS gemacht werden müssen und Preloads (aktuell) per default ohne CORS gemacht werden, brauchen wir das Attribut bei Font-Preloads.
Safaris Sonderstellung
Einzig Safari lädt Fonts nicht mit CORS und weicht damit von der Spezifikation ab, was für langanhaltende Diskussionen sorgt. Die Argumente gegen die Umsetzung sind für mich nicht ganz einfach nachzuvollziehen, aber Tatsache ist, dass Safari Fonts nicht im CORS Modus lädt. Probiert es aus.
Dieses Verhalten führt im Zusammenhang mit Font-Preloads zu ernsthaften Problemen, wie der nächste Abschnitt zeigt.
Das vary: Origin
-Dilemma
Manchmal ist es nötig, dass Fonts mit einem vary: Origin
-Header antworten. Etwa wenn das Laden eines Fonts von mehreren Ursprüngen erlaubt sein soll, nicht aber von allen.
Leider lässt sich ein Font-Preload in solchen Fällen nicht realisieren ohne Caching-Probleme zu verursachen. Man kann es nun einfach nicht mehr allen Browsern recht machen.
Wenn wir uns an die Regeln halten und das crossorigin
-Attribut setzen, bereitet Safari uns Probleme. Die Font-Anfrage initiiert vom Preload kommt nun mit einem Origin
-Header. Die Anfrage initiiert vom CSS (ohne CORS) kommt ohne Origin
-Header.
1. Überraschender Weise wird beim ersten Laden einer Seite mit diesem Setup, der Font aus dem Preload-Cache verwendet. Offenbar kann der vary: Origin
-Header hier noch nicht berücksichtigt werden.
2. Wenn der Font aber beim zweiten Laden schon im HTTP/Memory-Cache liegt, kann der Header berücksichtigt werden. Der Font wird nun – initiiert vom CSS – erneut heruntergeladen.
Beim erneuten Laden der Seite wiederholt sich 1.. Der Font wird beim Preload erneut heruntergeladen, da es zu einem Origin
-Mismatch kommt. Danach wird der Font aus dem Preload-Cache wiederverwendet. Beim nächsten Laden wiederholt sich 2., usw.. Bei jeden Laden wird der Font genau einmal vom Server heruntergeladen. Probiert die Demo weiter unten aus, um das Verhalten zu überprüfen.
Dieser Fall ist deutlich schwerwiegender, da er alle Chromium-Browser betrifft und die entsprechenden Fonts bei jedem Refresh der Seite doppelt(!) lädt.
Durch die Preload-Anfrage und die darauf folgende Anfrage aus dem CSS kommt es zu alternierenden Anfragen bzgl. des Origin
-Headers. Jede Anfrage kann nicht auf den HTTP-Cache zurückgreifen und lädt den Font erneut vom Server herunter und speichert die Antwort im Cache. Das Caching wird so komplett ausgehebelt. Bei jedem Seitenaufruf wird der Font doppelt heruntergeladen.
Tragweite des Problems
Folgende Zahlen konnte ich ermitteln:
vary: Origin
-Header antworten.<link rel="preload" as="font">
-Tags haben das crossorigin
-Attribut.Alle Zahlen zusammengeworfen komme ich damit etwa darauf, dass Fall 1 auf etwa einer in 273 Webseiten auftritt. Fall 2 kommt glücklicher Weise deutlich seltener vor (eine in 17.930 Webseiten). Das mag nach wenig klingen, aber auch große Plattformen können betroffen sein – wie das nächste Kapitel zeigt.
Ein Fallbeispiel
Vor kurzem bin ich über ein Problem auf der Bluesky Web-App gestolpert als ich ein spannendes Tool von Brian Louis Ramirez ausprobierte, welches im Rahmen des Web Performance Calendars vorgestellt wurde.
Es stellte sich heraus, dass Fonts ohne crossorigin
-Attribut vorgeladen wurden und die Antwort der Anfragen einen vary: Origin
-Header enthielt. Es kam also genau zum oben beschriebenen Szenario. In diesem konkreten Fall bedeutete das, dass mit jedem Refresh über 1MB extra an Daten vom Server zu Besuchenden transferiert wurden. Und das alles wegen eines nicht gesetzten Attributs. Eins, das zudem noch kontraintuitiv hätte gesetzt werden müssen, da die Fonts vom gleichen Ursprung geladen wurden.
Die Folgen des vergessen Attributs
Zusätzlich zur verschlechterten Performance beim Laden der Fonts hatte der Fehler auch Auswirkungen auf die CO2-Bilanz der Webseite.
- Den Fehler gab es etwa drei Monate von Oktober bis Dezember. In diesem Zeitraum hatte die Webseite 350 Million Besuchende laut similarweb, davon waren schätzungsweise 77% (also 270 Million) mit Chromium-Browsern unterwegs.
- Wenn wir konservativ rechnen und davon ausgehen, dass pro Besuch kein Refresh stattfindet, und 10% erstmalig Besuchende waren, dann wurden durch diesen Fehler insgesamt 256 Terabyte an Daten nutzlos zu den Besuchenden gesendet. Bei Safari Besuchenden wurden ironischer Weise 28 Terabyte durch den Fehler eingespart (minus mal minus 😊).
- Mit der Open-Source-Bibliothek co2.js kommt man mit diesen Zahlen auf 31 Tonnen emittiertes CO2e zusätzlich.
Demo des Problems.
Ich habe hier eine kleine Live-Demo vorbereitet. Probiert es mit verschiedenen Browsern aus. Beim Klick auf einen der Buttons ladet ihr einen iframe
neu. Dieser enthält einen Webfont, der per Preload geladen wird und vom CSS verwendet werden will. Der iframe
enthält Infos über alle HTTP-Requests zu diesem Font. Der Text auf den Buttons bedeutet dabei:
- mit/ohne CORS: Preload des Fonts wird mit/ohne
crossorigin
-Attribut - mit/ohne vary: der geladene Font antwortet mit/ohne
vary: Origin
-Header
Interessant finde ich insbesondere, dass in Chromium-Browsern bei Antworten ohne vary: Origin
-Header der HTTP-Cache nach dem Preload ohne CORS verwendet werden kann – nicht aber der Preload-Cache.
Dass Firefox Font-Preloads ohne crossorigin
-Attribut trotzdem im CORS Modus macht, war eine weitere schöne Überraschung.
Was können Browser tun? Macht es wie Firefox!
Browser sollten bei Preloads per default den Modus des angefragten Datei-Typens übernehmen. Das bedeutete in Chromium, dass Preloads von Fonts automatisch mit CORS geschehen. Ein entsprechender Vorschlag für die HTML Spezifikationen wurde bereits eingereicht. Die Umsetzung würde die potentiellen Probleme bei Chromium beseitigen. Ich hoffe sehr, dass der Vorschlag dieses Mal angenommen wird. Vor 10 Jahren wurde eine ähnlicher Vorschlag noch abgewiesen.
Bei Safari würde die Änderung allerdings keinen Einfluss haben, da hier ein explizit gesetztes crossorigin
-Attribut ignoriert werden müsste. Ich glaube nicht, dass das im Sinne des Vorschlags ist. Stattdessen sollte Safari Font-Anfragen endlich mit CORS tätigen. Damit würden sie das beschriebene Problem umgehen, der Spezifikation folgen und für konsistentes Verhalten in allen Browsern sorgen.
Kurzum: Chromium und Safari sollten sich so verhalten wie es Firefox tut. Font-Anfragen werden mit CORS getätigt und Preloads von Fonts führen unabhängig vom crossorigin
-Attribut zu einem CORS Request. Probiert es oben mit Firefox aus.
-
Die Frage, warum dass
crossorigin
-Attribut beisame-origin
-Font-Preloads notwendig ist, wurde u.a. auf Stackoverflow gestellt und hat eine sehr gute, detaillierte Antwort erhalten. Wie es allerdings überhaupt dazu kam, ist schwer nachzuvollziehen. Wenn ich die W3C-Diskussionen richtig verstehe, so handelt es sich eher um eine Implementierungsentscheidung als um eine bewusste Sicherheitsmaßnahme. - Ein Argument gegen das Umsetzen ist, dass Font-Dienste das CORS Feature eher als DRM-Werkzeug einsetzen wollten, was nicht der ursprünglichen Intention des CORS-Modus entspricht.
-
Browser benutzen maximal einen Platz pro URI im HTTP-Cache. Der
vary
-Header wird nur zur Validierung verwendet: stimmen dievary
-Werte der im Cache gespeicherten und der angefragten Ressource nicht überein, wird der Cache für diese URI neu überschrieben. Das Caching-Verhalten scheint diesbezüglich übrigens nicht einfach verbessert werden zu können und ein entsprechender Fix-Request für denvary: Accept
-Header in Chromium wurde abgewiesen. - Das Tool generiert eine Tonfolge anhand der Anfragen, die durch einen Seitenaufruf entstehen. Jede Anfrage erzeugt einen Ton, der solange anhält, wie der entsprechende Download dauert. Beim Refresh der Bluesky Seite zeigte sich, dass einige Anfragen deutlich länger dauerten als alle anderen.
- Beim erstmaligen Besuch wurden bei Chromium 505KB zusätzlich versendet, beim wiederholten Besuch 1,01MB. Bei Safari wurden Daten eingespart: 505KB bei jedem wiederholten Besuch.
-
Berechnung mit der co2.js (v0.16) Bibliotheksmethode
perVisitTrace(1010*1000, false, {dataReloadRatio: 0.5, firstVisitPercentage: 0.9, returnVisitPercentage: 0.1});
ergibt das 0.116g pro Seitenaufruf. Mulitpliziert mit den 270 Million Chromium Besuchenden sind das 31,32 Tonnen. Mit analoger Rechnung kommt man bei Safari auf 3,3 Tonnen Einsparung. -
Die Abfrage aus dem HTTP-Cache klappt allerdings nur, wenn die Antwort des no-CORS Requests mit
Access-Control-Allow-Origin
-Header kommt. - Tatsächlich wurde das Thema schon beim Verfassen der Spezifikation für Preload-Links vor knapp 10 Jahren in einem Github-Issue diskutiert. Es ist nicht ganz einfach der Diskussion zu folgen. Aber das Feedback zur Frage, ob man das Attribut implizit setzen sollte, lautete mehrstimmig: nein. Die einzige Begründung, die ich der Diskussion entnehmen kann, ist, dass eine Änderung eines Aufrufs von no-CORS auf CORS zu “magisch” sei.
- https://drafts.csswg.org/css-fonts/#font-fetching-requirements (last opened: 18.02.2025)
- https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#requests_with_credentials (last opened: 18.02.2025)
- https://github.com/whatwg/html/issues/10891 (last opened: 18.02.2025) https://github.com/whatwg/html/issues/10891 (last opened: 18.02.2025)
- https://bugs.webkit.org/show_bug.cgi?id=86817 (last opened: 18.02.2025)
- https://fetch.spec.whatwg.org/#cors-protocol-and-http-caches (last opened: 18.02.2025)
- https://almanac.httparchive.org/en/2024/fonts#fig-15 (last opened: 18.02.2025)
- https://gist.github.com/ko-jordan/4adc2c7c57d149695b635018ba81f05e (last opened: 18.02.2025)
- https://bsky.app/profile/tunetheweb.com/post/3ld2lp3c2i22i (last opened: 18.02.2025)
- https://calendar.perfplanet.com/2024/ (last opened: 18.02.2025)
- https://www.similarweb.com/website/bsky.app/#ranking (last opened: 18.02.2025)
- https://gs.statcounter.com/ (last opened: 18.02.2025)
- https://stackoverflow.com/questions/70183153/why-do-we-need-the-crossorigin-attribute-when-preloading-font-files (last opened: 18.02.2025)
- https://github.com/w3c/preload/issues/32 (last opened: 18.02.2025) https://github.com/w3c/preload/issues/32 (last opened: 18.02.2025)
- https://jakearchibald.com/2014/browser-cache-vary-broken/ (last opened: 18.02.2025)
- https://www.mnot.net/blog/2017/03/16/browser-caching (last opened: 18.02.2025)
- https://issues.chromium.org/issues/40876300 (last opened: 18.02.2025)
- https://bsky.app/profile/screenspan.net/post/3lcfir6csj22s (last opened: 18.02.2025)
- https://developers.thegreenwebfoundation.org/co2js/overview/ (last opened: 18.02.2025)