Tema
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ón | Técnica | Tiempo sugerido |
|---|---|---|
| Buscador / autocompletar | debounce | 250–400 ms |
| Validar campo mientras escribe | debounce | 400–600 ms |
| Guardado automático | debounce | 1000–2000 ms |
| Scroll, barra de progreso | throttle o rafThrottle | 100 ms / cada frame |
resize de ventana | debounce | 150–250 ms |
| Evitar doble envío | debounce inmediato | 1000–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.