Un mois après la mise en ligne de Nuxt, le site tenait la charge. Restait une question plus difficile : est-ce qu’il était agréable à utiliser sur un téléphone ?
Les premières semaines de données ont répondu sans ambiguïté. Sur mobile, le tunnel perdait des clients aux étapes livraison et paiement. Rien n’était cassé : les commandes passaient, les tests passaient. Mais quand on reprenait le parcours sur un iPhone 12, on voyait ce que les tableaux de bord ne disent pas. Le bouton pour valider le panier n’apparaissait qu’après avoir fait défiler la page. Sur l’étape de paiement, la case des conditions générales et le bouton de confirmation étaient enterrés sous les sections déjà remplies.
Cet article raconte deux chantiers menés en parallèle à partir de mars 2026 : réduire les frictions du checkout mobile, et améliorer nos Core Web Vitals. Le premier se joue au pixel, le second à la milliseconde, et les deux ont la même règle : mesurer sur un vrai téléphone avant de croire quoi que ce soit.
Sept quick wins, une contrainte
On s’est imposé une contrainte forte : uniquement du template et du CSS, aucun changement côté backend, et rien qui casse le design existant sur desktop. Tout passe par les préfixes responsive de Tailwind (max-lg: et lg:) pour isoler le mobile.
On a listé les irritants par ordre d’impact attendu, puis on les a traités dans cet ordre :
| # | Quick win | Ce qui change |
|---|---|---|
| 1 | CTA panier visible | Le bouton « Valider mon panier » apparaît sous les articles, et se fixe en bas d’écran tant qu’on n’a pas fait défiler jusqu’à lui |
| 2 | CTA de paiement fixe | Case CGV et bouton de confirmation dans un pied de page fixe, sauf pour Apple Pay qui a son propre bouton natif |
| 3 | Espacements | Les gaps entre sections passent de 32 à 16 px sur mobile |
| 4 | Sections repliées | Les étapes déjà remplies tiennent sur une ligne avec un lien « modifier » |
| 5 | Titres | Moins de marge au-dessus du titre de chaque étape |
| 6 | Défilement imposé | Suppression d’un scroll automatique vers l’adresse de facturation qui désorientait |
| 7 | Récapitulatif | Miniatures des articles dans le résumé mobile, avec un « +2 » quand il y en a plus de trois |
Le quick win numéro 2 est celui qui a demandé le plus de soin. Un pied de page fixe sur mobile, c’est simple jusqu’à ce qu’on le confronte aux moyens de paiement : la carte bancaire, Alma et PayPal passent par notre bouton, Apple Pay par le sien. Le footer doit donc apparaître ou disparaître selon la méthode sélectionnée, et la validation des CGV doit afficher son erreur dans le footer lui-même, là où le client regarde.
La recette a fait remonter ce qu’aucune maquette n’aurait montré : des textes encore trop petits sur iPhone 12 alors qu’ils passaient sur un 13 Pro, un lien « modifier » qui menait au mode de livraison au lieu de l’adresse, des informations redemandées après une création de compte. Tout a été corrigé avant la mise en production, le 2 avril.
Ces quick wins s’inscrivent dans une série d’ajustements. En mars, les logos de paiement avaient déjà été regroupés sur une seule ligne, et le mode de livraison avait gagné une étape dédiée, avec un vrai bouton pour continuer depuis la carte des points relais. Fin avril, un dernier chantier a fait remonter le récapitulatif du panier en haut du checkout mobile, sous forme d’accordéon dans la page plutôt qu’en overlay. Les miniatures y passent de l’état replié à l’état déplié grâce à l’API View Transitions : le navigateur interpole lui-même les positions, sans librairie d’animation.
Le compteur de livraison offerte
Dans la même logique de réassurance, le mini-panier affiche depuis juin une barre de progression vers la livraison offerte.
C’est le genre de composant qui paraît trivial et ne l’est pas. Le montant à atteindre se calcule sur le sous-total après remises, pas avant. Les cartes cadeaux et les produits virtuels en sont exclus. Le seuil n’est pas codé en dur : il vient de la configuration de chaque storefront dans Magento, ce qui permet à l’équipe e-commerce de le changer sans déploiement. Sur les marchés opérés par Global-e, dont la livraison suit d’autres règles, la barre est masquée. Et le message est annoncé aux lecteurs d’écran grâce à aria-live.
L’INP, ou comment notre site était lent sans l’être
Côté performance, le signal d’alarme venait des audits : l’INP (Interaction to Next Paint) était mauvais partout. 396 ms sur l’accueil britannique, 543 ms sur une fiche produit, plus de 1 000 ms sur certaines listes produits, pour un seuil « bon » fixé à 200 ms.
Première surprise : sur nos postes, impossible de reproduire. Avec un PerformanceObserver et les traces de Chrome DevTools, nos mesures plafonnaient sous 100 ms. L’écart venait du terrain : des téléphones moins puissants, un thread principal déjà occupé par les scripts de tracking, et des interactions qui faisaient trop de travail avant de rendre la main au navigateur.
L’INP ne mesure pas la durée totale du travail déclenché par un tap. Il mesure le délai jusqu’au prochain affichage. Tout le travail qui n’est pas nécessaire à cet affichage peut donc attendre.
On a appliqué ce principe partout où un utilisateur interagit :
- Céder la main avant le travail secondaire. Un clic sur une tuile produit déclenche la navigation tout de suite, et l’événement de tracking part dans un
setTimeoutjuste après. Même logique pour les filtres et l’ajout au panier : la mise à jour visuelle d’abord, le reste ensuite. - Limiter les gestionnaires de scroll à une exécution par frame. Le header qui se masque, le mode sombre de la navigation au-dessus du hero, le bouton « Filtrer » qui disparaît : tous passent par un utilitaire minuscule, et tous les écouteurs de scroll sont déclarés
passive. - Éviter les layouts forcés. Un
getComputedStyledans le gestionnaire du sélecteur de taille a été remplacé par un simple test de position dans le viewport. - Rendre moins.
content-visibility: autosur les tuiles de la grille produits, containment CSS sur le footer, panneau de filtres et onglets de la fiche produit chargés à la demande, watchers profonds supprimés. - Répondre avant le serveur. Le cœur de la wishlist se remplit immédiatement et revient en arrière seulement en cas d’erreur.
export function rafThrottle<T extends (...args: any[]) => void>(fn: T): T & { cancel: () => void } {
let rafId: number | null = null
const throttled = (...args: Parameters<T>) => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
fn(...args)
rafId = null
})
}
throttled.cancel = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
return throttled as T & { cancel: () => void }
}
Une idée n’a pas survécu à la recette : l’interface optimiste sur la suppression d’un article du panier. Supprimer vite plusieurs lignes, ou supprimer un produit juste après l’avoir ajouté, faisait apparaître un message d’erreur et un panier qui ne se vidait pas. On a retiré cette optimisation : sur un panier, la justesse passe avant la sensation de vitesse.
La règle « scroll throttlé par requestAnimationFrame et écouteurs passifs » a aussi été ajoutée aux consignes de développement du projet, celles que lisent les développeurs comme les agents qui codent avec eux. Une correction qui n’est pas écrite quelque part finit toujours par être défaite.
Le tracking, premier consommateur du thread principal
Les scripts tiers pesaient environ 300 Ko et 150 ms de CPU sur chaque page. Le plus lourd était le chargeur GTM : 133 Ko et entre 229 et 290 ms de CPU. La plateforme de consentement, elle, est une obligation légale et doit se charger tôt.
On a différé le chargeur GTM jusqu’à la fin de l’hydratation de Nuxt. Le dataLayer est initialisé immédiatement, pour que les événements des premières secondes ne soient pas perdus, mais le script lui-même attend que l’application soit interactive :
window.dataLayer = window.dataLayer || []
window.dataLayer.push({ 'event': 'gtm.js', 'gtm.start': new Date().getTime() })
useScript({ src: `${source}?${params}` }, { trigger: 'onNuxtReady' })
Un enseignement au passage : en mesure de laboratoire sur mobile, le plus gros élément affiché sur notre page d’accueil n’est souvent ni une image ni un titre, mais le texte du bandeau de consentement. Le LCP devient alors le temps que met un script tiers à s’afficher. Il faut regarder quel élément est mesuré avant d’optimiser le mauvais.
LCP : dire au navigateur ce qui compte
Sur les pages où le LCP est une image, le navigateur la découvrait tard et la traitait comme les autres. Sur une liste produits, la bannière de catégorie mettait plus de deux secondes à s’afficher une fois téléchargée.
Trois corrections ont suffi pour l’essentiel :
fetchpriority="high"sur l’image LCP de chaque gabarit (bannière de catégorie, premier slide du hero, première image produit), et jamais deloading="lazy"sur ces images-là ;- des
preconnectvers les domaines appelés dès le premier rendu ; - des tailles d’images enfin justes : les largeurs servies par imgix ont été centralisées dans un jeu de constantes, les attributs
sizescorrigés, et chaque composant demande l’image dont il a besoin, pas l’original.
En parallèle, toutes les slices du CMS et les composants lourds du panier (zoom produit, recommandations, alertes) sont passés en chargement différé.
La vidéo du hero, ou l’histoire de trois formats
La vidéo est le cas d’école de cette année. Le hero de la page d’accueil est une boucle muette de quelques secondes. À l’époque de l’ancien lecteur vidéo, elle démarrait instantanément.
En mars, le site a appris à lire du HLS, avec hls.js pour les navigateurs qui ne le lisent pas nativement. En juillet, les vidéos ont quitté l’ancien hébergeur pour être servies en HLS depuis un CDN vidéo. Et là, régression : à l’arrivée sur la page d’accueil, le visiteur voyait une image figée pendant environ deux secondes avant que la lecture ne démarre.
Le CDN n’y était pour rien : il répondait en une cinquantaine de millisecondes. Le retard venait entièrement de la chaîne de démarrage côté client.
L’élément <video> n’existait pas dans le HTML rendu par le serveur : il apparaissait dans le onMounted du composant. Aucun octet de vidéo n’était demandé avant que le bundle soit téléchargé, exécuté et hydraté. Venaient ensuite 107 Ko de hls.js, puis trois allers-retours séquentiels (le manifeste principal, le manifeste de la variante, un premier segment de 611 Ko) avant la première image. Sur grand écran, l’adaptation de débit montait en plus jusqu’à la meilleure qualité : environ 6 Mo téléchargés pour une boucle de sept secondes.
Autre découverte, purement éditoriale celle-là : le poster était capturé à la quatrième seconde de la vidéo, sur un autre plan que la première image. Même avec un démarrage instantané, la bascule du poster à la lecture aurait ressemblé à un saut.
On a fait le choix le plus simple : abandonner le HLS pour des MP4 progressifs. Nos vidéos sont des fonds décoratifs courts et muets, pas des contenus regardés pendant des minutes : l’adaptation de débit ne servait à rien. Chaque vidéo est désormais encodée en deux rendus MP4, un petit et un grand, à qualité égale à l’ancienne échelle de débits vérifiée au SSIM. Pour le hero de sept secondes sur desktop, on passe d’environ 6,1 Mo à 2,6 Mo.
Restait à faire démarrer la vidéo sans attendre Vue, tout en servant un seul HTML pour tous les appareils, puisque le CDN met la page en cache. La solution : rendre la balise <video> côté serveur sans src, avec les deux sources en attributs data-, et la faire suivre d’un script inline de quelques lignes qui choisit la bonne pendant le parsing du HTML.
{
const video = document.currentScript.previousElementSibling
if (!video.checkVisibility || video.checkVisibility()) {
const mobile = matchMedia('(max-width: 767px)').matches
const poster = mobile ? video.dataset.posterMobile : video.dataset.posterDesktop
if (poster) video.poster = poster
video.src = mobile ? video.dataset.videoMobile : video.dataset.videoDesktop
}
}
Les accolades gardent les const dans un bloc, pour pouvoir avoir plusieurs vidéos sur la même page sans variables globales. Le test checkVisibility évite de télécharger la version masquée en CSS quand un gabarit contient une variante mobile et une variante desktop. Le poster est préchargé dans le <head> avec fetchpriority="high", et Vue ne touche jamais aux attributs src et poster : il se contente de changer de source si la fenêtre passe d’un format à l’autre.
En recette, la première requête vidéo partait 0,26 seconde après le début de la navigation, avec une réponse 206 Partial Content : le navigateur ne télécharge que ce qu’il joue. Le tout est en production depuis le 15 septembre, et 145 vidéos existantes ont été réencodées le lendemain.
Ce qu’on a appris
- Le tunnel se teste sur un vrai téléphone, idéalement un modèle un peu ancien. La moitié des retours de recette n’apparaissait que sur iPhone 12.
- Un quick win bien choisi vaut une refonte. Sept corrections de template et de CSS, sans une ligne de backend, ont changé l’expérience du tunnel mobile.
- Les mesures de laboratoire et de terrain ne racontent pas la même histoire. Notre INP était bon sur nos machines et mauvais chez nos clients. Il faut les deux, et savoir lequel croire pour quelle décision.
- Pour l’INP, la question n’est pas « combien de travail ? » mais « qu’est-ce qui doit être fait avant le prochain affichage ? ». Le reste peut attendre une frame.
- Le tracking est un invité, pas le propriétaire du thread principal. Le différer jusqu’à ce que l’application soit interactive n’enlève aucun événement.
- Le format le plus simple est souvent le bon. Le HLS est fait pour de longues vidéos regardées. Pour une boucle de sept secondes, un MP4 progressif démarre plus vite, pèse moins lourd et supprime une dépendance.
- Tout ce qui peut démarrer pendant le parsing du HTML ne doit pas attendre l’hydratation. Quelques lignes de script inline battent n’importe quel composant.