Refonte typo : quand une variable font lazy-loadée fait plonger le LCP et le ranking
Mercredi 14h. L'équipe design valide la dernière maquette. La refonte typographique est bouclée : exit la classique Inter servie par Google Fonts, place à une variable font custom auto-hébergée. Trois fichiers .woff2, un @font-face propre, un rendu sublime sur Figma. Le site — une marketplace de mobilier avec 4 200 fiches produit et 380 000 sessions organiques mensuelles — déploie le changement jeudi soir. Aucune balise SEO modifiée. Aucune URL touchée. Aucun redirect. Juste une police. Le genre de changement que personne ne monitore.
T+72h — Le signal faible que personne ne voit
Le vendredi qui suit le déploiement, rien ne bouge. Ni dans la Search Console, ni dans GA4. Normal : les données CWV mettent 28 jours à se consolider dans le rapport Chrome UX (CrUX), et la Search Console agrège les métriques sur des fenêtres glissantes.
Le lundi suivant, un développeur frontend remarque un flash de texte au chargement. FOUT — Flash of Unstyled Text. La police system fallback s'affiche pendant une bonne seconde avant que la variable font prenne le relais. Il ouvre un ticket Jira, priorité basse. "Cosmétique."
Le mercredi, T+6 jours, le lead SEO lance son audit hebdomadaire dans PageSpeed Insights. Il teste la homepage. LCP : 3.1 secondes. La semaine précédente, le même test affichait 1.8 secondes. Écart de 1.3 secondes. Il vérifie : même connexion, même machine, même heure. Il relance trois fois. LCP moyen : 3.05s.
Il bascule sur une fiche produit. LCP : 2.9s. Avant : 1.7s. L'écart est constant, autour de 1.2 seconde.
Premier réflexe : vérifier si un script tiers a été ajouté. Le tag manager est identique. Pas de nouveau pixel. Pas de changement CDN. Le TTFB reste stable à 340ms. Le CLS est à 0.04, inchangé.
Deuxième réflexe : ouvrir le rapport Core Web Vitals de la Search Console. Les données terrain (field data) montrent encore les anciens chiffres — elles sont agrégées sur 28 jours. Mais les données lab de Lighthouse sont formelles : le LCP a décroché.
L'équipe commence à chercher un changement d'infrastructure. Quelqu'un vérifie les headers de cache côté Cloudflare. Tout est en ordre. Un autre regarde les logs Nginx. RAS.
C'est le développeur frontend — celui du ticket FOUT — qui fait le lien en standup le jeudi matin. "La nouvelle police. Elle se charge tard." Le lead SEO ouvre Chrome DevTools, onglet Network, filtre sur font. Ce qu'il voit le fait blêmir.
La variable font — 287 Ko en .woff2 — se charge en cascade, déclenchée par le parsing CSS, lui-même lazy-loadé via un <link rel="stylesheet" media="print" onload="this.media='all'">. Le navigateur ne découvre la police qu'après avoir chargé et parsé le CSS critique. La police est invisible pour le preload scanner. Le LCP element — le titre H1 de la fiche produit — attend la font pour être considéré comme "rendu final" par le navigateur.
Le diagnostic initial ("c'est un problème de CDN") était faux. Le problème, c'est un pattern d'optimisation CSS qui a transformé la font en ressource invisible.
Le bug : un pattern d'optimisation qui se retourne contre le LCP
Pour comprendre la régression, il faut retracer la chaîne de chargement complète.
Le pattern CSS "print trick"
L'équipe utilisait un pattern classique pour charger le CSS de manière non bloquante :
<link
rel="stylesheet"
href="/css/main.css"
media="print"
onload="this.media='all'"
/>
<noscript>
<link rel="stylesheet" href="/css/main.css" />
</noscript>
Ce pattern, recommandé par Google il y a quelques années pour réduire le render-blocking CSS, force le navigateur à ignorer la feuille de style au chargement initial (car media="print" ne concerne pas l'écran). Une fois le fichier téléchargé en arrière-plan, l'attribut onload bascule le media sur all, et les styles s'appliquent.
Avec l'ancienne police Google Fonts, ça n'avait aucun impact sur le LCP : la font était chargée via un <link rel="preconnect"> séparé, et Google Fonts injectait sa propre logique de preload.
Mais la refonte a changé la donne. La @font-face de la nouvelle variable font est déclarée dans main.css :
/* main.css */
@font-face {
font-family: 'Mabry Pro VF';
src: url('/fonts/mabry-pro-variable.woff2') format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
body {
font-family: 'Mabry Pro VF', system-ui, sans-serif;
}
La cascade fatale
Voici ce qui se passe du point de vue du navigateur, étape par étape :
- Le HTML se charge. Le parser rencontre le
<link media="print">. - Le navigateur télécharge
main.cssen priorité basse (c'est duprint). - Le fichier CSS arrive. L'event
onloadbascule le media surall. - Le navigateur parse le CSS. Il découvre la
@font-face. - Il lance le téléchargement de
mabry-pro-variable.woff2(287 Ko). - Pendant ce temps, le H1 s'affiche en
system-ui(fallback). C'est le FOUT. - La font arrive. Le navigateur re-rend le H1 avec Mabry Pro VF.
- C'est ce re-rendu qui constitue le LCP final selon l'algorithme Chromium.
Le preload scanner du navigateur — le mécanisme qui scanne le HTML brut pour lancer les téléchargements en avance — ne peut pas voir la font. Elle est déclarée dans un fichier CSS externe, lui-même masqué derrière media="print". Double invisibilité.
Ce que voit le développeur vs ce que mesure Lighthouse
Le développeur voit la page s'afficher "immédiatement" : le fallback system-ui apparaît en moins de 500ms. Visuellement, la page est là. Le FOUT dure une seconde, puis la vraie police s'installe. Pour l'œil humain, c'est un détail.
Mais pour l'algorithme LCP de Chromium, c'est une autre histoire. Le LCP element est le H1. Quand la font change, le navigateur émet un nouveau LCP candidate. Le timestamp final du LCP devient celui du re-rendu avec la font définitive — pas celui du rendu initial en fallback.
La vérification dans Chrome DevTools :
# Dans la console Chrome DevTools, onglet Performance
# Après un enregistrement de chargement de page :
# 1. Chercher l'entrée "Largest Contentful Paint" dans le timeline
# 2. Vérifier le "LCP candidate" — il pointe vers le re-rendu du H1
# Ou via l'API PerformanceObserver en console :
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry.element);
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
Sur la fiche produit testée, la console affichait deux LCP candidates :
- T+480ms : le H1 en
system-ui(fallback) - T+2 940ms : le même H1 re-rendu en
Mabry Pro VF
C'est le second qui compte. 2.94 secondes. Au-dessus du seuil "needs improvement" de 2.5 secondes fixé par Google.
Pourquoi les tests n'ont rien détecté
L'équipe avait un pipeline Lighthouse CI dans GitHub Actions. Mais la configuration utilisait une font system en fallback pour les tests — un mock CSS qui court-circuitait le chargement de la variable font. Le Lighthouse CI passait au vert avec un LCP de 1.1s. En production, le vrai fichier font de 287 Ko changeait tout.
Autre point : les tests étaient exécutés sur un réseau local simulant du 4G rapide (9 Mbps). Sur du 4G réel à 3-4 Mbps — le réseau moyen des utilisateurs mobiles français — le téléchargement de la font prenait 600ms de plus.
L'absence de test sur les vraies conditions réseau a masqué la régression pendant trois semaines complètes. Le temps que les données CrUX basculent de "bon" à "à améliorer", le mal était fait.
L'impact sur le ranking
Les données CrUX ont basculé au jour 21. Le rapport Core Web Vitals de la Search Console est passé au jaune sur 3 800 URLs (le template fiche produit). Quatre jours plus tard, les premières baisses de positions sont apparues dans les SERPs.
Le lead SEO a mesuré :
- −18% de clics organiques sur les fiches produit en 14 jours (de 12 400 clics/jour à 10 200)
- 42 mots-clés passés de la position 3-5 à la position 8-12
- Aucun changement sur les pages catégories (LCP element = image, pas de texte stylé en variable font)
L'ironie : aucune balise title, aucune meta description, aucun canonical, aucun heading n'avait changé. Le contenu était identique, octet pour octet. Seule la performance avait bougé. Et Google l'a sanctionné.
Le fix : preload explicite et restructuration du chargement
Le correctif a été déployé en deux phases.
Phase 1 — Le patch immédiat (jour 0)
Ajout d'un <link rel="preload"> pour la font directement dans le <head>, avant toute feuille de style :
<head>
<!-- Preload de la variable font AVANT le CSS -->
<link
rel="preload"
href="/fonts/mabry-pro-variable.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!-- CSS critique inline -->
<style>
@font-face {
font-family: 'Mabry Pro VF';
src: url('/fonts/mabry-pro-variable.woff2') format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
body {
font-family: 'Mabry Pro VF', system-ui, sans-serif;
}
</style>
<!-- CSS non-critique en async -->
<link
rel="stylesheet"
href="/css/main.css"
media="print"
onload="this.media='all'"
/>
</head>
Trois changements clés :
-
rel="preload": le preload scanner voit la font dès le parsing du HTML. Le téléchargement commence immédiatement, en parallèle de tout le reste. -
@font-faceinline dans le critical CSS : la déclaration de font n'est plus enfermée dans le fichier CSS lazy-loadé. Le navigateur sait qu'il a besoin de cette font dès le premier rendu. -
crossoriginobligatoire : sans cet attribut, le preload et le@font-facefont deux requêtes séparées. Le navigateur traite les fonts comme des requêtes CORS anonymes. Si le preload n'a pascrossorigin, il télécharge la font une première fois (preload, sans CORS), puis la re-télécharge (font-face, avec CORS). Double pénalité.
Phase 2 — Optimisation de la font (jour +2)
La variable font faisait 287 Ko. Après audit avec pyftsubset, l'équipe a créé un subset limité aux glyphes Latin + Latin Extended :
# Subsetting avec pyftsubset (paquet fonttools)
pyftsubset mabry-pro-variable.woff2 \
--output-file=mabry-pro-variable-subset.woff2 \
--flavor=woff2 \
--layout-features='kern,liga,calt,frac,sups,subs' \
--unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD'
Résultat : 287 Ko → 94 Ko. Le téléchargement passe de 420ms à 140ms sur une connexion 4G médiane.
Phase 3 — Pipeline de test corrigé (jour +3)
L'équipe a ajouté une assertion Lighthouse CI sur le LCP avec les vraies fonts :
# lighthouserc.yml
ci:
collect:
settings:
throttling:
cpuSlowdownMultiplier: 4
requestLatencyMs: 150
downloadThroughputKbps: 1600
uploadThroughputKbps: 750
assert:
assertions:
largest-contentful-paint:
- error
- maxNumericValue: 2500
resource-summary:font:count:
- warn
- maxNumericValue: 3
resource-summary:font:size:
- warn
- maxNumericValue: 150000
Le throttling simule désormais du 4G moyen réel. Et le LCP est un critère bloquant du pipeline : au-dessus de 2.5 secondes, le build échoue.
La récupération
Le patch preload a été déployé un jeudi soir. Résultats mesurés dans Lighthouse (données lab) dès le lendemain :
- LCP homepage : 3.1s → 1.6s
- LCP fiche produit : 2.9s → 1.5s
Mais les données terrain (CrUX) ont mis 19 jours à basculer. La Search Console a repassé les URLs en vert au jour 24. Les positions ont commencé à remonter au jour 28.
Au jour 35 après le fix, le trafic organique sur les fiches produit avait récupéré 92% du niveau pré-régression. Les 8% restants se sont résorbés sur les deux semaines suivantes.
Bilan : 5 semaines de régression totale (3 semaines avant détection + 2 semaines de déploiement et récupération). Impact estimé : −52 000 clics organiques perdus sur la période.
Ce type de régression silencieuse — pas de balise modifiée, pas d'URL changée, juste un delta de performance — est le plus difficile à détecter manuellement. C'est aussi le type qui fait le plus de dégâts parce qu'il passe sous tous les radars classiques : les audits de heading, les vérifications de canonicals, les contrôles de redirections. Tout est "vert" sauf la vitesse.
Ce qu'on en retient
Une refonte typographique n'est pas un changement cosmétique. C'est un changement de chaîne de chargement. Chaque ressource qui s'intercale entre le premier octet et le rendu final du LCP element est un risque.
Trois règles à graver :
- Toute font utilisée par le LCP element doit être en
preloaddans le<head>. - Toute
@font-facecritique doit être inline, pas dans un CSS lazy-loadé. - Le pipeline Lighthouse CI doit tester avec les vraies fonts et un throttling réseau réaliste.
Le problème de fond reste la détection. Cette équipe a mis 21 jours à voir la régression. Un outil de monitoring qui compare le LCP réel — mesuré en conditions terrain, page par page, jour après jour — aurait levé l'alerte en 48 heures. C'est exactement ce que fait Seogard sur les métriques de performance liées au ranking. Pas un audit ponctuel. Un suivi continu.
Les régressions les plus coûteuses sont celles qui ne cassent rien de visible. Ni le HTML, ni les metas, ni le sitemap. Juste le temps que met une police à s'afficher. 1.2 seconde. 52 000 clics.