Un média en ligne de 8 000 articles migre de system fonts vers une police custom (Inter) pour harmoniser son design system. Résultat deux semaines plus tard : le CLS moyen passe de 0.04 à 0.19, 62% des URLs classées « poor » dans le rapport Core Web Vitals de Search Console, et une baisse de 11% du trafic organique sur les pages catégories. La cause n'est ni un problème de serveur, ni un changement d'algorithme. Juste des fichiers .woff2 mal chargés.
Le chargement de polices est l'angle mort de la performance web. La plupart des développeurs collent un <link> vers Google Fonts et passent à autre chose. Sauf que le navigateur, lui, doit résoudre un DNS, télécharger la feuille CSS, parser les @font-face, puis télécharger les fichiers de polices — le tout avant de pouvoir rendre le texte correctement. Chaque milliseconde de ce pipeline est un vecteur de régression CLS et LCP.
FOUT, FOIT : anatomie du problème
FOIT — Flash of Invisible Text
Le comportement historique de Chrome, Safari et Firefox (jusqu'à récemment) : le navigateur détecte qu'un @font-face est nécessaire pour rendre un élément visible, mais le fichier de police n'est pas encore chargé. Il masque le texte pendant une « block period » — 3 secondes sur Chrome, potentiellement infini sur les anciennes versions de Safari.
Concrètement, l'utilisateur voit un bloc vide là où le titre H1 devrait apparaître. Pour Googlebot, le texte existe dans le DOM — il n'est pas affecté par le FOIT au sens strict. Mais le problème est indirect : si le texte invisible provoque un re-layout une fois la police chargée, le CLS explose.
FOUT — Flash of Unstyled Text
Le navigateur affiche le texte avec la police fallback système (Arial, Times New Roman, etc.), puis swap vers la police custom une fois chargée. C'est le comportement par défaut des navigateurs modernes après la block period, et c'est aussi le comportement explicite quand vous utilisez font-display: swap.
Le FOUT est visuellement désagréable mais fonctionnellement préférable au FOIT pour le SEO : le contenu est lisible immédiatement. Le problème, c'est que le swap provoque un recalcul de layout — les glyphes de la police fallback et de la police custom n'ont pas les mêmes métriques (ascent, descent, line-gap, advance widths). Un titre qui fait 2 lignes en Arial peut passer à 3 lignes en Inter. Ce décalage, c'est du CLS.
Le vrai coût SEO
Le CLS généré par les font swaps est particulièrement sournois pour trois raisons :
- Il se produit tôt dans le cycle de rendu, souvent dans les premières 500ms, pile dans la fenêtre de mesure du CLS selon la spécification des Core Web Vitals.
- Il affecte chaque page — contrairement à une image lazy-loadée mal dimensionnée qui ne concerne que certains templates.
- Il est invisible dans les tests synthétiques rapides (Lighthouse en local avec la police en cache) mais systématique en conditions réelles (field data).
Pour comprendre l'impact global du CLS sur le classement, le sujet est traité en profondeur dans notre article sur le CLS et l'élimination des décalages de layout.
font-display : les 5 valeurs et leurs trade-offs réels
La propriété CSS font-display dans @font-face contrôle le comportement du navigateur pendant le chargement de la police. Voici ce que chaque valeur fait réellement, au-delà de la doc MDN.
/* Stratégie optimale pour la majorité des sites éditoriaux et e-commerce */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var-latin.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap; /* ou optional — voir analyse ci-dessous */
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
auto : le navigateur décide. En pratique, Chrome utilise un comportement proche de block avec un timeout de 3 secondes. Inutilisable en production car non prédictible.
block : block period longue (~3s), puis swap. Maximise le FOIT. À éviter sauf cas très spécifique (icon fonts où un glyphe fallback serait un caractère Unicode visible et incompréhensible).
swap : block period quasi nulle (~100ms), puis swap immédiat. Le texte est visible instantanément en fallback. C'est le choix par défaut recommandé par Google dans ses outils. Le problème : si la police met 800ms à charger sur mobile 3G, le swap provoque un CLS mesurable.
fallback : block period très courte (~100ms), swap period courte (~3s). Si la police n'est pas chargée après ~3s, le navigateur reste sur la fallback pour toute la durée de la navigation sur la page. Bon compromis : limite le CLS aux cas où la police arrive vite.
optional : block period quasi nulle, pas de swap period. Le navigateur utilise la police custom uniquement si elle est déjà disponible (en cache, par exemple). Sinon, fallback pour toute la session de page. Zéro CLS garanti, mais la police custom ne s'affiche qu'à partir de la deuxième visite. C'est le choix optimal pour le CLS, mais les équipes design le refusent souvent.
Le bon choix dépend de votre contexte. Un site SaaS dont les utilisateurs reviennent quotidiennement peut utiliser optional sans problème — après la première visite, la police est en cache. Un site média où 70% du trafic vient du SEO (première visite) a besoin de swap ou fallback avec des ajustements de métriques fallback pour minimiser le décalage.
Font metrics override : éliminer le CLS à la source
Depuis Chrome 87 et Firefox 89, les descripteurs ascent-override, descent-override, line-gap-override et size-adjust permettent de calibrer la police fallback pour qu'elle occupe exactement le même espace que la police custom. Le swap se fait alors sans décalage de layout.
/* Police fallback calibrée pour matcher Inter */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
ascent-override: 90.49%;
descent-override: 22.56%;
line-gap-override: 0%;
size-adjust: 107.64%;
}
/* Police custom */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var-latin.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
/* Usage dans le CSS */
body {
font-family: 'Inter', 'Inter Fallback', sans-serif;
}
Comment calculer les valeurs d'override
Les valeurs d'override dépendent des métriques internes des deux polices (la custom et la fallback). Vous pouvez les extraire manuellement avec des outils comme fontkit en Node.js, mais en pratique deux approches sont plus efficaces :
Next.js @next/font (maintenant next/font) calcule automatiquement les overrides quand vous déclarez une police Google ou locale. Le framework injecte le @font-face avec les bons descripteurs en build time.
Fontaine (de UnJS) est une bibliothèque agnostique qui fait le même travail pour Vite, Nuxt, Astro ou tout bundler compatible :
// nuxt.config.ts — exemple avec Fontaine via le module Nuxt
export default defineNuxtConfig({
modules: ['@nuxtjs/fontaine'],
fontMetrics: {
fonts: [
{
family: 'Inter',
fallbacks: ['Arial'],
// Les overrides sont calculés automatiquement
// à partir du fichier .woff2 fourni
}
]
}
})
Calculateur en ligne : l'outil Font Style Matcher de Monica Dinculescu permet de visualiser le match entre deux polices et d'ajuster les valeurs manuellement.
Edge case : polices variables
Les polices variables (variable fonts) compliquent légèrement le calcul des overrides. Les métriques changent selon le font-weight et le font-stretch actifs. En pratique, les overrides sont calculés pour les métriques du fichier de police à weight 400. Les écarts à d'autres weights sont généralement inférieurs à 1-2px par ligne et ne génèrent pas de CLS mesurable. Mais si votre design utilise un weight 800 sur des titres grands (48px+), vérifiez le CLS sur ces éléments spécifiquement dans Chrome DevTools > Performance > Layout Shifts.
Stratégie de chargement optimale : preload, self-hosting et subsetting
Self-hosting vs CDN tiers
Google Fonts est pratique mais introduit deux requêtes DNS supplémentaires (fonts.googleapis.com puis fonts.gstatic.com), plus une requête CSS, avant même le téléchargement du fichier .woff2. Sur mobile avec une latence élevée, c'est 200-400ms perdues.
Le self-hosting élimine ces requêtes cross-origin. Téléchargez le fichier .woff2 (via google-webfonts-helper ou directement depuis le repo GitHub de la police), placez-le sur votre domaine, et déclarez le @font-face localement.
Bonus : vous contrôlez les headers de cache. Un fichier de police ne change jamais — servez-le avec un Cache-Control: public, max-age=31536000, immutable.
# Nginx — headers optimaux pour les fichiers de polices
location ~* \.(woff2|woff|ttf|otf)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
# Compression déjà intégrée dans woff2, pas besoin de gzip
# Mais activez-la pour woff/ttf si vous les servez en fallback
gzip_types font/ttf application/font-woff;
}
Preload ciblé
Le preload indique au navigateur de démarrer le téléchargement de la police dès le parsing du <head>, sans attendre que le CSS soit parsé et qu'un élément visible en ait besoin.
<head>
<!-- Preload uniquement la ou les polices critiques -->
<link rel="preload"
href="/fonts/inter-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin>
<!-- ATTENTION : crossorigin est OBLIGATOIRE même en self-hosting.
Les fonts sont toujours fetchées en mode CORS par les navigateurs.
Sans cet attribut, le navigateur télécharge le fichier DEUX FOIS :
une fois pour le preload (no-cors), une fois pour le @font-face (cors). -->
</head>
Règle critique : ne preloadez que les polices réellement utilisées above the fold. Preloader 4 variantes (regular, italic, bold, bold italic) alors que le contenu above the fold n'utilise que regular et bold gaspille de la bande passante et peut retarder le LCP. Chaque preload entre en compétition avec les autres ressources critiques (CSS, hero image). Pour approfondir les stratégies d'optimisation LCP, consultez notre article sur le diagnostic et la correction du LCP lent.
Subsetting
Une police Inter complète avec tous les unicode ranges pèse ~300KB en woff2 variable. En latin uniquement (U+0000-00FF + quelques extras), elle descend à ~50KB. Si votre site est en français/anglais, vous n'avez pas besoin des glyphes cyrilliques, grecs ou vietnamiens.
Google Fonts fait ce subsetting automatiquement via unicode-range et des fichiers séparés. En self-hosting, utilisez pyftsubset de la bibliothèque fonttools :
# Installation
pip install fonttools brotli
# Subset latin uniquement, sortie woff2
pyftsubset inter-var.ttf \
--output-file=inter-var-latin.woff2 \
--flavor=woff2 \
--layout-features="kern,liga,calt,ccmp,ss01,cv05" \
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"
# Vérification du poids
ls -lh inter-var-latin.woff2
# Résultat typique : ~48KB vs ~290KB pour le fichier complet
Le paramètre --layout-features est important : il garde les features OpenType utiles (kerning, ligatures) tout en supprimant celles dont vous n'avez pas besoin (small caps, swashes, etc.), ce qui réduit encore la taille du fichier.
Cas concret : migration d'un e-commerce de 12K pages
Contexte
Un e-commerce mode avec 12 000 pages produits, 450 pages catégories, et un trafic organique de 180K sessions/mois. Stack : Nuxt 3 SSR, hébergé sur Vercel. Le design system utilisait system fonts (-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif). L'équipe design impose le passage à « DM Sans » pour toutes les pages.
Déploiement naïf — semaine 1
Le développeur front ajoute un <link> Google Fonts dans le <head> avec display=swap. Pas de preload, pas de subsetting, pas de font metrics override.
Résultats après 7 jours (données CrUX via Search Console) :
- CLS p75 : passe de 0.03 à 0.16 (seuil « poor » = 0.25, seuil « needs improvement » = 0.1)
- LCP p75 : passe de 2.1s à 2.6s (les requêtes cross-origin vers Google Fonts bloquent le rendu)
- 38% des pages catégories tombent sous le seuil « good » des Core Web Vitals
En parallèle, l'onglet Performance de Chrome DevTools montre des Layout Shifts systématiques entre 400ms et 800ms après le First Contentful Paint, corrélés au font swap. L'élément qui shift le plus : le titre H1 produit (police 32px, 1-3 lignes selon la longueur du nom produit).
Correction — semaine 2
L'équipe applique la stratégie complète :
- Self-hosting : téléchargement de DM Sans variable, subset latin, self-hosted sur le CDN Vercel.
- Preload : preload du fichier woff2 principal (weight 400-700) dans le
<head>. - Font metrics override : utilisation du module
@nuxtjs/fontainepour générer automatiquement les overrides d'Arial comme fallback. - font-display: swap maintenu (car 65% du trafic = première visite SEO,
optionalafficherait la fallback pour la majorité des utilisateurs).
Résultats après 14 jours :
- CLS p75 : redescend à 0.04
- LCP p75 : 2.2s (gain de 400ms par rapport au setup Google Fonts cross-origin)
- 97% des URLs repassent en « good » pour les Core Web Vitals
Le gain LCP s'explique principalement par l'élimination des 2 résolutions DNS cross-origin et de la requête CSS intermédiaire. Le fichier woff2 subsettté (42KB) est servi depuis le même domaine, avec un preload qui déclenche le téléchargement dès la première ligne du <head>.
Monitoring continu
Le problème de la performance des polices, c'est qu'elle régresse silencieusement. Un développeur ajoute une nouvelle variante (italic 700), un designer change le font-weight du H1 en production, ou le CDN a un incident qui rallonge le TTFB du fichier woff2. Sans monitoring, ces régressions passent inaperçues pendant des semaines — jusqu'à ce que les données CrUX se dégradent dans Search Console avec 28 jours de retard.
Un outil de monitoring comme SEOGard permet de détecter ces changements dès qu'ils se produisent : ajout ou suppression de preload, modification du font-display, apparition d'un fichier de police inattendu, ou dégradation des métriques CLS sur des URLs spécifiques.
Audit et debugging : la boîte à outils
Chrome DevTools — onglet Network
Filtrez par type « Font » pour voir exactement quels fichiers de police sont téléchargés, leur taille, leur timing, et si le preload a fonctionné (colonne « Initiator » montre <link rel=preload> vs CSS). Vérifiez aussi que le fichier n'est pas téléchargé deux fois — symptôme classique d'un preload sans crossorigin.
Chrome DevTools — onglet Performance
Enregistrez un profil de chargement avec le throttling CPU 4x et le réseau « Fast 3G ». Dans la timeline, cherchez les barres violettes « Layout Shift ». Cliquez sur chaque shift pour voir quel élément DOM a bougé et de combien. Les shifts liés aux polices sont facilement identifiables : ils se produisent après le téléchargement du fichier .woff2 dans la timeline Network, et les éléments affectés sont des nœuds texte.
Lighthouse et PageSpeed Insights
Le diagnostic « Ensure text remains visible during webfont load » vous alerte quand font-display n'est pas défini ou est réglé sur block/auto. Mais Lighthouse ne détecte pas les CLS causés par un mauvais match de métriques entre la fallback et la custom — il faut le vérifier manuellement ou via les field data CrUX.
Screaming Frog
Configurez un crawl custom avec extraction des <link rel="preload" as="font"> et des valeurs font-display dans les @font-face. Sur un site de 12K pages, cela permet de détecter les templates où le preload est manquant ou où un font-display: block a été oublié. Exportez les résultats, croisez-les avec les données CLS de Search Console par groupe de pages pour identifier les templates problématiques.
Web Font Loader (monitoring JavaScript)
Pour un debugging avancé en production, la Font Loading API native du navigateur permet de mesurer précisément quand chaque police est chargée :
// Mesure du temps de chargement de la police en production
// À envoyer à votre outil d'analytics/monitoring RUM
if ('fonts' in document) {
const fontLoadStart = performance.now();
document.fonts.ready.then(() => {
const fontLoadTime = performance.now() - fontLoadStart;
// Log en production via votre solution RUM
console.log(`Toutes les polices chargées en ${fontLoadTime.toFixed(0)}ms`);
// Vérification granulaire
document.fonts.forEach((font) => {
if (font.family === 'DM Sans' && font.status === 'loaded') {
console.log(`DM Sans ${font.weight} ${font.style}: loaded`);
}
});
});
// Détection de font loading failure
document.fonts.onloadingerror = (event) => {
// Alerte monitoring — la police n'a pas pu être chargée
// Cause possible : 404, CORS, certificat expiré sur le CDN
console.error('Font loading error:', event);
};
}
Ce script est particulièrement utile pour détecter les échecs silencieux : un certificat SSL expiré sur un CDN tiers, un WAF qui bloque les requêtes font, ou un déploiement qui a changé le chemin du fichier woff2 sans mettre à jour le @font-face.
Les erreurs les plus fréquentes
Preloader toutes les variantes
Un site qui preload 6 fichiers woff2 (3 weights × 2 styles) pour une police dépense ~250KB de bande passante avant même de commencer à charger le CSS et les images. En pratique, preloadez uniquement le poids utilisé dans le plus grand bloc de texte above the fold — généralement le body text (regular 400) et éventuellement le bold pour les titres.
Oublier unicode-range en self-hosting
Quand vous utilisez Google Fonts, le service découpe automatiquement la police en subsets par unicode range et le navigateur ne télécharge que les subsets nécessaires. En self-hosting avec un seul fichier couvrant tous les ranges, le navigateur télécharge tout. Ajoutez unicode-range dans votre @font-face pour reproduire ce comportement, ou mieux : subssettez le fichier avec pyftsubset comme montré plus haut.
Polices dans des CSS chargés en asynchrone
Si votre @font-face est déclaré dans un fichier CSS chargé via rel="preload" as="style" + onload, le navigateur ne découvre la police qu'au moment où le CSS asynchrone est parsé — souvent trop tard pour éviter un flash visible. Déclarez les @font-face critiques dans un <style> inline dans le <head>, ou dans le CSS critique inliné par votre stratégie de critical CSS.
Ignorer l'impact sur l'accessibilité
Le FOIT n'est pas qu'un problème de CLS. Un texte invisible pendant 3 secondes est inutilisable pour les lecteurs d'écran et les utilisateurs avec des connexions lentes. font-display: swap ou optional sont systématiquement préférables d'un point de vue accessibilité. C'est un argument supplémentaire quand l'équipe design résiste au passage de block à swap.
Ne pas tester en conditions réelles
Le piège classique : tester en Lighthouse avec une connexion locale rapide et la police en cache navigateur. Les métriques sont parfaites. En production, le p75 mobile raconte une autre histoire. Utilisez toujours le mode « Clear storage » de Lighthouse (onglet Application > Clear site data) et testez en throttling réseau « Slow 3G » pour reproduire l'expérience d'un premier visiteur SEO sur mobile.
Pour une vision complète des problématiques de performance qui affectent le SEO, notre guide sur les Core Web Vitals et leur impact réel sur le classement Google couvre l'ensemble des métriques et leurs interactions.
Wrap-up
La stratégie de chargement de polices optimale se résume à : self-hosting, subsetting, preload ciblé du poids principal, font-display: swap avec font metrics override pour neutraliser le CLS. C'est un investissement technique de quelques heures qui protège vos Core Web Vitals sur 100% de vos pages. Le vrai risque, ce n'est pas la mise en place initiale — c'est la régression silencieuse trois mois plus tard, quand un déploiement casse le preload ou ajoute une variante non optimisée. Automatisez la détection de ces changements avec un monitoring continu, et votre budget CLS restera intact.