Un buscador que lanza una petición en cada pulsación manda cinco peticiones para “redis”, y cuatro llegan obsoletas antes de que vuelva la respuesta. Un handler de scroll que recalcula el layout se ejecuta cientos de veces por segundo y hace que la página se trabe. El mismo problema en los dos casos: el evento del DOM se dispara mucho más a menudo de lo que el handler necesita ejecutarse. Debounce y throttle lo resuelven de dos formas distintas.

Qué hace el debounce y qué hace el throttle

El debounce espera a que el evento deje de dispararse durante un periodo fijo, y entonces se ejecuta una vez. Cada llamada nueva reinicia el reloj. Si el evento nunca se detiene, la función nunca se ejecuta. El campo de búsqueda es el caso típico: espera a que el usuario haga una pausa al escribir, y entonces manda una petición con lo último que escribió.

El throttle limita la frecuencia con la que puede ejecutarse la llamada. Durante una ráfaga sigue ejecutándose, como máximo una vez por intervalo. Úsalo para scroll, donde la página debe seguir actualizándose mientras el usuario desplaza, solo que no en cada evento individual.

DebounceThrottle
Se ejecuta durante la ráfagaNo hasta que terminaSí, como máximo una vez por intervalo
Qué obtienesLa última llamada de la ráfagaUna muestra constante durante toda la ráfaga
Uso típicoCampo de búsqueda, validación de formularios, guardado al escribirscroll, resize, mousemove, drag
Si la ráfaga nunca terminaNunca se ejecutaSigue ejecutándose según el intervalo

Los dos son wrappers. Pasas tu callback y un retraso, y recibes a cambio una función con la misma firma, que conectas al evento en lugar de la original.

Cómo escribir una función 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);
  };
}

Cada llamada cancela el setTimeout pendiente y programa uno nuevo, así que solo la última llamada de la ráfaga vive lo bastante para dispararse. El tiempo sigue las reglas de setTimeout explicadas en cómo el event loop de JavaScript programa las callbacks: la callback nunca se ejecuta antes de delay, y puede ejecutarse más tarde si el call stack está ocupado cuando expira el temporizador.

Conectado a un campo de búsqueda:

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));

Escribe “redis” a un ritmo normal y la consola muestra una sola línea, searching for: redis, unos 300ms después de la última pulsación. No cinco líneas para cinco letras.

Cómo escribir una función 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);
    }
  };
}

Esta es la versión leading-edge. La primera llamada de una ventana se ejecuta de inmediato; todo lo que llega después se descarta hasta que pasan interval milisegundos.

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

window.addEventListener("scroll", onScroll);

Desplázate sin parar durante dos segundos y obtienes unas diez líneas de log repartidas de forma uniforme, en lugar de los cientos de eventos scroll que el navegador dispara en realidad.

Opciones de debounce y throttle en lodash: leading, trailing, maxWait

Cada función escrita a mano arriba fija una sola opción. El debounce solo se dispara en el trailing edge, tras el periodo de calma. El throttle solo se dispara en el leading edge, en el instante en que se abre una ventana, así que la última llamada de una ráfaga se pierde si cae a mitad de ventana. Eso importa en scroll: la última posición donde se detuvo el usuario puede no llegar a procesarse nunca.

_.debounce(fn, wait, options) y _.throttle(fn, wait, options) de lodash hacen configurables los dos bordes con los booleanos leading y trailing, y los valores por defecto son distintos. El debounce usa por defecto leading: false, trailing: true: espera, y entonces se dispara. El throttle usa por defecto los dos en true: se dispara de inmediato, y otra vez al final de una ráfaga que dure más de un intervalo. Por dentro, el throttle de lodash es su propio debounce con maxWait fijado al intervalo, así que los dos están más cerca de lo que sugieren sus nombres.

maxWait es una opción del debounce que limita cuánto puede retrasarse la primera llamada. Sin ella, alguien que escribe de forma constante con pausas más cortas que el retraso nunca dispara el handler, ni siquiera tras diez segundos de actividad. Con maxWait: 2000, se dispara al menos cada dos segundos.

El debounce solo-leading cubre el caso del doble envío. _.debounce(sendMail, 300, { leading: true, trailing: false }) se ejecuta en el primer clic e ignora los siguientes hasta que pasan 300ms sin ningún clic. El primer clic cuenta; el resto se descartan, no se retrasan.

Qué eventos necesitan debounce y cuáles throttle

EventoEligePor qué
Búsqueda mientras se escribeDebounceSolo importa la consulta final; las pulsaciones anteriores generan peticiones que acabarías descartando
resize de la ventanaDebounce o throttleDebounce si solo necesitas el tamaño final (recalcular el layout una vez); throttle si la interfaz debe seguir visiblemente el redimensionado
scrollThrottleLa página debe seguir respondiendo durante todo el desplazamiento, no solo cuando el usuario se detiene
mousemove (drag, tooltip que sigue al cursor)Throttle, con requestAnimationFrameActualizaciones más rápidas que la frecuencia de refresco de la pantalla son trabajo perdido
Clic en un botón (evitar doble envío)Debounce solo-leading o deshabilitar al clicEl primer clic debe contar; el resto deben ignorarse, no retrasarse
Autoguardado en cada pulsaciónDebounce con maxWaitGuarda tras una pausa, pero garantiza un guardado durante una escritura continua larga

Cuando el handler mueve algo visual (un drag, un tooltip que sigue al cursor, una animación ligada al scroll), sustituye Date.now por 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;
    });
  };
}

La frecuencia de las llamadas ahora sigue el ritmo de pintado del navegador en lugar de una cifra en milisegundos elegida al azar. La actualización se ejecuta una vez por frame, justo antes de que se dibuje, así que no puede ejecutarse más a menudo de lo que nadie puede ver y no se desincroniza del renderizado cuando el hilo principal está sobrecargado.

Errores comunes con debounce y throttle

Crear la función debounced dentro del handler. En React, onChange={(e) => debounce(handleSearch, 300)(e.target.value)} llama a debounce() en cada pulsación, y cada llamada devuelve un wrapper nuevo con su propio timer vacío. Nunca hay un temporizador anterior que cancelar, así que cada pulsación sigue disparando una petición, solo que 300ms más tarde. Crea la función debounced una sola vez (useMemo, useRef, o en el ámbito del módulo fuera de un componente) y llama siempre a esa misma instancia.

No cancelar al desmontar. Un autoguardado debounced que se dispara después de que el usuario navegó a otra parte escribe en un componente que ya no existe, o guarda desde una closure obsoleta. Las funciones debounced y throttled de lodash ya tienen un método .cancel(); para el debounce escrito a mano, añade uno que llame a clearTimeout(timer). Llámalo cuando el componente se desmonte o la página se cierre.

Probar con temporizadores reales. Esperar un debounce con await new Promise(r => setTimeout(r, 300)) funciona, pero añádelo a cada test de tiempos y la suite se vuelve tan lenta que la gente deja de ejecutarla en local. Usa temporizadores falsos: vi.useFakeTimers() en Vitest o jest.useFakeTimers() en Jest, y avanza el reloj con vi.advanceTimersByTime(300). Un temporizador falso es un test double para el reloj, no para tu código, la distinción que se traza en pruebas unitarias vs de integración vs end-to-end.

Aplicar debounce a algo que debe sentirse en vivo. Una barra de progreso o un tirador de drag que solo se mueve cuando el usuario se detiene se percibe como roto. Si el requisito es “actualiza con fluidez mientras esto sigue pasando”, lo que hace falta es throttle.

¿Usar debounce o throttle por defecto?

Usa debounce cuando solo importa el estado final: campos de búsqueda, autoguardado, validar un campo cuando el usuario deja de escribir. Usa throttle cuando el usuario espera ver progreso mientras algo continúa, como el scroll, el resize o el drag, y cambia a la versión con requestAnimationFrame cuando el resultado es una actualización visual.

En producción, importa debounce y throttle de una librería mantenida (lodash, o un hook como use-debounce en React) en lugar de usar las versiones de arriba. Existen para mostrar el mecanismo. Las versiones de librería ya gestionan la cancelación, maxWait, las llamadas trailing y el binding de this, justo donde una copia hecha a mano suele romperse.

Artículos relacionados