Skip to content

Vaynerov Technologies

Nous ne nous contentons pas de développer — nous façonnons chaque ligne de code et chaque pixel comme par enchantement.

Tous les articlesAnalyses techniques

Accélérer Safari mobile : deux bugs, un seul symptôme

Le signalement tenait en quatre mots : interminable, parfois ne charge jamais. C'étaient en réalité deux bugs sans rapport portant la même plainte — l'un dans la façon dont les pages étaient livrées, l'autre dans ce qu'elles faisaient au téléphone une fois arrivées. Voici toute la campagne, y compris la décision que nous avons inversée vingt-trois jours après l'avoir prise.

Vaynerov TechnologiesLe studio
Publié 15 min de lecture
Sur cette page
  1. Toutes les routes localisées étaient dynamiques
  2. Mettre en cache la base, pas la page
  3. Le même levier, à vingt-trois jours d'intervalle
  4. Quatre-vingt-dix animations que personne ne regardait
  5. Tout iPhone est un appareil de bas de gamme
  6. LazyMotion, à la deuxième tentative
  7. Le dernier ulp

Tout signalement de performance venu d'une personne réelle arrive compressé. Le nôtre disait que le site était interminable sur un iPhone et que parfois il ne chargeait pas du tout. Cette phrase ne contient aucun diagnostic, et la façon la plus rapide de gâcher une semaine est de la traiter comme un seul bug — de se mettre à supprimer des animations parce que la page semble lourde, ou à ajouter des en-têtes de cache parce que la page semble lente. Le travail de performance sur Safari mobile ne converge que si l'on coupe le symptôme en deux : combien de temps le téléphone attend des octets, et ce que le téléphone fait une fois qu'il les a.

Nous avons mesuré les deux, trouvé une cause distincte pour chacun, et corrigé le tout en deux commits le même jour. La moitié livraison était un bug structurel dans notre arbre de routes Next.js, qui rendait dynamique chaque page localisée. La moitié runtime était une accumulation — quelque quatre-vingt-dix animations infinies, une scène WebGL qui s'armait sur du matériel qu'on n'aurait jamais dû solliciter, et le cœur d'animation de framer-motion embarqué dans le bundle initial de la route d'accueil.

250–950 ms → 3–8 ms
TTFB de l'accueil
1.12 MB → 468 KB
HTML de l'accueil
7 → 345
routes prérendues
−91 KB
JS initial de l'accueil

Le résultat sur deux fronts, mesuré le 26 juillet 2026 sur les commits livraison et runtime.

Toutes les routes localisées étaient dynamiques

Le chiffre de livraison était laid avant même que le réseau entre en scène. Le TTFB de l'accueil tournait à 250–950 ms en local, avec dix à vingt requêtes Supabase non mises en cache par affichage. Sur un portable, en loopback, avec la base chaude. Quoi qu'ait vécu le téléphone, c'était cela plus tout ce qu'ajoute un aller-retour cellulaire.

La cause n'était pas les requêtes. C'est qu'aucune page n'avait le droit d'être statique. next-intl n'avait jamais été activé pour le rendu statique et — le point qui comptait vraiment — notre layout racine résolvait la locale active depuis l'état de la requête, en appelant getLocale(). Une lecture au moment de la requête dans un layout contamine toutes les routes en dessous. Next.js en concluait correctement que rien sous ce layout ne pouvait être prérendu : chaque route localisée était donc rendue par requête, servie en no-store, et ne devenait jamais cacheable par le CDN. L'éventail d'appels CMS s'exécutait alors à chaque affichage, faute d'un endroit où le mettre en cache.

Le correctif n'était pas un en-tête de cache. C'était la suppression de app/layout.tsx.

La nouvelle API next/root-params de Next existe exactement pour ce type de problème, et elle ne s'applique pas ici : [locale] n'est pas un paramètre racine tant qu'un layout se trouve au-dessus. Le seul moyen de faire du segment de locale le sommet de l'arbre est de n'avoir rien au-dessus — ce qui signifie renoncer entièrement au layout racine unique et partagé. Nous l'avons fait. Il n'y a plus de app/layout.tsx dans le dépôt. À sa place, quatre layouts racines, un par famille de routes, chacun rendant un <RootShell> partagé qui possède le balisage <html> et <body> et tout ce qui est global : [locale], plus (admin), (admin-auth) et pitch, ces trois derniers fixant locale="en" en dur parce que rien dans une console d'administration n'a besoin d'une table de traduction. global-error.tsx garde son propre <html>, comme il se doit.

Avant : un layout racine lit la locale depuis l'état de la requête, et tout l'arbre en dessous passe en dynamique. Après : quatre layouts racines partagent un composant plutôt qu'un parent, et l'arbre de locales devient statique/ISR.

L'autre moitié de l'activation se fait page par page : setRequestLocale doit être appelé dans chaque composant serveur qui rend une page localisée, et nous l'appelons désormais dans 34 fichiers. C'est un invariant franchement mauvais — il est invisible, il est silencieux quand on le viole, et une page qui l'oublie ne casse pas : elle redevient simplement dynamique, en emportant son TTFB. Nous avons écrit la règle dans les commentaires des fichiers depuis lesquels une nouvelle page a le plus de chances d'être copiée, et nous l'écrivons ici aussi : toute nouvelle page doit répéter setRequestLocale.

Une route est restée dynamique à dessein. La feuille de route interactive lit useSearchParams sans frontière Suspense, ce qui l'épingle en rendu dynamique ; c'est de toute façon une surface noindex, et forcer les choses aurait voulu dire restructurer une page dont personne ne se plaignait. L'épingler explicitement valait mieux que la laisser accidentelle.

Mettre en cache la base, pas la page

Le prérendu règle le chemin de requête, mais notre contenu est éditable : le texte des sections, les métadonnées de page et le contenu des lignes viennent tous de Supabase via une petite couche CMS. Une matrice de build à six locales qui frappe la base une fois par requête et par page échangerait une requête lente contre un build lent. Les trois chemins de lecture sont donc passés derrière unstable_cachegetSectionRow sous ["cms-section"], getRowsForLocale sous ["cms-rows"], getPageMetaRow sous ["cms-page-meta"], tous avec { revalidate: 300, tags: ["cms-content"] }. Un rendu de page, et le build complet à six locales, frappent désormais Supabase une fois par requête distincte et par tranche de cinq minutes, au lieu d'une fois par requête HTTP.

Un détail de cette phrase porte tout et se perd facilement dans un refactor : le CMS utilise un client Supabase public, sans cookies. Appeler cookies() à l'intérieur d'unstable_cache lève une exception dans Next 15+, si bien qu'un client qui lit la session ne peut pas être mis en cache du tout. Nous le savons parce que la même erreur avait déjà tué en silence nos surcharges de traduction en base de données quelques mois plus tôt, derrière un catch nu. Les éditeurs n'attendent pas non plus les cinq minutes : les actions d'écriture de l'administration associent revalidatePath à revalidateTag("cms-content"), et les surcharges i18n portent leur propre étiquette "i18n-overrides".

Les chiffres de livraison ont bougé d'un bloc. Le build est passé de 7 routes prérendues à 345, sur 497 pages statiques. La page d'accueil se sert avec x-nextjs-cache: HIT et un TTFB dans la fourchette 3–8 ms — des chiffres de CDN, puisque c'est désormais un artefact de CDN. Le HTML lui-même est tombé de 1,12 Mo à 468 ko, mais pas entièrement grâce au prérendu, ce qui nous amène à la décision inversée.

Le même levier, à vingt-trois jours d'intervalle

Le 3 juillet, nous avons activé inlineCss de Next. Le raisonnement était sain et les preuves réelles : notre feuille de style pesait 38,6 ko, c'était la dernière requête bloquant le rendu, et elle coûtait environ 530 ms de FCP mobile. L'inliner retirait un aller-retour du chemin critique. Nous avons resserré browserslist dans le même changement — Chrome, Edge et Firefox 111+, Safari et iOS 16.4+ — parce qu'un socle moderne est ce qui rend une petite feuille de style petite.

Le 26 juillet, nous l'avons désactivé. Deux choses avaient changé. La feuille de style avait grossi jusqu'à environ 320 ko bruts (une quarantaine de kilo-octets en gzip) ; et un CSS inliné voyage à l'intérieur du HTML de chaque navigation, sans aucun moyen pour le navigateur de le mettre en cache d'une page à l'autre. Quand chaque page était un rendu dynamique, le marché était honnête — le HTML était de toute façon non cacheable, donc le CSS ne perdait rien à le rejoindre. Une fois les routes prérendues et mises en cache par le CDN, l'arithmétique s'est inversée : une feuille de style cacheable, récupérée une fois, bat 320 ko relivrés dans chaque document.

Le même levier, la conclusion inverse, à vingt-trois jours d'intervalle — et les deux décisions étaient justes dans leur contexte.

Nous gardons ce couple en tête chaque fois que quelqu'un nous récite une règle de performance. inlineCss n'est ni bon ni mauvais. C'est un pari sur le rapport entre la taille de votre feuille de style et la cacheabilité de votre HTML, et ce rapport, nous l'avons changé nous-mêmes.

Quatre-vingt-dix animations que personne ne regardait

La moitié runtime n'était pas une erreur ; c'était trois mois de petites erreurs. Des animations CSS infinies, des couches de fusion sur toute la page, une scène WebGL, et le cœur de framer-motion chargé d'emblée dans le bundle initial — chacune défendable au moment où elle est arrivée, collectivement un fondoir à téléphone. La mesure la plus claire : le temps d'arriver au CTA final de la page d'accueil, environ quatre-vingt-dix animations infinies tournent simultanément. Le compositeur se moque bien que quatre-vingts d'entre elles soient loin hors écran.

Le garde-fou est délibérément bête. SectionViewportGate est un composant client qui observe chaque #main-content > section avec une marge de racine de 160 px et bascule un attribut data-offscreen ; le CSS fait le reste avec animation-play-state. Les bascules d'attribut se produisent hors de React à dessein — faire passer quatre-vingt-dix rappels d'observateur par des mises à jour d'état aurait coûté plus cher que les animations que nous cherchions à mettre en pause.

À côté, nous avons attrapé content-visibility: auto, qui laisse le navigateur sauter mise en page et peinture pour les sections jusqu'où il n'a pas encore déroulé. Ce changement est parti avec un bug qui vaut d'être raconté.

/* nth-of-type, not nth-child. Three JSON-LD <script> children
   precede the sections inside <main>. */
section:nth-of-type(n + 3) {
  content-visibility: auto;
  contain-intrinsic-size: auto 800px;
}

Une pseudo-classe, et toute une classe de CLS très déroutants. Le hero ne doit jamais correspondre à cette règle.

Écrit nth-child(n+3), le sélecteur compte tous les enfants de <main> — et trois balises <script> JSON-LD viennent en premier. Le décalage s'est déplacé de trois et la règle a atterri sur chaque section, hero compris. content-visibility implique contain: size, si bien que le hero en 100svh s'est d'abord disposé à sa hauteur de substitution de 800 px avant de se corriger un instant plus tard : une refonte de mise en page au chargement et un décalage visuel sur le tout premier écran, produits par un changement dont le but entier était de rendre ce premier écran moins coûteux. nth-of-type ne compte que les éléments <section>. Un mot, et le hero est de nouveau hors de portée.

Tout iPhone est un appareil de bas de gamme

Nos surfaces WebGL sont derrière un détecteur de palier de qualité. detectTier lit quatre signaux — pointer: coarse, hardwareConcurrency ?? 4, deviceMemory ?? 4, et la plus petite dimension de l'écran. Un appareil est un téléphone s'il a un pointeur grossier et une dimension minimale sous 700 px, et un téléphone n'est promu au palier moyen qu'avec au moins six cœurs et au moins six gigaoctets de mémoire déclarée.

PalierComment un appareil y arrivePlafond de DPR
basTout téléphone qui échoue à la promotion 6 cœurs / 6 Go — c'est-à-dire tout iPhone1.25
moyenLes ordinateurs de bureau par défaut, les téléphones promus, et tout rendu serveur1.5
hautPointeur fin avec les cœurs et la mémoire pour l'assumer2

La table des paliers, et la raison pour laquelle le défaut en SSR est « moyen » : le serveur ne peut pas savoir, et moyen est la supposition honnête.

Safari n'expose aucun deviceMemory. Le défaut ?? 4 échoue donc inconditionnellement au seuil ≥ 6, et tout iPhone atterrit dans le palier bas. Nous avons passé une heure à traiter cela comme un bug de détection avant de conclure que c'était la bonne réponse obtenue par accident : Safari iOS a le plus faible plafond de contextes WebGL de tous les navigateurs que nous supportons, et c'est le plus prompt à tuer un onglet sous pression mémoire. La scène sort donc désormais avant de se monter quand tier === "low", ce qui veut dire que le chunk de la scène n'est même jamais récupéré. Le contexte WebGL le moins cher est celui qu'on renonce à créer.

Le reste du régime runtime est une liste de petites amputations, chacune retirant du travail dont les appareils tactiles ne pouvaient de toute façon pas profiter :

  • Le champ de code du hero se rend statique au tactile, sans la moindre boucle d'images. Il n'y a pas de curseur à poursuivre, et la lentille itinérante coûtait un redessin de canvas plein écran à 60 ips sur le premier écran du téléphone. Son chemin de redimensionnement saute aussi la reconstruction fillText d'environ 2 600 appels, parce que sur iOS le repli de la barre d'URL compte comme un redimensionnement.
  • La surcouche code-lens est réservée à pointer: fine. C'est un canvas fixe en mix-blend-mode — il taxait chaque image de défilement sur téléphone pour produire un effet que le tactile ne peut pas déclencher.
  • Les orbes de la section réalisations ont échangé filter: blur(28px) contre des dégradés radiaux à trois arrêts précalculés. Elles animent leur échelle, si bien que la gaussienne se rejouait à chaque image ; un dégradé qui a la même allure ne coûte rien à remettre à l'échelle. will-change est maintenant réservé aux pointeurs capables de survol, et le tactile aplatit preserve-3d.
  • Les vidéos ont été réencodées en 960×540, passant de 11,2 Mo à 5,1 Mo. Elles ont été renommées en *-2.mp4 pour honorer notre contrat de nom de fichier à cache immuable — les assets sont servis en max-age=31536000, immutable, donc un fichier modifié exige un nom modifié, et en échange les visites suivantes économisent une centaine de mégaoctets.
La livraison, avant et après. Les barres de TTFB sont la mesure en local dans les deux cas — les chiffres du téléphone étaient pires des deux côtés du changement.

LazyMotion, à la deuxième tentative

Le poste le plus long de la campagne a commencé par un rapport de bundle accusant 63 ko de JavaScript inutilisé. Nous sommes allés voir, et le chunk était react-dom lui-même, ce qui ne se taille pas. Le vrai gras, c'était framer-motion : importer motion où que ce soit tire le cœur d'animation d'emblée, et notre page d'accueil l'importait dans la plupart de ses sections.

La tentative du 3 juillet a enveloppé chaque section dans son propre LazyMotion avec strict activé, faisant passer le transfert d'accueil de 284 ko à 269 ko. Le mode strict est ce qui rend l'économie réelle — il interdit les composants motion.* bruts, si bien qu'un seul import égaré ne peut pas ramener le cœur en douce. Il a aussi des dents : nous avons dû supprimer l'animate() impératif de framer dans notre hook de nombres animés et le réécrire en petit easeOutQuart sur requestAnimationFrame, parce que ce seul import suffisait à remettre le cœur d'animation sur le graphe critique du LCP. Une variable de boucle nommée m dans le composant d'orbite gravitas a dû être renommée, parce qu'elle masquait l'espace de noms m de framer.

Puis tout a été annulé. Le revert est un revert propre et complet, avec un corps de commit auto-généré et sans raison énoncée, ce qui fait que la chose la plus honnête que nous puissions dire est que nous ne savons pas ce qui avait cassé. Nous avons des opinions ; nous n'avons pas de preuves. Cet écart est sa propre leçon d'hygiène de commits, et c'est la raison pour laquelle le retour a pris une autre forme.

// One shared provider. The feature bundle is loaded asynchronously,
// so it streams in after first paint instead of blocking it.
const loadDomAnimation = () =>
  import("framer-motion").then((m) => m.domAnimation);

<LazyMotion features={loadDomAnimation} strict>

La forme relivrée : un unique MotionProvider avec un chargeur de fonctionnalités asynchrone, et le mode strict qui garde chaque consommateur honnête.

La version du 26 juillet a remplacé les enveloppes par section par un unique MotionProvider partagé, et elle n'est passée qu'après audit à la main de chaque consommateur de la route d'accueil. Il y en a trois : la vitrine des jeux, le rail du Labo, et l'enveloppe d'inclinaison des cartes. La règle de la maison est désormais écrite — les sections d'accueil utilisent m.* sous le provider, jamais motion.* — et le mode strict l'applique à l'exécution plutôt qu'en relecture de code.

Un piège mérite d'être nommé parce qu'il est invisible en relecture. CardTilt conserve un type d'élément constant : il conditionne son comportement au niveau du style et n'échange jamais un div contre un m.div. Changer le type d'élément côté client remonterait tout le sous-arbre après l'hydratation — ce qui, pour une carte contenant une scène canvas, revient à démonter et reconstruire précisément la chose qu'on cherchait à rendre moins coûteuse. Conditionner par le style est plus laid, et correct.

Le JavaScript initial de l'accueil a fini à 1 365 ko bruts, contre 1 456 ko, sans un seul chunk portant le cœur d'animation.

Le dernier ulp

L'artefact le plus étrange de la campagne n'avait rien à voir avec la vitesse. L'une de nos affiches de jeu — celle de riftline — calcule ses coordonnées SVG par trigonométrie, et React 19 compare les attributs rendus côté serveur à ceux rendus côté client pendant l'hydratation. La libm de Node et celle de Chromium divergent sur la dernière unité à la dernière place. Les deux chaînes diffèrent d'un chiffre qu'aucun humain ne verra jamais, React s'en aperçoit, et vous obtenez un avertissement d'hydratation sur une image fixe. Le correctif consiste à quantifier les coordonnées avant qu'elles ne deviennent des attributs, ce qui est une autre façon de dire : si deux implémentations doivent s'accorder sur un nombre, ne leur demandez pas de s'accorder sur tout le nombre.

Les deux commits sont passés avec tsc, eslint et le build de production au vert, ainsi que les 27 tests de bout en bout préexistants. Ce dernier contrôle compte plus qu'il n'en a l'air : presque chaque changement ici retire du travail, et retirer du travail est la façon la plus simple de retirer une fonctionnalité par accident.

Ce que nous dirions à quelqu'un qui entame la même campagne, c'est que la séparation est toute la méthode. Les bugs de livraison et les bugs de runtime produisent des plaintes identiques et ne partagent presque aucun correctif ; ici, la moitié livraison tenait à une seule décision structurelle sur l'endroit où vit la locale dans l'arbre de routes, et la moitié runtime à quatre-vingt-dix petites décisions. Ni l'une ni l'autre n'aurait été trouvée en fixant l'autre du regard.

L'invariant avec lequel nous vivons désormais est celui que nous aimons le moins. setRequestLocale doit apparaître dans chaque page localisée, et rien n'échoue quand ce n'est pas le cas — la page cesse simplement d'être statique et tout le gain de livraison fuit discrètement, une route à la fois. Si vous voulez voir la forme du site que ces chiffres financent, le Labo est la chose la plus lourde que nous servions et le meilleur test pour savoir si le régime a tenu. La refonte de la page d'accueil explique pourquoi il y a tant à rendre au départ, et six langues dans un seul codebase explique pourquoi le segment de locale se trouvait là où il était.