Skip to content

Debounce y throttle ​

Hay eventos que se disparan decenas de veces por segundo: escribir en un buscador, hacer scroll, redimensionar la ventana, mover el mouse. Si en cada uno haces una petición o un cálculo pesado, tu sitio se vuelve lento y tu servidor sufre.

  • Debounce: "espera a que el usuario deje de hacer algo". Ideal para buscadores: busca 300 ms después de la última tecla.
  • Throttle: "ejecuta como máximo una vez cada X ms". Ideal para scroll: actualiza la barra de progreso cada 100 ms.

Analogía

Debounce es el elevador que espera a que deje de entrar gente para cerrar las puertas. Throttle es el semáforo que deja pasar coches cada cierto tiempo, sin importar cuántos lleguen.

El código ​

js
/**
 * Retrasa la ejecución hasta que pasen `espera` ms sin nuevas llamadas.
 * @param {Function} fn
 * @param {number} espera ms
 * @param {{ inmediato?: boolean }} opciones inmediato = ejecuta en la primera llamada y luego espera
 */
export function debounce(fn, espera = 300, { inmediato = false } = {}) {
  let temporizador;
  function debounced(...args) {
    const ejecutarYa = inmediato && !temporizador;
    clearTimeout(temporizador);
    temporizador = setTimeout(() => {
      temporizador = null;
      if (!inmediato) fn.apply(this, args);
    }, espera);
    if (ejecutarYa) fn.apply(this, args);
  }
  debounced.cancelar = () => { clearTimeout(temporizador); temporizador = null; };
  return debounced;
}

/**
 * Ejecuta como máximo una vez cada `intervalo` ms (incluye la última llamada).
 */
export function throttle(fn, intervalo = 100) {
  let ultimo = 0;
  let pendiente;
  return function throttled(...args) {
    const ahora = Date.now();
    const restante = intervalo - (ahora - ultimo);
    if (restante <= 0) {
      clearTimeout(pendiente);
      pendiente = null;
      ultimo = ahora;
      fn.apply(this, args);
    } else if (!pendiente) {
      // Garantiza que la última llamada también se ejecute
      pendiente = setTimeout(() => {
        ultimo = Date.now();
        pendiente = null;
        fn.apply(this, args);
      }, restante);
    }
  };
}

/** Throttle sincronizado con el refresco de pantalla (ideal para animaciones y scroll) */
export function rafThrottle(fn) {
  let encolado = false;
  return function (...args) {
    if (encolado) return;
    encolado = true;
    requestAnimationFrame(() => {
      encolado = false;
      fn.apply(this, args);
    });
  };
}

Caso 1: buscador en vivo ​

html
<input type="search" id="buscador" placeholder="Buscar productos..." autocomplete="off">
<p id="estado"></p>
<ul id="resultados"></ul>
js
import { debounce } from './tiempo.js';

const input = document.getElementById('buscador');
const lista = document.getElementById('resultados');
const estado = document.getElementById('estado');
let controlador;

const buscar = debounce(async (texto) => {
  if (texto.length < 2) {
    lista.innerHTML = '';
    estado.textContent = '';
    return;
  }
  controlador?.abort();                     // cancela la búsqueda anterior
  controlador = new AbortController();
  estado.textContent = 'Buscando…';
  try {
    const res = await fetch(`/api/productos?q=${encodeURIComponent(texto)}`, { signal: controlador.signal });
    const { data } = await res.json();
    estado.textContent = data.length ? `${data.length} resultado(s)` : 'Sin resultados';
    lista.replaceChildren(...data.map((p) => {
      const li = document.createElement('li');
      li.textContent = `${p.nombre} — $${p.precio}`; // textContent evita XSS
      return li;
    }));
  } catch (err) {
    if (err.name !== 'AbortError') estado.textContent = 'Error al buscar';
  }
}, 300);

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

Caso 2: barra de progreso de lectura y botón "volver arriba" ​

js
import { throttle } from './tiempo.js';

const barra = document.querySelector('.barra-progreso');
const btnArriba = document.querySelector('.btn-arriba');

const alHacerScroll = throttle(() => {
  const total = document.documentElement.scrollHeight - window.innerHeight;
  const porcentaje = total > 0 ? (window.scrollY / total) * 100 : 0;
  barra.style.width = porcentaje + '%';
  btnArriba.hidden = window.scrollY < 600;
}, 100);

window.addEventListener('scroll', alHacerScroll, { passive: true });
btnArriba.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));

Caso 3: guardar borrador automáticamente ​

js
const guardarBorrador = debounce((texto) => {
  localStorage.setItem('borrador-mensaje', texto);
  document.querySelector('#guardado').textContent = 'Borrador guardado ✓';
}, 1000);

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

Caso 4: evitar doble clic en "Pagar" ​

js
// inmediato: true → el primer clic se ejecuta al instante y los siguientes 2 s se ignoran
const pagar = debounce(() => enviarPago(), 2000, { inmediato: true });
document.querySelector('#btn-pagar').addEventListener('click', pagar);

¿Cuál uso? ​

SituaciónTécnicaTiempo sugerido
Buscador / autocompletardebounce250–400 ms
Validar campo mientras escribedebounce400–600 ms
Guardado automáticodebounce1000–2000 ms
Scroll, barra de progresothrottle o rafThrottle100 ms / cada frame
resize de ventanadebounce150–250 ms
Evitar doble envíodebounce inmediato1000–2000 ms

Error común

Crear el debounce dentro del manejador: input.addEventListener('input', () => debounce(buscar, 300)()). Cada evento crea un debounce nuevo y nunca se cancela el anterior, así que no funciona. Crea la función una sola vez fuera y reutilízala.

Academia Web — aprende, copia y construye.