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.
| Debounce | Throttle | |
|---|---|---|
| Se ejecuta durante la ráfaga | No hasta que termina | Sí, como máximo una vez por intervalo |
| Qué obtienes | La última llamada de la ráfaga | Una muestra constante durante toda la ráfaga |
| Uso típico | Campo de búsqueda, validación de formularios, guardado al escribir | scroll, resize, mousemove, drag |
| Si la ráfaga nunca termina | Nunca se ejecuta | Sigue 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
| |
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:
| |
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
| |
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.
| |
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
| Evento | Elige | Por qué |
|---|---|---|
| Búsqueda mientras se escribe | Debounce | Solo importa la consulta final; las pulsaciones anteriores generan peticiones que acabarías descartando |
resize de la ventana | Debounce o throttle | Debounce si solo necesitas el tamaño final (recalcular el layout una vez); throttle si la interfaz debe seguir visiblemente el redimensionado |
scroll | Throttle | La 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 requestAnimationFrame | Actualizaciones 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 clic | El primer clic debe contar; el resto deben ignorarse, no retrasarse |
| Autoguardado en cada pulsación | Debounce con maxWait | Guarda 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:
| |
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.