On a posé un lundi matin un diagnostic qui revient tous les six mois. Un e-commerce refondu en Next.js, un LCP à 3,9 secondes sur mobile d’entrée de gamme, et un bundle JS unique de 520 ko envoyé aussi bien au Chrome 98 d’un Nokia G11 qu’au Safari 17 d’un MacBook M3. Le responsive design réglait les colonnes, les marges, les tailles de police. Il ne réglait pas le coût CPU du rendu. Le coupable n’était pas le poids du JS, c’était l’absence de segmentation au moment du rendu serveur.
La réponse classique, c’est de tout déporter côté client : charger un script qui détecte le device, puis adapter le DOM. Ça repousse le problème d’une centaine de millisecondes, et ça introduit un INP supplémentaire quand l’utilisateur tape avant que la page ne se soit réajustée. La réponse qui tient la route, c’est de brancher la détection de profil là où le HTML est assemblé. Sur le serveur.
Le mythe du rendu unique : quand le même HTML tue vos Core Web Vitals sur mobile bas de gamme
J’ai déjà détaillé dans le dossier sur l’optimisation des Core Web Vitals pourquoi réduire un LCP ne se joue pas seulement sur des images optimisées. Sur un device où le CPU est au tiers de la puissance d’un flagship, les librairies de visualisation lourdes, les animations CSS complexes ou un carrousel de fiches produit exécuté au montage deviennent des bombes à retardement. Servir le même HTML à tout le monde, c’est transformer Chrome Lite en antimatière pour le TTI.
Côté serveur, on peut déjà trancher simplement : extraire la chaîne User-Agent dans le handler HTTP, comparer à une liste de tokens « low-end » (SM-G960F, Moto G4, Redmi, Android Go) et désactiver l’hydratation des composants non critiques avant que le flux HTML ne démarre. Le gain n’est pas théorique. Sur un catalogue de 12 000 références que l’on a suivi en régie, le TTI est passé de 4,8 secondes à 2,1 secondes sur un panel de mobiles d’entrée de gamme, juste en supprimant le préchargement de trois bundles de visualisation conditionnelle. La partie responsive, elle, n’a pas bougé d’un pixel.
Ce qui bloque en pratique, c’est la peur du cloaking et l’idée que l’User-Agent est un champ trop bruité. Les deux se corrigent avec une règle de décision explicite et un test de non-régression côté indexation.
Client Hints versus User-Agent : l’arbitrage que personne n’explique côté serveur
On lit partout que les User-Agent Client Hints remplacent la chaîne User-Agent. C’est vrai pour le navigateur qui demande des entêtes granulaires, faux pour la première requête HTTP d’une session froide. Les high-entropy hints comme Sec-CH-UA-Model ou Sec-CH-UA-Arch ne sont envoyés qu’après un Accept-CH explicite côté serveur ou une requête préalable. En d’autres termes, au premier chargement, vous n’avez pas l’information pour décider.
La mécanique serveur qui doit alléger le LCP immédiatement n’a que l’User-Agent sous la main. Après quelques millisecondes, le navigateur renvoie les headers complémentaires pour enrichir le profil, ce qui permet d’ajuster des détails fins sur les prochaines navigations. Les deux ne s’opposent pas : on parsifie l’User-Agent pour la décision de première peinture, et on lit le Sec-CH-UA-Memory ou le Save-Data dans les middlewares suivants pour affiner les ressources différées.
Quand on écrit ce code, on finit souvent par passer d’un IDE à un assistant intégré pour tester rapidement les regex. Un outil comme Cursor ou Claude Code (on a comparé les deux dans Claude Code vs Cursor IDE) accélère le prototypage du parsing, mais ne remplace pas le débat d’architecture : quoi exclure, sur quel signal, avec quel filet de sécurité.
Le piège du Vary: User-Agent : pourquoi votre CDN va vous haïr
Armer un Vary: User-Agent sans filtrage crée une entrée de cache par chaîne User-Agent unique. Un CDN conçu pour quelques variantes par URL se retrouve avec des milliers d’objets, un taux d’éviction qui monte en flèche et un cache hit ratio qui s’effondre. La parade tient en une règle : regrouper les agents en quatre ou cinq familles (desktop moderne, mobile haut de gamme, mobile bas de gamme, Googlebot, autres) et ne varier que sur cette clé réduite.
Googlebot, ce faux Nexus 5X sous Chrome : comment lui servir la version optimisée sans cloaking
Googlebot se présente avec une chaîne User-Agent typée Android 6 x Nexus 5X et un Chrome relativement récent. Le robot exécute le JavaScript, applique le CSS, mais n’a pas la puissance CPU d’un smartphone contemporain. Si on lui sert le bundle lourd « desktop moderne », on ralentit le rendu et on augmente le risque de dépassement du budget de crawl. À l’inverse, lui servir une page allégée en retirant des blocs de contenu visibles relève du cloaking.
La bonne approche : lui attribuer le même profil que nos devices « bas de gamme » en matière de JavaScript, mais ne jamais masquer un élément du DOM que l’utilisateur verrait. On retire des animations lourdes, des carrousels avec IntersectionObserver dégradé, mais on garde chaque lien, chaque prix, chaque texte. On vérifie ensuite dans la Search Console que l’URL canonique inspectée restitue bien le même contenu sémantique.
15 lignes de middleware Next.js en prod : comment on a divisé le TTI par deux sur un catalogue e-commerce
Voici le cœur de ce qu’on a déployé en production. Dans un middleware Edge, on intercepte l’User-Agent et on positionne un header interne x-device-tier avec trois valeurs possibles : low, high, bot. Ce header est consommé par les Server Components du catalogue.
// middleware.ts (simplifié)
import { NextRequest, NextResponse } from 'next/server'
const LOW_DEVICES = /Moto|SM-G960|Android Go|KFKAWI|Redmi (Go|A[12])/
export function middleware(request: NextRequest) {
const ua = request.headers.get('user-agent') || ''
const tier = /Googlebot/.test(ua) ? 'bot' : LOW_DEVICES.test(ua) ? 'low' : 'high'
const response = NextResponse.next()
response.headers.set('x-device-tier', tier)
return response
}
Dans le layout, on lit x-device-tier et on conditionne le chargement des bundles lourds :
// layout.tsx
import { headers } from 'next/headers'
import dynamic from 'next/dynamic'
const HeavyGallery = dynamic(() => import('./HeavyGallery'), { ssr: true })
const HeavyReviews = dynamic(() => import('./HeavyReviews'), { ssr: true })
export default function RootLayout({ children }) {
const tier = headers().get('x-device-tier')
const enableHeavy = tier === 'high'
// ...
}
Sur notre échantillon de pages produits, le poids du JavaScript transféré est passé de 430 ko à 180 ko pour le palier low. Le TTI a répondu immédiatement : divisé par deux sur les mobiles de test. Le tout sans toucher au rendu desktop. Si le contexte client a besoin de savoir sur quel palier il travaille, un store comme Zustand initialisé depuis le flux serveur suffit à propager la valeur sans casser le SSR.
Au-delà du device : exploiter Save-Data et le budget CPU dans l’en-tête HTTP
Le client peut aussi signaler une préférence explicite d’économie via le header Save-Data: on. Ce signal traverse les proxies et ne dépend pas d’une détection de chaîne. Couplé à un Sec-CH-UA-Memory qui renseigne sur la RAM disponible, on peut construire une décision encore plus fine pour les navigateurs qui les émettent. La logique de palette ne change pas : on sélectionne la version de composant la plus légère quand l’un de ces signaux passe au vert. Sur un site à fort trafic en Afrique de l’Ouest, nous avons observé 12 % de sessions avec ce flag activé. Le gain en TTI y était immédiat, sans compromis fonctionnel.
Questions fréquentes
Peut-on cumuler User-Agent et Client Hints sans pénaliser le cache ?
Oui, à condition de ne pas faire varier la clé de cache sur les high-entropy hints. On les lit au vol dans le middleware, on ajuste une réponse secondaire (comme un JSON) et on laisse le cache se baser sur une clé stable de famille d’agents. L’impact cache reste alors maîtrisé.
La détection côté serveur fonctionne-t-elle avec les CDN edge comme Cloudflare Workers ?
Absolument. Il suffit d’exécuter le middleware en amont de l’origine, dans la worker edge, pour éviter de solliciter le serveur sur chaque purge. La décision de version s’applique avant le cache et sert la bonne variante directement depuis le CDN.
Risque-t-on le cloaking si le site est accessible en JavaScript chargé paressee ?
Non, tant que le contenu réel de la page (texte, liens, données structurées) est identique pour Googlebot et pour l’utilisateur. On retire seulement le JavaScript non essentiel, jamais un élément de contenu visible. Vérifier l’URL inspectée dans la Search Console confirme l’absence de différence structurante.