Un champ de recherche qui envoie une requête à chaque frappe en envoie cinq pour “redis”, et quatre arrivent périmées avant que la réponse ne revienne. Un handler de scroll qui recalcule le layout s’exécute des centaines de fois par seconde et fait saccader la page. Même problème dans les deux cas : l’événement du DOM se déclenche bien plus souvent que ce dont le handler a besoin. Debounce et throttle règlent ça de deux façons différentes.

Ce que fait le debounce et ce que fait le throttle

Le debounce attend que l’événement s’arrête de se déclencher pendant une période donnée, puis s’exécute une fois. Chaque nouvel appel remet le minuteur à zéro. Si l’événement ne s’arrête jamais, la fonction ne s’exécute jamais. Le champ de recherche est le cas typique : attendre que l’utilisateur fasse une pause en tapant, puis envoyer une requête pour ce qu’il a tapé en dernier.

Le throttle plafonne la fréquence à laquelle l’appel peut s’exécuter. Pendant une rafale, il continue de s’exécuter, au maximum une fois par intervalle. Utilisez-le pour scroll, où la page doit continuer à se mettre à jour pendant que l’utilisateur fait défiler, mais pas à chaque événement.

DebounceThrottle
S’exécute pendant la rafalePas avant la finOui, au maximum une fois par intervalle
Ce que vous obtenezLe dernier appel de la rafaleUn échantillon régulier pendant toute la rafale
Usage typiqueChamp de recherche, validation de formulaire, sauvegarde à la saisiescroll, resize, mousemove, drag
Si la rafale ne s’arrête jamaisNe s’exécute jamaisContinue de s’exécuter selon le rythme fixé

Les deux sont des wrappers. Vous passez votre callback et un délai, et récupérez en échange une fonction à la même signature, que vous attachez à l’événement à la place de l’originale.

Comment écrire une fonction de debounce

1
2
3
4
5
6
7
function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

Chaque appel annule le setTimeout en attente et en programme un nouveau, si bien que seul le dernier appel de la rafale vit assez longtemps pour se déclencher. Le timing suit les règles de setTimeout décrites dans comment l’event loop de JavaScript planifie les callbacks : la callback ne s’exécute jamais avant delay, et peut s’exécuter plus tard si la pile d’appels est occupée quand le minuteur arrive à échéance.

Branché à un champ de recherche :

1
2
3
4
5
6
7
const input = document.querySelector("#search");
const search = debounce((value) => {
  console.log("searching for:", value);
  fetch(`/api/search?q=${encodeURIComponent(value)}`);
}, 300);

input.addEventListener("input", (e) => search(e.target.value));

Tapez “redis” à vitesse normale et la console affiche une seule ligne, searching for: redis, environ 300ms après la dernière frappe. Pas cinq lignes pour cinq lettres.

Comment écrire une fonction de throttle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

C’est la version leading-edge. Le premier appel d’une fenêtre s’exécute immédiatement ; tout ce qui arrive ensuite est ignoré jusqu’à ce que interval millisecondes se soient écoulées.

1
2
3
4
5
const onScroll = throttle(() => {
  console.log("scrollY:", window.scrollY);
}, 200);

window.addEventListener("scroll", onScroll);

Faites défiler en continu pendant deux secondes et vous obtenez une dizaine de lignes de log réparties uniformément, au lieu des centaines d’événements scroll que le navigateur déclenche réellement.

Options debounce et throttle de lodash : leading, trailing, maxWait

Chacune des fonctions écrites à la main ci-dessus fige un seul choix. Le debounce ne se déclenche que sur le trailing edge, après la période de calme. Le throttle ne se déclenche que sur le leading edge, à l’instant où une fenêtre s’ouvre, donc le dernier appel d’une rafale est perdu s’il tombe en milieu de fenêtre. Ça compte pour scroll : la dernière position où l’utilisateur s’est arrêté peut ne jamais être traitée.

_.debounce(fn, wait, options) et _.throttle(fn, wait, options) de lodash rendent configurables les deux bords via les booléens leading et trailing, et les valeurs par défaut diffèrent. Le debounce a pour défaut leading: false, trailing: true : attendre, puis se déclencher. Le throttle a pour défaut les deux à true : se déclencher immédiatement, puis une fois de plus à la fin d’une rafale qui a duré plus d’un intervalle. En interne, le throttle de lodash est son debounce avec maxWait réglé sur l’intervalle, donc les deux sont plus proches que leurs noms ne le suggèrent.

maxWait est une option du debounce qui plafonne le report du premier appel. Sans elle, quelqu’un qui tape régulièrement avec des pauses plus courtes que le délai ne déclenche jamais le handler, même après dix secondes d’activité. Avec maxWait: 2000, il se déclenche au moins toutes les deux secondes.

Le debounce leading uniquement couvre le cas du double envoi. _.debounce(sendMail, 300, { leading: true, trailing: false }) s’exécute au premier clic et ignore les suivants jusqu’à ce que 300ms s’écoulent sans aucun clic. Le premier clic compte ; les autres sont ignorés, pas retardés.

Quels événements ont besoin de debounce et lesquels de throttle

ÉvénementChoixPourquoi
Recherche à la saisieDebounceSeule la requête finale compte ; les frappes précédentes produisent des requêtes que vous jetteriez
resize de la fenêtreDebounce ou throttleDebounce si seule la taille finale compte (recalculer le layout une fois) ; throttle si l’interface doit suivre visiblement le redimensionnement
scrollThrottleLa page doit continuer à répondre pendant tout le défilement, pas seulement après que l’utilisateur s’arrête
mousemove (drag, tooltip qui suit)Throttle, via requestAnimationFrameDes mises à jour plus rapides que la fréquence de rafraîchissement de l’écran sont du travail perdu
Clic sur un bouton (éviter le double envoi)Debounce leading uniquement ou désactivation au clicLe premier clic doit compter ; les autres doivent être ignorés, pas retardés
Sauvegarde automatique à chaque frappeDebounce avec maxWaitSauvegarder après une pause, mais garantir une sauvegarde pendant une saisie continue longue

Quand le handler pilote quelque chose de visuel (un drag, un tooltip qui suit le curseur, une animation liée au scroll), remplacez Date.now par requestAnimationFrame :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function rafThrottle(fn) {
  let scheduled = false;
  return function (...args) {
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      fn.apply(this, args);
      scheduled = false;
    });
  };
}

Le rythme des appels suit maintenant la cadence de rendu du navigateur au lieu d’un chiffre en millisecondes choisi au hasard. La mise à jour s’exécute une fois par frame, juste avant que la frame soit dessinée, donc elle ne peut pas s’exécuter plus souvent que ce que l’œil peut percevoir et ne se désynchronise pas du rendu quand le thread principal est chargé.

Erreurs courantes avec debounce et throttle

Créer la fonction debounced à l’intérieur du handler. En React, onChange={(e) => debounce(handleSearch, 300)(e.target.value)} appelle debounce() à chaque frappe, et chaque appel renvoie un nouveau wrapper avec son propre timer vide. Il n’y a jamais de minuteur précédent à annuler, donc chaque frappe déclenche malgré tout une requête, juste 300ms plus tard. Créez la fonction debounced une seule fois (useMemo, useRef, ou au niveau du module hors d’un composant) et appelez toujours cette même instance.

Ne pas annuler au démontage. Une sauvegarde automatique debounced qui se déclenche après que l’utilisateur a quitté la page écrit dans un composant qui n’existe plus, ou sauvegarde depuis une closure obsolète. Les fonctions debounced et throttled de lodash ont déjà une méthode .cancel() ; pour le debounce écrit à la main, ajoutez-en une qui appelle clearTimeout(timer). Appelez-la quand le composant se démonte ou que la page se ferme.

Tester avec de vrais minuteurs. Attendre un debounce avec await new Promise(r => setTimeout(r, 300)) fonctionne, mais ajoutez ça à chaque test de timing et la suite devient assez lente pour que plus personne ne la lance en local. Utilisez des minuteurs factices : vi.useFakeTimers() dans Vitest ou jest.useFakeTimers() dans Jest, puis avancez l’horloge avec vi.advanceTimersByTime(300). Un minuteur factice est un test double pour l’horloge, pas pour votre code, la distinction tracée dans tests unitaires vs tests d’intégration vs tests end-to-end.

Appliquer un debounce à quelque chose qui doit sembler en direct. Une barre de progression ou une poignée de drag qui ne bouge qu’une fois que l’utilisateur s’arrête se perçoit comme cassée. Si le besoin est « mettre à jour en douceur pendant que ça continue », c’est le throttle qu’il faut.

Debounce ou throttle par défaut ?

Utilisez le debounce quand seul l’état final compte : champs de recherche, sauvegarde automatique, valider un champ une fois que l’utilisateur arrête de taper. Utilisez le throttle quand l’utilisateur s’attend à voir une progression pendant que quelque chose continue, comme le scroll, le resize ou le drag, et passez à la version requestAnimationFrame quand le résultat est une mise à jour visuelle.

En production, importez debounce et throttle depuis une bibliothèque maintenue (lodash, ou un hook comme use-debounce en React) plutôt que d’utiliser les versions ci-dessus. Elles existent pour montrer le mécanisme. Les versions de bibliothèque gèrent déjà l’annulation, maxWait, les appels trailing et le binding de this, là où une copie écrite à la main casse en général.

Articles associés