Closures en JavaScript: debounce y throttle con ejemplos reales

Close-up of a colorful code snippet on a computer screen, highlighting programming concepts.
Foto de Drishan Dey en Pexels

En el día a día de un desarrollador frontend nos encontramos con eventos que se disparan con gran frecuencia: scroll, resize, pulsaciones de tecla, peticiones a APIs, etc. Si no los gestionamos bien, podemos sobrecargar el hilo principal y acabar con una UI temblorosa. Aquí es donde entran en juego dos patrones muy útiles: debounce y throttle. Ambos se basan en el concepto de closure para mantener estado entre llamadas sin exponerlo al resto del código.

¿Qué es debounce?

El debounce retrasa la ejecución de una función hasta que haya pasado un intervalo de tiempo sin que se vuelva a disparar el evento. Es ideal para situaciones como la búsqueda en tiempo real: queremos lanzar la petición solo cuando el usuario ha dejado de escribir unos milisegundos.

function debounce(fn, wait) {
  let timeoutId; // variable capturada por el closure
  return function(...args) {
    // Si ya había un timeout, lo cancelamos
    clearTimeout(timeoutId);
    // Programamos la ejecución tras 'wait' milisegundos
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, wait);
  };
}

// Uso práctico
const buscar = (texto) => console.log('Buscar:', texto);
const buscarDebounced = debounce(buscar, 300);

// Simulamos escritura rápida
['a','ab','abc','abcd'].forEach((t, i) => setTimeout(() => buscarDebounced(t), i * 100));

En el ejemplo anterior, timeoutId es la variable que el closure mantiene viva entre invocaciones. Cada vez que llamamos a buscarDebounced, el temporizador anterior se cancela y se crea uno nuevo, de modo que buscar solo se ejecuta cuando el usuario deja de escribir durante 300 ms.

¿Qué es throttle?

El throttle limita la frecuencia con la que una función puede ejecutarse, garantizando que se invoque como máximo una vez cada X milisegundos. Es útil para eventos que necesitan respuestas regulares, como actualizar la posición de un elemento mientras el usuario hace scroll.

function throttle(fn, limit) {
  let lastCall = 0; // timestamp del último llamado
  return function(...args) {
    const now = Date.now();
    if (now - lastCall >= limit) {
      lastCall = now;
      fn.apply(this, args);
    }
  };
}

// Uso práctico
const actualizarPosicion = () => console.log('Scroll a', window.scrollY);
const actualizarThrottled = throttle(actualizarPosicion, 200);

window.addEventListener('scroll', actualizarThrottled);

Aquí el closure conserva lastCall, que representa la última vez que la función original se ejecutó. Si el intervalo desde el último llamado es menor que el límite, simplemente ignoramos la llamada.

¿Cuándo usar cada uno?

  • Debounce: cuando quieres esperar a que la actividad del usuario se estabilice antes de actuar (búsqueda, validación de formularios, redimensionado de ventana).
  • Throttle: cuando necesitas respuestas periódicas sin saturar el hilo (animaciones basadas en scroll, actualización de indicadores de posición, envío de métricas).

Una regla práctica es: si la acción debe ocurrir *después* de la última interacción, usa debounce; si la acción debe ocurrir *a intervalos regulares* mientras la interacción sigue, usa throttle.

Mejora con parámetros opcionales

Podemos hacer nuestras utilidades más flexibles aceptando opciones como leading y trailing (ejecución al inicio o al final del intervalo). A continuación, una versión ligera que combina ambas ideas:

function debounceOrThrottle(fn, wait, options = {type: 'debounce', leading: false}) {
  let timeoutId, lastCall = 0;
  const {type, leading} = options;

  return function(...args) {
    const now = Date.now();
    if (type === 'throttle') {
      if (now - lastCall >= wait) {
        lastCall = now;
        fn.apply(this, args);
      }
      return;
    }
    // debounce
    clearTimeout(timeoutId);
    if (leading && !timeoutId) {
      fn.apply(this, args);
    }
    timeoutId = setTimeout(() => {
      if (!leading) fn.apply(this, args);
      timeoutId = null;
    }, wait);
  };
}

Con esta única función puedes crear tanto debounces como throttles, eligiendo el comportamiento mediante la opción type. Así reducimos la superficie de código y mantenemos la consistencia.

Conclusión

Los closures son el motor que permite que debounce y throttle mantengan su estado interno sin necesidad de variables globales. Implementarlos a mano te ayuda a comprender mejor cómo funciona el alcance léxico y, sobre todo, a adaptar la lógica a casos concretos sin depender de librerías externas. La próxima vez que notes que un evento está consumiendo recursos, prueba a envolver tu lógica en uno de estos patrones y verás la diferencia tanto en rendimiento como en experiencia de usuario.