¿Cómo se implementan debounce y throttle en el backend (no solo en JS de frontend) — por ejemplo, para limitar reintentos o escrituras ruidosas?
Debounce y throttle no son solo trucos para manejadores de clics: en un backend sirven para agrupar ráfagas de escrituras ruidosas y limitar qué tan fuerte puede golpear un cliente a una dependencia downstream.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo se implementan debounce y throttle en el backend (no solo en JS de frontend) — por ejemplo, para limitar reintentos o escrituras ruidosas?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Dos problemas distintos que suenan parecidos
Debounce y throttle existen ambos para domar una ráfaga de llamadas, pero resuelven problemas diferentes y en una entrevista se espera que no los mezcles.
Debounce agrupa una ráfaga en una única acción, aplazada hasta que la ráfaga se calma. Si llegan diez eventos dentro de una ventana de 500ms, un manejador debounced espera hasta que pasen 500ms de silencio y entonces se ejecuta exactamente una vez, usando el estado más reciente. Optimiza para "no hacer trabajo redundante mientras las cosas todavía están cambiando".
Throttle limita la tasa de ejecución a como máximo una vez por ventana fija, sin importar cuántas llamadas lleguen, y típicamente sigue ejecutándose con una cadencia regular durante una ráfaga sostenida en lugar de esperar a que termine. Optimiza para "nunca exceder esta tasa", que es lo que importa cuando el recurso downstream tiene un límite de capacidad duro.
En el frontend, ambos suelen envolver un manejador de eventos de UI con un closure que guarda un único temporizador. En el backend, los mismos problemas aparecen con otra forma: muchas solicitudes independientes golpeando una flota de procesos compartida, normalmente sin estado y escalada horizontalmente, así que el estado que un debounce o throttle necesita rastrear (el temporizador, el contador, el timestamp de la última ejecución) generalmente no puede vivir en la memoria de un solo proceso, a menos que hayas fijado ese recurso a una única instancia.
#Debounce en el backend: agrupando escrituras ruidosas
Un caso real muy común: un documento, producto o registro de usuario recibe varias actualizaciones de campo rápidas desde un flujo de eventos (webhooks, el autoguardado de un editor, eventos de CDC desde una base de datos), y cada actualización debería disparar una reindexación en un motor de búsqueda, pero reindexar es costoso, y hacerlo en cada escritura durante una ráfaga es un desperdicio. Quieres hacer debounce de la reindexación por entidad: esperar a que las escrituras a esa entidad dejen de llegar durante, digamos, 400ms, y entonces reindexar una sola vez con el estado más reciente.
// Un debouncer por clave, de un solo proceso. Sirve para una única// instancia de Node.js o como primitiva central de una versión con Redis.class KeyedDebouncer {private timers = new Map<string, NodeJS.Timeout>();constructor(private delayMs: number, private onFire: (key: string) => void) {}trigger(key: string): void {const existing = this.timers.get(key);if (existing) clearTimeout(existing);const timer = setTimeout(() => {this.timers.delete(key);this.onFire(key);}, this.delayMs);this.timers.set(key, timer);}}const reindexDebouncer = new KeyedDebouncer(400, (productId) => {reindexProduct(productId); // se ejecuta una vez que las escrituras se calman});// Se llama desde cada camino de escritura que toca un producto.function onProductWrite(productId: string) {reindexDebouncer.trigger(productId);}
Esto funciona limpiamente en una sola instancia porque el Map de temporizadores vive en la memoria de un proceso. En el momento en que corres más de una instancia detrás de un balanceador de carga, dos instancias pueden tener cada una su propio temporizador para la misma clave, y ambas dispararán; así que una versión de producción necesita que el estado "¿ya hay una reindexación programada para esta clave?" viva en algún lugar compartido, típicamente Redis: fijar una clave con una expiración igual a la ventana de debounce en cada disparo (SET reindex:product:123 1 PX 400 NX, refrescada en cada evento nuevo), y tener un barrido en segundo plano o una cola de jobs con retraso (por ejemplo BullMQ, Sidekiq) que realice la reindexación de verdad una vez que la clave expire sin haber sido refrescada de nuevo, lo cual es funcionalmente una versión distribuida de "limpiar y reiniciar el temporizador".
#Throttle en el backend: limitar la tasa de reintentos salientes
El throttle aparece constantemente con la forma de "nuestro servicio sigue reintentando una dependencia downstream que está fallando, y los propios reintentos son la causa de la caída": una tormenta de reintentos. La solución es un limitador de tasa delante del camino de llamadas salientes, independiente de cuántos llamadores o hilos quieran hacer la llamada.
Un token bucket es la estructura estándar: un balde contiene hasta capacity tokens, se rellena a una tasa fija, y cada intento de llamada debe quitar un token para proceder; si el balde está vacío, la llamada se rechaza o se retrasa. Permite naturalmente ráfagas cortas hasta la capacidad del balde mientras sigue haciendo cumplir una tasa estable a largo plazo, a diferencia de un contador de ventana fija ingenuo, que permite dos ráfagas completas seguidas justo en el borde de una ventana.
package ratelimitimport ("sync""time")type TokenBucket struct {mu sync.Mutexcapacity float64tokens float64refillRate float64 // tokens por segundolastRefill time.Time}func NewTokenBucket(capacity, refillRate float64) *TokenBucket {return &TokenBucket{capacity: capacity,tokens: capacity,refillRate: refillRate,lastRefill: time.Now(),}}// Allow indica si una llamada puede proceder ahora mismo, consumiendo// un token si es así. Se llama en cada intento de reintento antes de salir.func (b *TokenBucket) Allow() bool {b.mu.Lock()defer b.mu.Unlock()now := time.Now()elapsed := now.Sub(b.lastRefill).Seconds()b.tokens = min(b.capacity, b.tokens+elapsed*b.refillRate)b.lastRefill = nowif b.tokens >= 1 {b.tokens--return true}return false}func min(a, b float64) float64 {if a < b {return a}return b}
Envolver una llamada downstream inestable con este balde, combinado con backoff exponencial y jitter en el propio reintento, aborda dos modos de fallo distintos a la vez: el backoff espacía los reintentos de cualquier llamador individual para que no golpeen la dependencia en el instante en que falla, y el token bucket limita la tasa agregada saliente entre todos los llamadores y todos los intentos de reintento en el proceso, así que ni siquiera una estampida simultánea de fallos puede superar una tasa que el downstream pueda absorber. En un despliegue con varias instancias, la misma idea se hace cumplir de forma centralizada con un limitador respaldado por Redis (un script de Lua que hace la comprobación y el decremento de forma atómica contra una clave compartida) en lugar de un balde por proceso, para que el límite sea sobre la tasa saliente total de la flota, no de cada instancia por separado.
#Dónde aparece esto en entrevistas
Espera que esta pregunta venga acompañada de un escenario: "el SDK de un cliente reintenta demasiado agresivamente cuando devolvemos 503" o "cada pulsación de tecla en nuestro editor está golpeando la base de datos". La respuesta sólida identifica cuál de los dos patrones aplica de verdad —agrupar trabajo redundante versus limitar una tasa—, nombra el problema de compartir estado entre instancias, y elige una estructura de datos concreta (token bucket para throttle, un temporizador por clave o una clave con TTL para debounce) en lugar de describir el concepto solo en abstracto.