Async JavaScript - requestAnimationFrame et requestIdleCallback
Ce que tu vas apprendre
- Comment
requestAnimationFramesynchronise ton code avec le rendu du navigateur - La boucle d'animation a 60fps et comment éviter le jank
requestIdleCallbackpour le travail de fond sans bloquer l'utilisateur- L'API
scheduler.postTask()et la gestion des priorités - Comment découper les taches longues (long tasks) pour rester reactif
- Le pattern "yielding to the main thread"
Prerequisites
- 05 - Event loop en détail : comprendre la boucle d'événements et le rendu
- 06 - Microtasks vs macrotasks : la différence de priorité entre les files
J'ai passe un apres-midi entier a debugger une animation CSS qui "sautait" sur certains appareils. Le problème n'etait pas le CSS. C'etait un setInterval a 16ms qui tentait de mettre à jour la position d'un élément. Parfois ca tombait juste avant le rendu, parfois juste apres. Le résultat : un mouvement saccade, irregulier, et un utilisateur (moi) frustre. La solution tenait en un mot : requestAnimationFrame.
requestAnimationFrame : synchronise avec le rendu
Le navigateur essaie de rafraichir l'ecran 60 fois par seconde (ou plus, selon l'ecran). Chaque cycle de rafraichissement passe par ces étapes :
[ JavaScript ] -> [ Style ] -> [ Layout ] -> [ Paint ] -> [ Composite ]
|
+-- requestAnimationFrame s'execute ICI, juste avant le calcul des styles
requestAnimationFrame (rAF) garantit que ton callback sera exécuté une fois par frame, juste avant le rendu. Ni trop tot, ni trop tard.
javascriptfunction animer(element) {
let position = 0;
function frame(timestamp) {
position += 2;
element.style.transform = `translateX(${position}px)`;
if (position < 500) {
requestAnimationFrame(frame);
}
}
requestAnimationFrame(frame);
}
Le paramètre timestamp est un DOMHighResTimeStamp (en millisecondes, precision au microseconde). Utilise-le pour calculer des animations basees sur le temps plutot que sur le nombre de frames :
javascriptfunction animerAvecDuree(element, duree) {
let debut = null;
function frame(timestamp) {
if (!debut) debut = timestamp;
const progression = Math.min((timestamp - debut) / duree, 1);
element.style.transform = `translateX(${progression * 500}px)`;
element.style.opacity = 1 - progression * 0.5;
if (progression < 1) {
requestAnimationFrame(frame);
}
}
requestAnimationFrame(frame);
}
// L'animation dure exactement 1 seconde, quel que soit le taux de rafraichissement
animerAvecDuree(monElement, 1000);
Pourquoi pas setInterval ?
setInterval(fn, 16) ne se synchronise pas avec le rendu. Il peut s'exécuter au milieu d'une frame, forcer un recalcul de layout (reflow), ou meme sauter des frames si le callback est trop lent. Le résultat visible : du jank -- ces micro-saccades qui donnent l'impression que l'animation rame.
javascript// Mauvais : desynchronise du rendu
setInterval(() => {
element.style.left = (parseInt(element.style.left) + 1) + "px";
}, 16);
// Bien : synchronise avec le rendu
function frame() {
element.style.transform = `translateX(${pos++}px)`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Note aussi que transform est préférable a left/top. Les transformations CSS sont composees par le GPU sans provoquer de reflow. C'est gratuit en termes de performance.
Éviter le jank : le budget de 16ms
A 60fps, chaque frame dispose de 16.66ms. Si ton JavaScript dépassé ce budget, le navigateur saute une frame. L'utilisateur voit un a-coup.
javascript// Mauvais : travail lourd dans le callback rAF
function frame() {
// Ca prend 25ms -> frame sautee
for (let i = 0; i < 1_000_000; i++) {
calculerTruc(i);
}
element.style.transform = `translateX(${pos}px)`;
requestAnimationFrame(frame);
}
// Bien : le callback rAF ne fait que le rendu
// Le calcul lourd est fait ailleurs (Web Worker, ou decoupe)
function frame() {
element.style.transform = `translateX(${positionCalculee}px)`;
requestAnimationFrame(frame);
}
La regle est simple : dans un callback rAF, tu lis et tu ecris le DOM, point final. Le calcul lourd, tu le fais avant, dans un Worker, ou tu le decoupes.
requestIdleCallback : travailler en arriere-plan
requestIdleCallback (rIC) exécuté ton code quand le navigateur n'a rien d'autre a faire. Entre deux frames, s'il reste du temps, ton callback est appele avec un objet deadline qui te dit combien de temps il te reste.
javascriptfunction tacheDeFond(deadline) {
// deadline.timeRemaining() retourne les ms disponibles
while (deadline.timeRemaining() > 1 && taches.length > 0) {
traiter(taches.shift());
}
// S'il reste des taches, on se replanifie
if (taches.length > 0) {
requestIdleCallback(tacheDeFond);
}
}
requestIdleCallback(tacheDeFond, { timeout: 5000 });
Le paramètre timeout est un filet de sécurité : meme si le navigateur est occupe, ton callback sera appele apres 5 secondes maximum. Utile pour les taches qui ne peuvent pas attendre indefiniment.
Cas d'usage typiques :
- Envoyer des analytics
- Pre-charger des ressources non critiques
- Indexer du contenu pour la recherche locale
- Nettoyer des caches
javascript// Envoyer des analytics sans impacter le rendu
const analyticsQueue = [];
function flushAnalytics(deadline) {
while (deadline.timeRemaining() > 5 && analyticsQueue.length > 0) {
const event = analyticsQueue.shift();
navigator.sendBeacon("/analytics", JSON.stringify(event));
}
if (analyticsQueue.length > 0) {
requestIdleCallback(flushAnalytics);
}
}
function track(event) {
analyticsQueue.push({ ...event, timestamp: Date.now() });
requestIdleCallback(flushAnalytics);
}
scheduler.postTask() : la prochaine génération
L'API scheduler.postTask() va plus loin que rAF et rIC. Elle permet de planifier des taches avec des priorités explicites :
javascript// Trois niveaux de priorite
// "user-blocking" : critique pour l'interaction (comme rAF)
// "user-visible" : visible mais pas bloquant (defaut)
// "background" : quand le navigateur a le temps (comme rIC)
await scheduler.postTask(() => {
mettreAJourUI();
}, { priority: "user-blocking" });
await scheduler.postTask(() => {
envoyerAnalytics();
}, { priority: "background" });
Tu peux aussi utiliser un TaskController pour annuler ou changer la priorité dynamiquement :
javascriptconst controller = new TaskController({ priority: "background" });
scheduler.postTask(() => {
traiterDonnees();
}, { signal: controller.signal });
// Plus tard, si l'utilisateur a besoin du resultat maintenant
controller.setPriority("user-blocking");
// Ou annuler
controller.abort();
En mars 2026, scheduler.postTask() est supporte dans Chromium. Firefox et Safari avancent, mais pour la production multi-navigateur, garde un fallback avec rIC.
Decouper les taches longues
Chrome signale toute tache de plus de 50ms comme une "Long Task" dans les DevTools. Ces taches bloquent les interactions et degradent les metriques Core Web Vitals (INP notamment).
Le pattern classique pour découper :
javascriptfunction yieldToMainThread() {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
async function traiterGrosTableau(items) {
const BATCH_SIZE = 100;
for (let i = 0; i < items.length; i += BATCH_SIZE) {
const batch = items.slice(i, i + BATCH_SIZE);
for (const item of batch) {
traiter(item);
}
// Rendre la main au navigateur entre chaque batch
await yieldToMainThread();
}
}
Le setTimeout(resolve, 0) pousse la reprise dans la file des macrotasks, ce qui laisse le navigateur traiter les événements utilisateur et le rendu entre chaque batch.
Yielding avec scheduler.yield()
La spec Scheduling API propose scheduler.yield(), une facon plus propre de rendre la main :
javascriptasync function traiterAvecYield(items) {
for (let i = 0; i < items.length; i++) {
traiter(items[i]);
// Toutes les 100 iterations, on rend la main
if (i % 100 === 0) {
await scheduler.yield();
}
}
}
La différence avec setTimeout(0) : scheduler.yield() conserve ta position dans la file de priorité. Avec setTimeout, tu retombes au fond de la file des macrotasks, derrière tout ce qui attend. Avec yield(), tu reprends des que le navigateur a fini le rendu courant.
Résumé
requestAnimationFrame synchronise ton code avec le cycle de rendu du navigateur -- indispensable pour des animations fluides a 60fps. requestIdleCallback exécuté du travail de fond sans impacter la réactivité, en exploitant les temps morts entre les frames. scheduler.postTask() apporte un système de priorités explicites plus fin que les deux précédentes API. Pour les taches longues, la technique du "yield to main thread" (via setTimeout(0) ou scheduler.yield()) permet de découper le travail et garder l'interface reactive. La regle d'or : le thread principal appartient a l'utilisateur, ton code doit apprendre a partager.
Retrouve d'autres articles techniques sur paltemps.fr.
Navigation : Precedent : 13 - Generators | Suivant : 15 - Top-level await
Sources
- MDN - requestAnimationFrame par Mozilla
- MDN - requestIdleCallback par Mozilla
- Prioritized Task Scheduling API par Chrome Developers