Debounce y throttle en JavaScript: controlar la frecuencia de eventos

Debounce y throttle son dos técnicas para controlar la frecuencia con la que se ejecuta una función en respuesta a eventos de alta frecuencia, como el scroll, el resize o la escritura en un input. Sin ellas, un handler de scroll puede ejecutarse cientos de veces por segundo, degradando el rendimiento de la aplicación.

Debounce: esperar a que el usuario pare

Debounce retrasa la ejecución hasta que han pasado N milisegundos desde la última llamada. Si la función se invoca de nuevo antes de que expire el temporizador, este se reinicia:

function debounce(fn, delay) {
  let timer = null;

  return function(...args) {
    clearTimeout(timer);  // Cancelar el timer anterior
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// Caso de uso: búsqueda en tiempo real
const input = document.querySelector('#busqueda');
const buscarDebounced = debounce(async (termino) => {
  if (termino.length < 2) return;
  const resultados = await fetch(`/api/buscar?q=${termino}`).then(r => r.json());
  mostrarResultados(resultados);
}, 300);  // Espera 300ms desde el último keystroke

input.addEventListener('input', (e) => buscarDebounced(e.target.value));

// Sin debounce: con escritura rápida de "javascript"
// Se harían 10 peticiones al servidor
// Con debounce 300ms: solo 1 petición (cuando el usuario para)

Implementación completa de debounce con opción immediate

La variante "leading edge" ejecuta la función inmediatamente en la primera llamada y luego espera a que expire el delay antes de permitir otra ejecución:

function debounceCompleto(fn, delay, opciones = {}) {
  const { immediato = false } = opciones;
  let timer = null;

  return function(...args) {
    const contexto = this;
    const ejecutarAhora = immediato && !timer;

    clearTimeout(timer);

    timer = setTimeout(() => {
      timer = null;
      if (!immediato) fn.apply(contexto, args);
    }, delay);

    if (ejecutarAhora) fn.apply(contexto, args);
  };
}

// Debounce immediato: ejecuta en el PRIMER clic, luego ignora los siguientes
// hasta que pasen los 500ms
const boton = document.querySelector('#enviar');
boton.addEventListener('click', debounceCompleto(() => {
  enviarFormulario();
}, 500, { immediato: true }));

Throttle: limitar a una ejecución cada N ms

Throttle garantiza que la función no se ejecuta más de una vez en cada ventana de tiempo N. A diferencia de debounce, no espera a que el usuario pare: ejecuta regularmente durante el evento:

function throttle(fn, limite) {
  let ultimaEjecucion = 0;

  return function(...args) {
    const ahora = Date.now();

    if (ahora - ultimaEjecucion >= limite) {
      ultimaEjecucion = ahora;
      return fn.apply(this, args);
    }
  };
}

// Caso de uso: guardar la posición de scroll
const guardarPosicion = throttle(() => {
  localStorage.setItem('scrollY', window.scrollY.toString());
}, 500);  // Máximo 2 veces por segundo

window.addEventListener('scroll', guardarPosicion);

// Caso de uso: actualizar una barra de progreso al mover el ratón
const actualizarProgreso = throttle((e) => {
  const progreso = (e.clientX / window.innerWidth * 100).toFixed(1);
  document.querySelector('.progreso').style.width = `${progreso}%`;
}, 16);  // ~60fps

document.addEventListener('mousemove', actualizarProgreso);

Throttle con requestAnimationFrame

Para actualizaciones visuales, es mejor sincronizar con el ciclo de renderizado del navegador en lugar de usar un timer fijo:

function throttleRAF(fn) {
  let frameId = null;

  return function(...args) {
    if (frameId) return;  // Ya hay una ejecución pendiente

    frameId = requestAnimationFrame(() => {
      fn.apply(this, args);
      frameId = null;
    });
  };
}

// Para animaciones de scroll: perfectamente sincronizado con el monitor
const manejarScroll = throttleRAF(() => {
  const cabecera = document.querySelector('header');
  if (window.scrollY > 100) {
    cabecera.classList.add('fija');
  } else {
    cabecera.classList.remove('fija');
  }
});

window.addEventListener('scroll', manejarScroll);

Debounce vs throttle: cuándo usar cada uno

// USA DEBOUNCE cuando:
// - Búsqueda en tiempo real (esperar a que el usuario deje de escribir)
// - Guardado automático en formulario
// - Validación de campo al salir del input
// - Redimensionar ventana (calcular layout solo cuando termina)

const validarEmail = debounce((email) => {
  const valido = /^[w.]+@w+.w+$/.test(email);
  mostrarEstado(valido ? 'ok' : 'error');
}, 400);

// USA THROTTLE cuando:
// - Scroll (actualizar navbar, lazy loading)
// - Mousemove (tooltips, seguimiento de cursor)
// - Resize (actualizar gráficos periódicamente durante el resize)
// - Juegos (límite de disparos por segundo)

const actualizarNavbar = throttle(() => {
  const esFija = window.scrollY > 50;
  document.body.classList.toggle('navbar-fija', esFija);
}, 100);

El error más habitual al usar debounce

// MAL: se crea una nueva función debounced en cada render
// Cada instancia tiene su propio timer, por lo que el debounce no funciona
function Componente() {
  const buscar = debounce(hacerBusqueda, 300);  // Nueva instancia en cada render
  return <input onChange={e => buscar(e.target.value)} />;
}

// BIEN: en React, usar useCallback o useMemo para preservar la referencia
function Componente() {
  const buscar = useCallback(
    debounce(hacerBusqueda, 300),
    []  // Sin dependencias: se crea solo una vez
  );
  return <input onChange={e => buscar(e.target.value)} />;
}

En la práctica, la mayoría de proyectos usan la función debounce y throttle de la librería lodash, que maneja correctamente casos extremos como la cancelación, el leading/trailing edge, y el contexto de this. Implementarlas desde cero es útil para entender cómo funcionan, pero en producción lo razonable es usar una implementación probada.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP