Optimiser le rendu : requestAnimationFrame, layout thrashing et paint cost
Une animation qui saccade, un scroll qui accroche, une interaction en retard d'une fraction de seconde. Le problème vient rarement de la machine et presque toujours de la pipeline de rendu. Comment tenir les 60 images par seconde avec requestAnimationFrame, éviter le layout thrashing et maîtriser le paint cost.

Une animation qui devrait être fluide saccade. Un scroll accroche par à-coups. Un menu met une demi-seconde de trop à réagir au clic. Sur la plupart des sites, le réflexe est d'accuser la machine du visiteur ou de réduire les ambitions visuelles. Le vrai coupable est ailleurs : le navigateur n'arrive pas à produire ses images assez vite parce qu'on lui demande le mauvais travail au mauvais moment.
Un écran à 60 Hz rafraîchit son image 60 fois par seconde. Le navigateur a donc environ 16,6 millisecondes pour produire chaque frame, c'est-à-dire chaque image affichée à l'écran : exécuter le JavaScript, recalculer les styles, mesurer la mise en page, peindre les pixels et composer le tout. Il suffit de dépasser ce budget sur quelques frames pour que l'œil perçoive une saccade. C'est ce qu'on appelle le jank.
La bonne nouvelle est que ce budget se respecte sans rien sacrifier de l'ambition créative. Il suffit de comprendre ce que fait le navigateur à chaque frame et d'arrêter de le forcer à refaire le même travail en boucle. Aujourd'hui, on dissèque la pipeline de rendu, le rôle réel de requestAnimationFrame, le piège du layout thrashing et le paint cost caché.
La pipeline de rendu : ce que fait le navigateur à chaque frame
Pour afficher une image, le navigateur traverse une séquence d'étapes qu'on appelle la pixel pipeline. Chaque modification visuelle déclenche tout ou partie de cette chaîne. Comprendre quelles étapes votre code réveille est la base de toute optimisation du rendu.
| Étape | Rôle | Coût |
|---|---|---|
| JavaScript | Exécute la logique : événements, animations, manipulation du DOM | Variable. Bloque tout le reste pendant son exécution |
| Style | Calcule quelles règles CSS s'appliquent à quels éléments | Proportionnel au nombre d'éléments touchés |
| Layout (reflow) | Calcule la géométrie : position et taille de chaque élément | Élevé. Un changement peut affecter toute la page |
| Paint | Remplit les pixels : couleurs, textes, ombres, bordures | Élevé sur de grandes surfaces ou des effets complexes |
| Composite | Assemble les couches peintes dans le bon ordre à l'écran | Faible. Délégué au GPU quand c'est possible |
Les cinq étapes de la pixel pipeline, de l'exécution du JavaScript à l'affichage final
Le point clé : toutes les modifications ne coûtent pas le même prix. Changer une couleur de texte déclenche Paint et Composite mais pas Layout. Déplacer un élément avec transform ne déclenche que Composite. En revanche, modifier sa width ou son top réveille toute la chaîne à partir de Layout. Plus une modification réveille d'étapes en amont, plus elle est chère.
L'objectif d'une animation fluide est donc de viser le plus à droite possible dans cette chaîne. Une animation qui ne touche que Composite tient sans effort dans le budget de 16 ms. Une animation qui force un Layout à chaque frame le dépasse dès que la page se complexifie.
requestAnimationFrame : se caler sur le rythme de l'écran
La première erreur classique est d'animer avec setTimeout ou setInterval. Le problème n'est pas la précision : c'est que ces timers ne sont pas synchronisés avec le rafraîchissement de l'écran. Une animation à setInterval(fn, 16) produit des frames qui tombent à côté du cycle d'affichage. Résultat : des images calculées pour rien et d'autres affichées deux fois. L'œil voit du micro-saccadé même quand le code tourne vite.
requestAnimationFrame (rAF) résout exactement ce problème. Comme le détaille la documentation MDN (opens in a new tab), le callback passé à rAF est exécuté par le navigateur juste avant le prochain paint, calé sur le rythme réel de l'écran : 60 fois par seconde sur un écran 60 Hz, 120 fois sur un écran 120 Hz. Le code s'adapte automatiquement au matériel.
// Anti-pattern : timer désynchronisé de l'écran
setInterval(() => {
box.style.left = (position += 2) + 'px'; // frames perdues, micro-jank
}, 16);
// Pattern recommandé : calé sur le rafraîchissement de l'écran
function animate(timestamp) {
// timestamp est fourni par le navigateur : on calcule le mouvement
// en fonction du temps écoulé, pas d'un incrément fixe par frame
const progress = (timestamp - start) / duration;
box.style.transform = `translateX(${progress * distance}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
let start;
requestAnimationFrame((t) => {
start = t;
animate(t);
});Animer avec requestAnimationFrame plutôt qu'un timer
Deux détails font toute la différence. D'abord, le timestamp passé au callback permet de calculer le mouvement en fonction du temps réel écoulé et non d'un incrément fixe. Une animation pilotée par le temps reste cohérente que l'écran soit à 60 ou 120 Hz et qu'une frame soit ratée ou non. Ensuite, quand l'onglet passe en arrière-plan, le navigateur suspend automatiquement les callbacks rAF. Un setInterval continuerait de tourner et de consommer batterie et CPU pour rien.
rAF sert aussi à une chose moins évidente : différer une lecture ou une écriture du DOM jusqu'au bon moment du cycle. C'est la base de la technique qui résout le problème suivant, le plus coûteux de tous.
Layout thrashing : le piège du reflow forcé
Le layout thrashing est sans doute la cause numéro un de saccades dans le code JavaScript qui manipule le DOM. Il se produit quand on alterne lectures et écritures de propriétés géométriques dans une même séquence, ce qui force le navigateur à recalculer la mise en page plusieurs fois au lieu d'une seule.
Le navigateur est pourtant malin : il regroupe les modifications du DOM et ne recalcule le layout qu'une fois, au dernier moment. Sauf si on lui demande de lire une valeur géométrique qui dépend de modifications encore en attente. Là, il n'a pas le choix : il doit recalculer immédiatement pour répondre. C'est le forced synchronous layout.
// Anti-pattern : chaque lecture de offsetWidth force un reflow
// car une écriture vient juste d'invalider la mise en page
const boxes = document.querySelectorAll('.box');
boxes.forEach((box) => {
const width = box.offsetWidth; // LECTURE : force un reflow
box.style.width = width + 10 + 'px'; // ÉCRITURE : invalide le layout
});
// Pour 100 éléments : 100 reflows synchrones. La frame exploseLayout thrashing : lecture et écriture entrelacées
La correction tient en une règle : lire d'abord, écrire ensuite. On regroupe toutes les lectures géométriques dans une première passe, puis toutes les écritures dans une seconde. Le navigateur ne recalcule alors le layout qu'une seule fois, après le lot complet d'écritures. C'est la recommandation officielle de l'équipe web.dev de Google (opens in a new tab).
const boxes = document.querySelectorAll('.box');
// 1. Passe de LECTURE : on collecte toutes les valeurs d'un coup
const widths = Array.from(boxes).map((box) => box.offsetWidth);
// 2. Passe d'ÉCRITURE : aucune lecture entre les deux
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px';
});
// Un seul reflow pour l'ensemble, peu importe le nombre d'élémentsSolution : grouper les lectures puis les écritures
Encore faut-il savoir quelles propriétés déclenchent ce reflow forcé à la lecture. Ce sont toutes celles qui décrivent une géométrie calculée à partir de l'état courant du DOM.
- Dimensions et position :
offsetWidth,offsetHeight,offsetTop,offsetLeft,clientWidth,clientHeight,scrollWidth,scrollHeight. - Scroll :
scrollTop,scrollLeften lecture. - Géométrie calculée :
getBoundingClientRect(),getComputedStyle()sur des propriétés de mise en page. - Focus et visibilité :
element.focus()peut forcer un layout pour calculer la position de la cible.
Quand une logique alterne forcément lectures et écritures (par exemple dans des composants découplés qui ne se connaissent pas), la solution est de planifier les écritures dans un requestAnimationFrame. Les bibliothèques comme FastDOM formalisent ce pattern en exposant deux files séparées, measure() et mutate(), exécutées au bon moment du cycle. Le principe reste le même : ne jamais lire la géométrie juste après l'avoir invalidée.
Le paint cost : peindre moins, peindre plus petit
Une fois la mise en page calculée, le navigateur remplit les pixels. C'est l'étape Paint. Elle est invisible dans le code mais bien réelle dans le budget : certains effets visuels coûtent cher à peindre et le faire à chaque frame d'une animation suffit à provoquer du jank, même sans aucun reflow.
Les propriétés les plus coûteuses à peindre sont celles qui demandent au navigateur de calculer chaque pixel à partir de plusieurs sources : box-shadow avec un grand rayon de flou, filter: blur(), les dégradés complexes, border-radius sur de grandes surfaces. Une seule grande box-shadow animée peut suffire à faire chuter une page sous les 60 fps, soit 60 images par seconde.
Deux leviers réduisent ce coût. Le premier : peindre une surface plus petite. Plus la zone à repeindre est grande, plus le paint est cher. Animer un effet sur un petit élément coûte moins qu'animer la même chose sur un bloc plein écran. Le second : éviter de repeindre ce qui ne change pas en isolant les éléments animés sur leur propre couche de composition.
transform et opacity : les deux propriétés gratuites
Il existe deux propriétés que le navigateur peut animer sans déclencher ni Layout ni Paint : transform et opacity. Elles sont traitées directement à l'étape Composite, déléguée au GPU. web.dev recommande de s'en tenir à ces deux propriétés (opens in a new tab) pour les animations. C'est la raison pour laquelle toute animation de mouvement, d'échelle, de rotation ou de fondu devrait passer par elles plutôt que par leurs équivalents géométriques.
| Au lieu de | Utiliser | Étapes évitées |
|---|---|---|
top / left | transform: translate() | Layout + Paint |
width / height | transform: scale() | Layout + Paint |
margin pour décaler | transform: translate() | Layout + Paint |
visibility / couleur pour fondre | opacity | Paint |
Les substitutions qui font passer une animation de Layout à Composite uniquement
Pour qu'un élément soit animé sur le compositeur, le navigateur le promeut sur sa propre couche GPU. On peut suggérer cette promotion à l'avance avec will-change, ce qui évite un à-coup au démarrage de l'animation pendant que le navigateur prépare la couche.
/* On prévient le navigateur qu'on va animer transform sur cet élément.
Il prépare une couche GPU dédiée en amont */
.card-interactive {
will-change: transform;
}
.card-interactive:hover {
transform: translateY(-8px) scale(1.02);
transition: transform 0.3s ease;
}
/* Toujours respecter les préférences de mouvement réduit */
@media (prefers-reduced-motion: reduce) {
.card-interactive {
transition: none;
}
}Préparer la couche de composition avec will-change
will-change est un outil à manier avec parcimonie. Chaque couche de composition consomme de la mémoire vidéo. Posé sur des dizaines d'éléments ou, pire, laissé en permanence sur tout le site, il sature le GPU et produit l'effet inverse de celui recherché. La règle : ne promouvoir que les éléments réellement sur le point d'être animés et retirer la propriété une fois l'animation terminée si elle ne se répète pas. C'est aussi un cousin direct des fuites de couches qu'on retrouve dans les memory leaks et cleanup patterns (opens in a new tab).
Quand l'animation peut être entièrement décrite en CSS, mieux vaut souvent la déléguer au navigateur plutôt que de la piloter en JavaScript frame par frame. Les CSS Scroll-Driven Animations (opens in a new tab) couvrent désormais une bonne partie des cas liés au scroll sans une ligne de rAF.
Mesurer avant d'optimiser : la méthode DevTools
Optimiser au jugé fait perdre du temps et ajoute de la complexité là où elle est inutile. La pipeline de rendu se mesure précisément dans les DevTools du navigateur. Voici le protocole pour identifier d'où vient une saccade.
1. Enregistrer un profil dans l'onglet Performance
Ouvrir l'onglet Performance, lancer un enregistrement, reproduire l'interaction qui saccade, arrêter. La timeline affiche chaque frame avec sa décomposition par couleur : jaune pour le JavaScript, violet pour le Layout, vert pour le Paint. Les frames qui dépassent 16,6 ms sont signalées en rouge. La couleur dominante d'une frame rouge dit immédiatement quelle étape est en cause.
2. Traquer les forced synchronous layouts
Dans le même profil, le navigateur marque d'un triangle d'avertissement les reflows forcés. Survoler la marque révèle la ligne de code exacte qui a déclenché la lecture géométrique au mauvais moment. C'est le moyen le plus rapide de localiser un layout thrashing dans une base de code qu'on ne connaît pas.
3. Visualiser les zones repeintes
Activer "Paint flashing" dans les DevTools (menu Rendering). Chaque zone repeinte clignote en vert à l'écran. Si une grande surface clignote alors qu'un petit élément seulement est censé bouger, le repaint déborde : l'élément animé n'est pas isolé sur sa couche et entraîne le repaint de tout son voisinage. C'est le signal qu'il faut passer à transform ou isoler la couche.
4. Inspecter les couches de composition
L'onglet Layers liste les couches GPU créées par la page et leur poids mémoire. Trop de couches signale un usage excessif de will-change ou de propriétés qui forcent la promotion. Zéro couche sur un élément censé être animé sur le compositeur signale l'inverse : l'animation retombe sur le CPU. Cet onglet réconcilie ce que vous croyez avoir codé avec ce que le navigateur fait réellement.
Au-delà des animations, ce budget par frame est exactement ce que mesure l'INP (Interaction to Next Paint), désormais l'un des Core Web Vitals. Une interaction lente à répondre, c'est une pipeline de rendu saturée sur les quelques frames qui suivent le clic. Optimiser le rendu, c'est donc aussi optimiser un signal que Google regarde, comme détaillé dans SEO et Core Web Vitals en 2026 (opens in a new tab).
Les 6 erreurs les plus fréquentes
- Animer
top,left,widthouheightau lieu detransform. Chaque frame force un Layout complet. Le passage àtransformest souvent l'optimisation la plus rentable d'une animation qui saccade. - Lire la géométrie dans une boucle d'écriture. Chaque
offsetWidthougetBoundingClientRect()après une mutation force un reflow synchrone. Grouper les lectures puis les écritures supprime le problème. - Utiliser
setIntervalpour animer. Désynchronisé de l'écran, il produit du micro-jank et continue de tourner en arrière-plan.requestAnimationFramese cale sur l'affichage et se suspend tout seul. - Laisser
will-changeen permanence sur de nombreux éléments. Chaque couche consomme de la mémoire vidéo. Promu trop largement, l'outil sature le GPU et dégrade le rendu au lieu de l'améliorer. - Animer une
box-shadowou unfilter: blur()directement. Ces effets repeints à chaque frame coûtent cher. Préférer animer l'opacityd'une ombre déjà peinte sur une couche séparée. - Optimiser sans mesurer. Sans profil DevTools, on devine. La timeline Performance dit en trente secondes si le coupable est le JavaScript, le Layout ou le Paint. Mesurer d'abord évite de complexifier du code qui n'était pas le problème.
La fluidité n'est pas une question de puissance
Un site qui saccade sur une machine récente ne manque pas de puissance : il gâche le peu de temps dont le navigateur dispose pour afficher chaque image. Le navigateur du visiteur fait exactement ce qu'on lui demande et ce qu'on lui demande est trop souvent de refaire le même calcul de mise en page soixante fois par seconde pour un mouvement qui aurait pu rester sur le compositeur.
Maîtriser la pipeline de rendu change la donne sur l'ensemble du site : des animations qui tiennent les 60 fps sur mobile comme sur desktop, un scroll qui ne décroche pas, des interactions qui répondent à l'instant et un INP qui passe au vert. C'est ce qui sépare une expérience qui paraît soignée d'une expérience qui l'est réellement.