Funciones Debounce y Throttle en JavaScript
Controla la tasa de ejecución de funciones con debounce y throttle. Cubre leading y trailing edge, timers cancelables y casos de uso reales.
Descripción General
Debounce y throttle evitan que eventos rápidos saturen tu código. Debounce espera a que la actividad se detenga antes de ejecutar la función. Throttle ejecuta la función a lo sumo una vez por intervalo, aunque el evento siga disparándose. Ambos son útiles para manejadores de scroll, resize, input y mousemove.
Vi equipos que publicaban sin esto y después no entendían por qué su página de búsqueda disparaba 30 llamadas a la API por segundo. El fix suele ser una línea — pero elegir el equivocado (debounce cuando querías throttle, o viceversa) empeora las cosas en lugar de mejorarlas. Yo cometí ese error al principio, así que lo escribo para ahorrarte la sesión de debugging.
Cuándo Usar
- Debounce: campos de búsqueda, autoguardado, redimensionado de ventana. Esperar hasta que el usuario se detenga.
- Throttle: posición de scroll, movimiento del mouse, clics repetidos. Ejecutar a una tasa fija.
- Tenés un evento que se dispara muchas veces por segundo y provoca trabajo costoso.
Para alternativas, consultá la Guía Completa de Optimización del Tamaño del Bundle. Si estás construyendo infinite scroll, throttle suele ser lo que necesitás para el manejador de scroll.
Solución
La idea central: envolvé una función en otra que decide cuándo se ejecuta la interna. Debounce resetea un timer en cada llamada; throttle verifica el tiempo transcurrido y saltea si es muy pronto.
Debounce básico
function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// Uso — campo de búsqueda
const handleSearch = debounce((query) => {
console.log("Searching for:", query);
fetchResults(query);
}, 300);
input.addEventListener("input", (e) => handleSearch(e.target.value));
Throttle básico
function throttle(fn, interval) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= interval) {
fn.apply(this, args);
lastTime = now;
}
};
}
// Uso — manejador de scroll
const handleScroll = throttle(() => {
console.log("Scroll position:", window.scrollY);
}, 100);
window.addEventListener("scroll", handleScroll);
Debounce con leading edge
function debounceLeading(fn, delay) {
let timeoutId;
let called = false;
return function (...args) {
if (!called) {
fn.apply(this, args);
called = true;
}
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
called = false;
}, delay);
};
}
// Se dispara inmediatamente en la primera llamada, luego ignora hasta que haya silencio por delay ms
const handleDoubleClick = debounceLeading(() => {
console.log("Action triggered");
}, 500);
Debounce con opciones leading y trailing
function debounceAdvanced(fn, delay, { leading = false, trailing = true } = {}) {
let timeoutId;
let lastArgs;
let invoked = false;
return function (...args) {
lastArgs = args;
const shouldInvokeLeading = leading && !invoked;
if (shouldInvokeLeading) {
fn.apply(this, args);
invoked = true;
}
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (trailing && (!leading || invoked)) {
fn.apply(this, lastArgs);
}
invoked = false;
}, delay);
};
}
// Solo leading — disparar inmediatamente, luego ignorar
const onClick = debounceAdvanced(saveData, 1000, { leading: true, trailing: false });
// Solo trailing — disparar después del periodo de silencio (default)
const onInput = debounceAdvanced(searchApi, 300, { leading: false, trailing: true });
// Ambos — disparar inmediatamente y otra vez después del silencio
const onResize = debounceAdvanced(layoutCalc, 200, { leading: true, trailing: true });
Throttle con trailing edge
function throttleTrailing(fn, interval) {
let lastTime = 0;
let timeoutId;
let lastArgs;
return function (...args) {
const now = Date.now();
const remaining = interval - (now - lastTime);
lastArgs = args;
if (remaining <= 0) {
clearTimeout(timeoutId);
timeoutId = null;
lastTime = now;
fn.apply(this, args);
} else if (!timeoutId) {
timeoutId = setTimeout(() => {
lastTime = Date.now();
timeoutId = null;
fn.apply(this, lastArgs);
}, remaining);
}
};
}
// Se dispara máximo una vez por intervalo, con una llamada final después de que la actividad se detiene
const onMouseMove = throttleTrailing(updatePosition, 50);
Debounce y throttle cancelables
function debounceCancelable(fn, delay) {
let timeoutId;
const debounced = function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
debounced.cancel = () => {
clearTimeout(timeoutId);
timeoutId = null;
};
debounced.flush = (...args) => {
clearTimeout(timeoutId);
fn.apply(this, args);
};
return debounced;
}
// Uso
const save = debounceCancelable(autosave, 1000);
input.addEventListener("input", () => save());
button.addEventListener("click", () => save.cancel()); // Cancelar save pendiente
Práctico: autosave con debounce
class AutoSave {
constructor(saveFn, delay = 2000) {
this.save = debounceCancelable(saveFn, delay);
}
onChange(data) {
this.save(data);
}
forceSave(data) {
this.save.flush(data);
}
cancel() {
this.save.cancel();
}
}
const autosave = new AutoSave(async (data) => {
const response = await fetch("/api/save", {
method: "POST",
body: JSON.stringify(data),
});
console.log("Saved:", await response.json());
});
editor.addEventListener("input", () => autosave.onChange(editor.value));
window.addEventListener("beforeunload", () => autosave.forceSave(editor.value));
Práctico: progreso de scroll con throttle
const updateScrollProgress = throttle(() => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = (scrollTop / docHeight) * 100;
document.querySelector(".progress-bar").style.width = `${progress}%`;
}, 16); // ~60fps
window.addEventListener("scroll", updateScrollProgress, { passive: true });
Explicación
Debounce resetea un timer cada vez que el evento se dispara. La función
envuelta solo se ejecuta después de que el flujo de llamadas esté quieto por
delay milisegundos. Esa es la herramienta que buscás cuando querés esperar a que
el usuario termine de escribir, hacer scroll o redimensionar.
Throttle ejecuta la función en la primera llamada y luego saltea las demás
hasta que pasen interval milisegundos. Usalo cuando querés actualizaciones
periódicas y estables en lugar de esperar una pausa.
Leading edge significa que la primera llamada se ejecuta inmediatamente; las siguientes se ignoran o reprograman. Trailing edge significa que una llamada final se ejecuta después del periodo de silencio, usando los argumentos más recientes.
| Técnica | Se Dispara Cuando | Caso de Uso |
|---|---|---|
| Debounce (trailing) | Después de que la actividad se detiene | Búsqueda, autoguardado |
| Debounce (leading) | Inmediatamente, luego espera | Proteger clics de botón |
| Throttle | A lo sumo una vez por intervalo | Scroll, mousemove |
| Throttle (trailing) | Una vez por intervalo + final | Scroll con última posición |
Una cosa que confunde a la gente: debounce y throttle no son intercambiables. Una vez revisé un PR que cambiaba throttle por debounce en un manejador de scroll “para hacerlo más suave.” El resultado fue lo contrario. El handler no se disparaba hasta que el scroll se detenía, así que la barra de progreso saltaba en bloques grandes. La documentación de MDN sobre setTimeout explica el comportamiento del timer en el que ambos patrones se basan. Para una implementación probada en producción, el debounce y throttle de Lodash manejan casos borde (leading/trailing, max wait, cancel) que las versiones básicas de arriba saltean.
Lo aprendí por las malas en un proyecto de dashboard. Teníamos un listener de resize que recalculaba las dimensiones de los gráficos, y alguien lo “optimizó” con throttle a 500ms. Los gráficos se quedaban medio segundo atrás en cada redimensión de ventana. Cambiar a un debounce de 150ms lo arregló al instante. La lección: matcheá el patrón a la expectativa del usuario, no a lo que suena más rápido.
Variantes
| Patrón | Comportamiento | Ejemplo |
|---|---|---|
| Debounce | Retrasar hasta silencio | Campo de búsqueda |
| Throttle | Rate limit a intervalo | Manejador de scroll |
| RequestAnimationFrame | Sync con repaint | Animaciones |
| IntersectionObserver | Callback en visibilidad | Lazy loading |
Mejores Prácticas
Usá debounce cuando necesites el valor final, como en un campo de búsqueda, una
rutina de autoguardado o un manejador de redimensionado. Usá throttle cuando
quieras actualizaciones regulares, como una barra de progreso de scroll o un
rastreador de posición del mouse. Para actualizaciones visuales sincronizadas con
el repaint del navegador, usá requestAnimationFrame en lugar de throttle.
Siempre limpiá los timers cuando un componente se desmonte. El cleanup de
useEffect en React y onUnmounted en Vue son buenos lugares. Agregá
{ passive: true } a los listeners de scroll y touch para que el navegador no
bloquee el main thread. Usá leading edge para clics de botón, así das feedback
inmediato, y trailing edge para campos de búsqueda, así capturás el último query.
Testeá con input rápido para asegurarte de que la función no se dispare
demasiado a menudo.
También te recomiendo extraer el wrapper de debounce/throttle a un archivo de
utilidad compartida en lugar de copy-pastearlo en cada componente. Así, cuando
encuentres un bug en la implementación (y vas a encontrar), lo corregís una sola
vez. Si estás trabajando con formularios en
React, un custom hook
useDebounce mantiene la lógica reutilizable y testeable. Un tip más: logeá el
conteo de llamadas en desarrollo. Si ves 50 llamadas para 10 keystrokes, tu
wrapper no está funcionando y lo vas a atrapar antes que los usuarios.
Errores Comunes
Usar debounce para scroll es un error común: el handler no se disparará mientras
el usuario siga haciendo scroll, así que la UI se siente congelada. Throttle es
mejor ahí. Usar throttle para un campo de búsqueda también está mal porque la API
se llama mientras el usuario sigue escribiendo; debounce es el caso adecuado.
Siempre limpiá los timers, porque los timeouts pendientes pueden dispararse
después del unmount y generar errores. En throttle, no te olvides de verificar
remaining, o la función puede dispararse en momentos raros. Saltearse
{ passive: true } en listeners de scroll puede bloquear el scroll. Y si te
olvidás de pasar this y args, la función envuelta pierde contexto y
argumentos. Por último, no elijas un delay de debounce muy largo. El usuario va
a pensar que la app se rompió. Mantené los delays de feedback de UI bajo un
segundo.
Otro que vi seguido: usar Date.now() para el timing de throttle en un entorno
de test donde simulás el clock. El throttle nunca se dispara porque el mock no
avanza. Usá una inyección de clock configurable o testeá con timers reales e
intervalos chicos. Tampoco apliques debounce a trabajo impulsado por
requestAnimationFrame. Estás doble-gateando algo que ya se sincroniza con el
refresh del display. Lo atrapé en un code review el mes pasado. Alguien envolvió
un loop de rAF en un throttle de 16ms “por seguridad” y terminó dropeando la
mitad de los frames.
Resumen
Debounce espera una pausa, throttle impone una tasa. Elegí debounce para
búsqueda, autoguardado y resize, cualquier cosa donde quieras el valor final.
Elegí throttle para scroll, mousemove y clics repetidos, cualquier cosa donde
querés actualizaciones estables. Leading edge dispara inmediatamente; trailing
edge dispara después del periodo de silencio. Siempre limpiá los timers en el
unmount, pasá this y args, y usá requestAnimationFrame para trabajo visual.
Para código de producción, recurrí a las implementaciones probadas de Lodash
en lugar de armar tus propios casos borde.
Ver También
- Paginación Infinite Scroll en JavaScript: throttle es la herramienta correcta para scroll-driven loading
- Validación de Formularios en React con React Hook Form: aplicá debounce a inputs de búsqueda en componentes con muchos formularios
- MDN: setTimeout: la API de timer en la que debounce y throttle se basan
- Lodash debounce: implementación de producción con leading, trailing y max wait options
- MDN: requestAnimationFrame: la herramienta correcta para actualizaciones visuales sincronizadas con el refresh del display
Preguntas frecuentes
¿Cuál es la diferencia entre debounce y throttle?
Debounce espera a que el usuario deje de disparar el evento, luego se ejecuta una vez. Throttle se ejecuta a lo sumo una vez por intervalo sin importar cuántas veces se dispare el evento. Usá debounce cuando querés esperar a que el usuario termine, y throttle cuando querés limitar la tasa. Lo pienso así: debounce es "esperar silencio," throttle es "capar la velocidad."
¿Debería usar debounce o throttle para window resize?
Usá debounce. Querés recalcular el layout después de que el usuario termina de redimensionar, no en cada cambio de píxel. Un debounce de 150-200ms funciona bien. Usé 200ms en todos los proyectos y nunca tuve un complaint.
¿Cómo implemento debounce en React?
Usá un custom hook con useRef para almacenar el timeout. Guardá la referencia de
la función en otro useRef y limpiá el timeout en el cleanup effect:
function useDebounce(fn, delay) {
const timeoutRef = useRef(null);
const fnRef = useRef(fn);
fnRef.current = fn;
const debounced = useCallback((...args) => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => fnRef.current(...args), delay);
}, [delay]);
useEffect(() => () => clearTimeout(timeoutRef.current), []);
return debounced;
}
¿Puedo usar requestAnimationFrame en lugar de throttle?
Sí, para actualizaciones visuales. requestAnimationFrame se sincroniza con el
ciclo de repaint del navegador (~60fps). Es más suave que throttle para
animaciones y actualizaciones visuales basadas en scroll. Dejá throttle para
trabajo no visual como llamadas a API.
¿Cuál es la diferencia entre debounce con leading y trailing edge?
El debounce con leading edge dispara la función inmediatamente en la primera
llamada, luego ignora llamadas subsecuentes hasta que expire el periodo de espera.
El debounce con trailing edge (el default) dispara después del periodo de espera
sin nuevas llamadas. Uso leading para eventos de click donde querés feedback
inmediato, trailing para búsquedas mientras escribís donde querés el último valor.
Lodash soporta ambos con { leading: true, trailing: false }. La combinación que
más uso es ambos edges en un manejador de resize: leading para que la UI se
actualice al instante, trailing para que atrape el tamaño final.
¿Debería cancelar llamadas debounce pendientes en el unmount?
Sí. Siempre limpiá el timeout en una función de cleanup (return de useEffect)
para prevenir actualizaciones de estado después de que el componente se desmonte.
Esto evita memory leaks y warnings de React sobre setear estado en un componente
desmontado.
Recursos Relacionados
Paginación con Scroll Infinito en JavaScript con
Implementa carga de datos basada en scroll en JavaScript con IntersectionObserver
RecipeLocalStorage con Expiración TTL en JavaScript
Almacena datos con expiración TTL en el localStorage del navegador
GuideGuía de Optimización de Performance Web
manual detallado para optimizar el rendimiento de aplicaciones web con mejores Core Web Vitals y experiencia de usuario.
GuideOptimización del Bundle JS: Guía Práctica Frontend
Guía práctica para medir y reducir el tamaño del bundle JavaScript con tree shaking, code splitting, reemplazo de dependencias, compresión y budgets en CI.
GuideOptimización de Performance en React
Optimiza apps de React para velocidad. Cubre memoization, virtualization, code splitting, bundle analysis, React Profiler, concurrent features y Core Web Vitals.
RecipeValidación de Formularios con react-hook-form y Zod
Construí formularios type-safe en React con react-hook-form y Zod: campos anidados, reglas async, campos dinámicos e integración con UI.