Debounce et throttle JavaScript : implementations vanilla, RxJS debounceTime, requestAnimationFrame, hooks React, leading/trailing et patterns testes.
Pourquoi debounce et throttle existent
Certains événements du navigateur se déclenchent très souvent : scroll, mousemove, resize, input, wheel, touchmove. Une vraie session utilisateur peut générer 60 à 200 événements par seconde sur chacun d'eux. Si votre handler fait quoi que ce soit de coûteux (appel API, calcul lourd, mise à jour DOM importante), le navigateur sature en quelques secondes et l'UI gèle.
Les deux patterns debounce et throttle résolvent le problème en interposant un filtre temporel entre l'événement et le handler. Vous ne changez pas la logique métier — vous limitez seulement la fréquence à laquelle elle s'exécute. Bien employés, ils transforment une UI gelée en interface fluide sans refactoring profond. C'est l'optimisation à plus fort levier sur les pages avec beaucoup d'interactivité.
Ce que cet article couvre
- La différence fondamentale entre debounce et throttle, illustrée par des cas concrets.
- Cinq implémentations vanilla JavaScript : debounce basique, throttle timestamp, throttle setTimeout, leading/trailing, annulation.
requestAnimationFramecomme alternative supérieure pour tout ce qui touche au DOM.- Les outils des trois frameworks majeurs : RxJS pour Angular, hooks custom pour React, Signal pour Angular 17+.
- Une dizaine de cas d'usage classés par scénario (autocomplete, scroll handler, drag, sauvegarde, API rate limiting).
- Comment tester un debounce de façon déterministe avec
vi.useFakeTimers. - Les pièges les plus courants : fuite mémoire en SPA, perte de
this, double-trigger.
Le coût caché des events haute fréquence
Faites l'expérience : ouvrez Chrome DevTools sur n'importe quelle page, onglet Performance, démarrez un enregistrement, faites simplement défiler la page pendant 2 secondes, arrêtez. Vous verrez probablement 30 à 100 invocations de handlers scroll par seconde. Si chacun fait une vérification de position d'élément, une mise à jour de classe CSS, ou pire un calcul de layout (getBoundingClientRect), vous obtenez plusieurs centaines de millisecondes de travail JavaScript par seconde. Sur mobile bas de gamme, c'est suffisant pour faire chuter le framerate à 15 fps et donner cette sensation de « lag » que tout utilisateur a déjà ressentie.
La solution n'est presque jamais d'optimiser le handler — c'est de l'appeler moins souvent. Un scroll handler qui s'exécute 60 fois par seconde au lieu de 200 produit exactement le même résultat visuel et coûte 3× moins. C'est la promesse de debounce, throttle et requestAnimationFrame : faire le même travail, juste moins fréquemment.
Debounce vs Throttle — la règle simple
La règle pour choisir tient en une question simple : « voulez-vous la valeur après que l'utilisateur ait fini, ou voulez-vous des valeurs régulières pendant qu'il agit ? ». Si vous attendez la fin, c'est debounce. Si vous voulez réagir en continu mais sans saturation, c'est throttle.
Schéma temporel des deux patterns
// Événements bruts (X = événement, _ = vide)
Events : X X X _ X X X X _ _ _ X _ _
Time : 0 1 2 3 4 5 6 7 8 9 . . . . . (en 100ms)
// Debounce 300ms — exécution APRÈS 3 cases vides
Debounce: _ _ _ _ _ _ _ _ _ _ X _ _ _ X
(un seul appel après l'arrêt total des events)
// Throttle 300ms — exécution AU MAX une fois par 300ms
Throttle: X _ _ X _ _ X _ _ _ X _ _ _ _
(cadence régulière, peu importe le débit d'entrée)
Tableau de décision
| Cas d'usage | Choix | Pourquoi |
|---|---|---|
| Recherche autocomplete | Debounce | Attendre la fin de la frappe avant appel API |
| Sauvegarde auto d'éditeur | Debounce | Sauver quand l'utilisateur arrête de taper |
| Validation de formulaire live | Debounce | Valider sur état stable |
| Resize de fenêtre | Debounce | Recalculer une fois après le glisser |
| Sticky header au scroll | Throttle / rAF | Réagir en continu mais pas plus que 60 fps |
| Lazy load au scroll | Throttle / rAF | Vérifier la position régulièrement |
| Drag-and-drop position | rAF | Synchronisé sur les frames du navigateur |
| Métriques analytics scroll | Throttle | Limiter le nombre d'events envoyés |
| Bouton anti-double-click | Debounce leading | Exécuter le premier, bloquer les suivants |
| Suivi GPS dans une app | Throttle | Recevoir une position par seconde, pas 60 |
Implémenter debounce en JavaScript pur
Version basique typée TypeScript
function debounce<T extends (...args: any[]) => any>(
fn: T,
ms = 300,
): (...args: Parameters<T>) => void {
let timerId: ReturnType<typeof setTimeout> | null = null;
return function (this: unknown, ...args: Parameters<T>) {
if (timerId !== null) clearTimeout(timerId);
timerId = setTimeout(() => {
fn.apply(this, args);
timerId = null;
}, ms);
};
}
// Usage — autocomplete d'une barre de recherche
const onSearch = debounce((q: string) => {
fetch(`/api/search?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(results => updateUI(results));
}, 300);
document.querySelector('#search')!.addEventListener('input', (e) => {
onSearch((e.target as HTMLInputElement).value);
});
Pourquoi le pattern fonctionne
À chaque appel, on annule le timer précédent (clearTimeout) et on en démarre un nouveau. Tant que les appels arrivent à moins de ms ms d'écart, on accumule des annulations sans rien exécuter. Dès qu'un délai de ms s'écoule sans nouvel appel, le dernier timer arrive à échéance et déclenche fn. La closure mémorise timerId entre les appels — c'est le pattern closure sur un timer classique.
Préservation du contexte this
L'usage de fn.apply(this, args) est essentiel quand on debounce une méthode de classe. Sans apply, this serait undefined à l'appel différé. Si vous utilisez des arrow functions à la place de function (...) dans le wrapper, this est capturé lexicalement — pratique si vous ne consommez pas this dans la fonction debounce.
Implémenter throttle (version timestamp et setTimeout)
Version « timestamp » — la plus simple
function throttle<T extends (...args: any[]) => any>(
fn: T,
ms = 300,
): (...args: Parameters<T>) => void {
let lastCall = 0;
return function (this: unknown, ...args: Parameters<T>) {
const now = Date.now();
if (now - lastCall >= ms) {
lastCall = now;
fn.apply(this, args);
}
};
}
// Usage — sticky header au scroll
const onScroll = throttle(() => {
document.body.classList.toggle('scrolled', window.scrollY > 100);
}, 100); // au max 10 vérifications par seconde
window.addEventListener('scroll', onScroll, { passive: true });
Version « setTimeout » — avec trailing call
La version timestamp a un défaut : le dernier événement de la rafale est ignoré si l'intervalle n'est pas écoulé. Pour les cas où on veut absolument capturer la dernière valeur (drag end position, scroll final), on ajoute un setTimeout qui rejoue le dernier appel à la fin de l'intervalle.
function throttleWithTrailing<T extends (...args: any[]) => any>(
fn: T,
ms = 300,
): (...args: Parameters<T>) => void {
let timerId: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
let lastCall = 0;
return function (this: unknown, ...args: Parameters<T>) {
const now = Date.now();
const remaining = ms - (now - lastCall);
lastArgs = args;
if (remaining <= 0) {
lastCall = now;
fn.apply(this, args);
lastArgs = null;
} else if (timerId === null) {
timerId = setTimeout(() => {
lastCall = Date.now();
if (lastArgs) fn.apply(this, lastArgs);
timerId = null;
lastArgs = null;
}, remaining);
}
};
}
{ passive: true } à addEventListener pour les events scroll, touchstart, touchmove, wheel. Cela indique au navigateur que vous n'appellerez pas event.preventDefault(), ce qui permet le scroll natif fluide même pendant l'exécution de votre handler.
Leading et trailing — les variantes avancées
Par défaut, le debounce est trailing : la fonction s'exécute après la fin de la rafale. L'option leading inverse le comportement — la fonction s'exécute immédiatement au premier appel, puis les suivants sont ignorés pendant la fenêtre.
interface DebounceOpts {
leading?: boolean; // appel immédiat au premier event
trailing?: boolean; // appel à la fin de la fenêtre (défaut: true)
}
function debounceAdvanced<T extends (...args: any[]) => any>(
fn: T,
ms = 300,
{ leading = false, trailing = true }: DebounceOpts = {},
): (...args: Parameters<T>) => void {
let timerId: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
return function (this: unknown, ...args: Parameters<T>) {
const callNow = leading && timerId === null;
lastArgs = args;
if (timerId !== null) clearTimeout(timerId);
timerId = setTimeout(() => {
if (trailing && lastArgs && !callNow) fn.apply(this, lastArgs);
timerId = null;
lastArgs = null;
}, ms);
if (callNow) fn.apply(this, args);
};
}
L'option maxWait de Lodash (non implémentée ci-dessus) garantit en plus que la fonction sera exécutée au moins une fois toutes les maxWait ms, même si les événements continuent d'arriver. Utile pour des cas où vous voulez à la fois debouncer ET garantir une exécution périodique — par exemple un autosave qui doit fonctionner même quand l'utilisateur tape sans interruption pendant 30 secondes.
Notez l'importance du paramètre callNow dans cette implémentation avancée. Il évite d'exécuter à la fois au début et à la fin si leading et trailing sont à true mais qu'il n'y a eu qu'un seul appel — sinon vous obtenez deux exécutions pour une seule action utilisateur, ce qui n'est jamais ce qu'on veut.
Cas concrets d'utilisation
- Bouton anti-double-click —
leading: true, trailing: false. Le premier clic exécute l'action, les suivants pendant 500 ms sont ignorés. - Autocomplete — par défaut (
trailing: true). On attend la fin de la frappe. - Validation visuelle —
leading: true, trailing: true. Première validation immédiate pour réactivité, dernière validation au calme.
Annulation et flush — APIs complètes
Une bonne implémentation expose deux méthodes additionnelles. cancel() annule un appel en attente sans déclencher la fonction. flush() déclenche immédiatement l'appel en attente (vidange du buffer). Ces deux APIs sont essentielles pour les composants React/Angular qui doivent nettoyer leurs effets à la destruction.
interface DebouncedFn<T extends (...args: any[]) => any> {
(...args: Parameters<T>): void;
cancel(): void;
flush(): void;
}
function debounce<T extends (...args: any[]) => any>(
fn: T,
ms = 300,
): DebouncedFn<T> {
let timerId: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
let lastThis: unknown = null;
const debounced = function (this: unknown, ...args: Parameters<T>): void {
lastArgs = args;
lastThis = this;
if (timerId !== null) clearTimeout(timerId);
timerId = setTimeout(() => {
if (lastArgs) fn.apply(lastThis, lastArgs);
timerId = null;
lastArgs = null;
}, ms);
} as DebouncedFn<T>;
debounced.cancel = () => {
if (timerId !== null) clearTimeout(timerId);
timerId = null;
lastArgs = null;
};
debounced.flush = () => {
if (timerId === null || !lastArgs) return;
clearTimeout(timerId);
fn.apply(lastThis, lastArgs);
timerId = null;
lastArgs = null;
};
return debounced;
}
// Usage dans un composant React
useEffect(() => {
const debouncedSave = debounce(saveData, 500);
input.addEventListener('input', debouncedSave);
return () => {
debouncedSave.cancel(); // évite la fuite mémoire
input.removeEventListener('input', debouncedSave);
};
}, []);
requestAnimationFrame — le throttle « parfait »
Pour tout ce qui touche au DOM ou aux animations visuelles, requestAnimationFrame est supérieur à throttle(fn, 16). Il aligne l'exécution sur les frames du navigateur (60 ou 120 fps selon l'écran), s'arrête automatiquement quand l'onglet est en arrière-plan (économie batterie), et ne déclenche jamais de rendu inutile entre deux frames.
function rafThrottle<T extends (...args: any[]) => any>(
fn: T,
): (...args: Parameters<T>) => void {
let scheduled = false;
let lastArgs: Parameters<T>;
return function (this: unknown, ...args: Parameters<T>) {
lastArgs = args;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
fn.apply(this, lastArgs);
scheduled = false;
});
};
}
// Usage — scroll handler buttery smooth
const onScroll = rafThrottle(() => {
const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
progressBar.style.width = `${progress * 100}%`;
});
window.addEventListener('scroll', onScroll, { passive: true });
requestAnimationFrame au lieu de throttle. Le navigateur fait le travail de cadencement à votre place, et l'animation reste fluide à 60/120 fps sans surcoût CPU.
requestIdleCallback — pour les tâches non urgentes
Cousin moins connu de rAF, requestIdleCallback exécute son callback quand le navigateur n'a rien à faire — entre deux interactions utilisateur. Idéal pour les opérations background (sauvegarde locale, indexation client, envoi de télémétrie). Spécifiez un timeout pour garantir l'exécution sous N ms même si le navigateur reste occupé : requestIdleCallback(saveDraft, { timeout: 2000 }). Combiné à un debounce, vous obtenez une UX qui ne sacrifie jamais la réactivité aux opérations lourdes en arrière-plan.
RxJS : debounceTime, throttleTime, auditTime
RxJS, utilisé par Angular et de nombreux projets Node, propose trois opérateurs distincts pour ces patterns — chacun avec une sémantique précise.
import { fromEvent } from 'rxjs';
import { debounceTime, throttleTime, auditTime, map, distinctUntilChanged } from 'rxjs/operators';
// Autocomplete avec debounce
fromEvent<Event>(searchInput, 'input').pipe(
map(e => (e.target as HTMLInputElement).value),
debounceTime(300),
distinctUntilChanged(),
).subscribe(query => performSearch(query));
// Scroll handler avec throttle
fromEvent(window, 'scroll').pipe(
throttleTime(100),
).subscribe(() => updateScrollIndicator());
// Auditing — émet APRÈS le délai en utilisant la DERNIÈRE valeur reçue
fromEvent(input, 'input').pipe(
auditTime(500),
).subscribe(e => saveDraft(e));
Différence entre throttle, debounce, audit
debounceTime(ms)— émet la dernière valeur aprèsmsms d'inactivité.throttleTime(ms)— émet la première valeur de chaque fenêtre, ignore les suivantes.auditTime(ms)— émet la dernière valeur aprèsmsms suivant la première reçue (cadence régulière + dernière valeur garantie).sampleTime(ms)— émet la dernière valeur reçue à intervalles fixes, même sans nouvel événement.
React : custom hooks et libs prêtes
useDebounce custom hook
import { useEffect, useState } from 'react';
function useDebounce<T>(value: T, ms = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(id); // cleanup automatique
}, [value, ms]);
return debounced;
}
// Usage — autocomplete
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Alternative React 18+ : useDeferredValue
Pour différer une valeur de rendu (pas un appel API), React 18 propose useDeferredValue qui s'intègre nativement au concurrent rendering. Il diffère automatiquement la valeur pendant les transitions sans utiliser de timer explicite.
Bibliothèques tierces : lodash, use-debounce, ahooks
Si vous ne voulez pas écrire votre propre debounce, plusieurs libs éprouvées existent. lodash.debounce et lodash.throttle sont la référence historique, importables individuellement pour éviter d'embarquer tout Lodash (gain de 60 ko gzipped). use-debounce est spécifique React et propose des hooks prêts à l'emploi. ahooks couvre les deux et propose une useThrottleFn bien testée. Toutes ces libs gèrent cancel et flush nativement.
Angular : Signals, RxJS et le pattern Observable
Sur un input Angular qui doit déclencher un appel API debouncé, la voie recommandée combine FormControl.valueChanges avec debounceTime.
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<input [formControl]="searchCtrl" placeholder="Rechercher…" />
@if (results(); as items) {
<ul>@for (item of items; track item.id) { <li>{{ item.name }}</li> }</ul>
}
`,
})
export class SearchComponent {
private readonly api = inject(SearchApi);
searchCtrl = new FormControl('', { nonNullable: true });
// Signal de résultats — debounce + distinct + switchMap
results = toSignal(
this.searchCtrl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => q ? this.api.search(q) : Promise.resolve([])),
takeUntilDestroyed(),
),
{ initialValue: [] }
);
}
Cette combinaison est l'archétype du pattern recherche en Angular moderne. switchMap annule la requête précédente si une nouvelle frappe arrive — c'est ce qui empêche les conditions de course (résultats anciens écrasant les nouveaux). distinctUntilChanged évite de relancer la requête si la valeur n'a pas changé (utile quand l'input perd puis regagne le focus).
Pour les cas où vous travaillez directement avec un Signal sans passer par RxJS, vous pouvez créer un signal debouncé maison à l'aide de effect() + setTimeout. Mais sur les inputs utilisateurs avec validation ou appels API, le pattern FormControl + RxJS reste plus expressif et mieux testé.
Cas d'usage par scénario réel
- Autocomplete / recherche live — debounce 300 ms + distinctUntilChanged + switchMap pour annuler l'ancien appel.
- Sauvegarde automatique d'un éditeur — debounce 1000 ms + flush() à la fermeture de page (event
beforeunload). - Validation de formulaire en direct — debounce 500 ms pour ne pas valider à chaque frappe.
- Resize de fenêtre — debounce 150 ms pour les recalculs lourds, ou rAF pour les ajustements visuels légers.
- Sticky header — rAF (préféré) ou throttle 100 ms.
- Infinite scroll / lazy load — throttle 200 ms pour vérifier la position sans saturer.
- Drag-and-drop — toujours rAF, l'animation reste fluide.
- Analytics scroll-depth — throttle 1000 ms pour ne pas inonder le service de métriques.
- Bouton anti-double-click — debounce leading 500 ms.
- Heartbeat WebSocket — throttle 30 000 ms pour limiter les pings à 1/30s.
Tester un debounce avec vi.useFakeTimers
// debounce.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce';
describe('debounce', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('n'exécute qu'une fois pour une rafale', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced(); debounced(); debounced(); debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledOnce();
});
it('cancel annule l'appel en attente', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
debounced.cancel();
vi.advanceTimersByTime(500);
expect(fn).not.toHaveBeenCalled();
});
it('flush exécute immédiatement', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced('hello');
debounced.flush();
expect(fn).toHaveBeenCalledWith('hello');
});
});
vi.useFakeTimers() remplace setTimeout, setInterval, Date.now() par des versions virtuelles. vi.advanceTimersByTime(ms) fait avancer le temps virtuel sans attendre réellement. Les tests deviennent déterministes et rapides — une suite de 50 tests de debounce s'exécute en moins d'une seconde.
Pour les tests RxJS, l'équivalent est TestScheduler avec sa syntaxe « marble » qui permet de décrire visuellement les Observables : cold(' -a-b-c|'). Combiné à debounceTime, vous testez la latence exacte attendue entre l'émission de la source et celle de l'observable debouncé, sans aucun setTimeout réel.
Pièges et bonnes pratiques
- Exposer
cancel()etflush()sur vos wrappers debounce/throttle. - Annuler le debounce/throttle au démontage du composant (cleanup React, takeUntilDestroyed Angular).
- Utiliser
{ passive: true }sur les listeners scroll/touch. - Préférer
requestAnimationFramepour tout ce qui touche au DOM/CSS. - Combiner
debounceTimeavecdistinctUntilChangedetswitchMapdans RxJS. - Tester avec
vi.useFakeTimerspour des specs déterministes.
- Recréer un debounce à chaque rendu React — il faut le déclarer une fois avec
useMemoouuseCallback, sinon il ne fonctionne pas. - Oublier
cancel()au cleanup — fuite mémoire et appel sur un composant démonté. - Confondre
throttleetdebouncesur un autocomplete — vous obtenez des résultats partiels et des requêtes inutiles. - Utiliser un délai trop faible (< 100 ms) sur un debounce d'autocomplete — pas assez pour filtrer les frappes.
- Stocker un debounce dans un singleton global partagé entre utilisateurs — collisions assurées.
Mini-projet appliqué — recherche autocomplete production-ready
Pour ancrer tous les patterns vus dans un cas concret, voici une recherche autocomplete complète qui combine debounce 300 ms + annulation des requêtes obsolètes + cache LRU + métriques de performance. C'est exactement le pattern qu'on retrouve dans les barres de recherche d'Algolia, Notion, Linear et tous les SaaS modernes — typiquement < 100 lignes de code mais robustes en production.
1. Cache LRU léger pour éviter les requêtes répétées
Pour le détail des closures qui encapsulent l'état du cache, voir le guide des closures JavaScript.
// Cache LRU minimal — 50 dernières recherches
function createLRUCache(maxSize = 50) {
const cache = new Map();
return {
get(key) {
if (!cache.has(key)) return undefined;
// Move-to-end : marqueur d'usage récent
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
},
set(key, value) {
if (cache.has(key)) cache.delete(key);
else if (cache.size >= maxSize) {
// Évincer le premier (le plus ancien)
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
},
get size() { return cache.size; },
clear() { cache.clear(); },
};
}
2. Hook useSearch — debounce + AbortController + cache
import { useState, useEffect, useRef, useCallback } from 'react';
function useSearch({
fetcher, // (q: string, signal: AbortSignal) => Promise<T[]>
debounceMs = 300,
minLength = 2,
cacheSize = 50,
} = {}) {
const [state, setState] = useState({
query: '',
results: [],
loading: false,
error: null,
metrics: { lastFetchMs: 0, cacheHits: 0, cacheMisses: 0 },
});
// Refs stables pour éviter les stale closures
const cacheRef = useRef(createLRUCache(cacheSize));
const debounceTimerRef = useRef(null);
const abortControllerRef = useRef(null);
const search = useCallback((query) => {
// 1. Annuler le timer de debounce précédent
clearTimeout(debounceTimerRef.current);
// 2. Annuler la requête HTTP en cours (si existe)
abortControllerRef.current?.abort();
// 3. Mise à jour optimiste de la query
setState(prev => ({ ...prev, query, error: null }));
// 4. Skip les queries trop courtes
if (query.length < minLength) {
setState(prev => ({ ...prev, results: [], loading: false }));
return;
}
// 5. Cache hit ? Retour instantané
const cached = cacheRef.current.get(query);
if (cached) {
setState(prev => ({
...prev,
results: cached,
loading: false,
metrics: { ...prev.metrics, cacheHits: prev.metrics.cacheHits + 1 },
}));
return;
}
// 6. Debounce + fetch async
debounceTimerRef.current = setTimeout(async () => {
const controller = new AbortController();
abortControllerRef.current = controller;
setState(prev => ({ ...prev, loading: true }));
const start = performance.now();
try {
const results = await fetcher(query, controller.signal);
const elapsed = performance.now() - start;
cacheRef.current.set(query, results);
setState(prev => ({
...prev,
results,
loading: false,
metrics: {
lastFetchMs: Math.round(elapsed),
cacheHits: prev.metrics.cacheHits,
cacheMisses: prev.metrics.cacheMisses + 1,
},
}));
} catch (e) {
if (e.name === 'AbortError') return; // ignorer l'annulation
setState(prev => ({ ...prev, loading: false, error: e.message }));
}
}, debounceMs);
}, [fetcher, debounceMs, minLength]);
// Cleanup à la destruction du composant
useEffect(() => () => {
clearTimeout(debounceTimerRef.current);
abortControllerRef.current?.abort();
}, []);
return { ...state, search };
}
3. Composant React final — UX réactive et fluide
function SearchBar() {
const { query, results, loading, error, metrics, search } = useSearch({
fetcher: async (q, signal) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { items } = await res.json();
return items;
},
debounceMs: 300,
minLength: 2,
});
return (
<div className="search">
<input
type="search"
value={query}
onChange={(e) => search(e.target.value)}
placeholder="Rechercher..."
aria-busy={loading}
/>
{loading && <div className="spinner" />}
{error && <div className="error">{error}</div>}
<ul className="results">
{results.map(item => <li key={item.id}>{item.title}</li>)}
</ul>
{/* Métriques en mode dev */}
{process.env.NODE_ENV === 'development' && (
<small>
{metrics.cacheHits} hits / {metrics.cacheMisses} misses ·
dernier fetch : {metrics.lastFetchMs} ms
</small>
)}
</div>
);
}
4. Version Angular avec Signals + RxJS
Pour les patterns Signals Angular en production, lire le guide complet des Angular Signals.
import { Component, signal, inject, effect, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, debounceTime, distinctUntilChanged, switchMap, of, catchError } from 'rxjs';
@Component({ /* ... */ })
export class SearchComponent {
private destroyRef = inject(DestroyRef);
private query$ = new Subject<string>();
readonly query = signal('');
readonly results = signal<Item[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
private cache = createLRUCache(50);
constructor() {
// RxJS pipeline : debounce + dedup + switchMap (annule la requête précédente)
this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => {
if (q.length < 2) return of([]);
const cached = this.cache.get(q);
if (cached) return of(cached);
this.loading.set(true);
return fetch(`/api/search?q=${q}`).then(r => r.json()).then(d => {
this.cache.set(q, d.items);
return d.items;
});
}),
catchError(e => { this.error.set(e.message); return of([]); }),
takeUntilDestroyed(this.destroyRef),
).subscribe(results => {
this.results.set(results);
this.loading.set(false);
});
}
onInput(value: string) {
this.query.set(value);
this.query$.next(value);
}
}
/api/search. Détail : (1) le debounce 300 ms supprime ~85 % des frappes intermédiaires, (2) le cache LRU couvre ~40 % des requêtes (queries fréquentes), (3) l'annulation via AbortController retire ~15 % de requêtes serveur inutiles. Le coût frontend reste négligeable : ~2 KB de logique JS.
5. Tests unitaires avec vi.useFakeTimers
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useSearch } from './useSearch';
describe('useSearch', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('debounce 300ms avant de fetch', async () => {
const fetcher = vi.fn().mockResolvedValue([{ id: '1', title: 'test' }]);
const { result } = renderHook(() => useSearch({ fetcher, debounceMs: 300 }));
act(() => result.current.search('angular'));
expect(fetcher).not.toHaveBeenCalled(); // pas encore appelé
await act(async () => { vi.advanceTimersByTime(299); });
expect(fetcher).not.toHaveBeenCalled(); // 299ms : toujours pas
await act(async () => { vi.advanceTimersByTime(1); });
expect(fetcher).toHaveBeenCalledWith('angular', expect.any(AbortSignal));
});
it('annule la requête précédente quand une nouvelle arrive', async () => {
const fetcher = vi.fn().mockImplementation((q, signal) =>
new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('abort'))))
);
const { result } = renderHook(() => useSearch({ fetcher }));
act(() => result.current.search('first'));
await act(async () => { vi.advanceTimersByTime(300); });
act(() => result.current.search('second'));
await act(async () => { vi.advanceTimersByTime(300); });
// Vérifier que la première fetch a été abort
const firstCall = fetcher.mock.calls[0];
expect(firstCall[1].aborted).toBe(true);
});
});
Pour pousser ce pattern à l'échelle (recherche multi-source, instant search avec préfetch, scoring de pertinence), lire également le guide du destructuring qui permet d'écrire proprement les hooks à plusieurs retours, et le guide TypeScript infer + conditional types pour typer useSearch avec inférence automatique du type des résultats selon le fetcher.
Conclusion
Debounce et throttle sont les deux outils universels pour transformer une UI saturée en interface fluide. Vingt lignes de code chacun, des dizaines d'usages quotidiens en frontend. La règle de choix tient en une phrase : « debounce pour attendre la fin, throttle pour limiter la cadence ». Pour tout ce qui touche au DOM, requestAnimationFrame est encore meilleur — il aligne sur les frames du navigateur, économise la batterie, et reste fluide jusqu'à 120 fps sur les écrans modernes.
Sur les projets Angular ou React modernes, n'écrivez plus de debounce vanilla — utilisez RxJS (debounceTime, throttleTime, auditTime) ou un hook React (useDebounce, useDeferredValue). Ces APIs gèrent automatiquement le cleanup à la destruction du composant, vous évitent les fuites mémoire, et s'intègrent au cycle de change detection. La connaissance des implémentations vanilla reste précieuse pour comprendre ce qui se passe sous le capot et écrire des hooks ou décorateurs custom quand le besoin sort des cas standards.
Le réflexe pratique à acquérir : à chaque fois que vous écrivez un addEventListener('scroll', ...), addEventListener('input', ...), ou un handler qui appelle une API ou met à jour le DOM, demandez-vous immédiatement « est-ce que je dois debouncer ou throttler ? ». Dans 80 % des cas, la réponse est oui — et les 20 % restants sont les cas où l'événement est intrinsèquement peu fréquent (clic, submit). Adopter ce réflexe transforme la performance perçue de vos applications.
- Choisir debounce pour « attendre la fin », throttle pour « limiter la cadence »
- Préférer
requestAnimationFramedès que le DOM ou le visuel sont concernés - Toujours exposer
cancel()etflush()sur les wrappers - Annuler au cleanup React / via
takeUntilDestroyeden Angular - Ajouter
{ passive: true }sur les listenersscroll,touch*,wheel - Combiner
debounceTime+distinctUntilChanged+switchMapdans RxJS pour les recherches - En React, mémoïser le debounce avec
useMemoouuseCallback - Tester avec
vi.useFakeTimers+vi.advanceTimersByTimepour des specs déterministes - Délais typiques : 300 ms autocomplete, 500 ms validation, 1000 ms autosave, 100 ms scroll throttle
- Combiner debounce leading + trailing pour des UX « réactives mais filtrées »