Problema y alcance
Implementa debounce(fn, wait, options) y throttle(fn, wait, options) para código orientado al navegador. La función devuelta debe preservar los argumentos más recientes y this, retornar el resultado de la invocación más reciente y exponer cancel() y flush(). Las opciones son leading, trailing y, para debounce, maxWait.
El debounce por defecto se invoca únicamente en el flanco final (trailing). Una llamada inicial (leading) se ejecuta al comienzo de una ráfaga. Si tanto leading como trailing están habilitados, una llamada aislada se ejecuta una vez en el flanco leading; una segunda llamada dentro del período de espera crea una invocación trailing con los argumentos más recientes. maxWait evita que un flujo continuo de eventos posponga el trabajo indefinidamente. Throttle permite como máximo una invocación por intervalo de tamaño wait y soporta el comportamiento leading y trailing.
Esta es una pregunta de frontend porque el contrato está impulsado por la entrada del navegador, el desplazamiento (scroll), el cambio de tamaño (resize), el renderizado y el ciclo de vida del componente. La misma utilidad puede ejecutarse en Node.js, pero cambiar el entorno de ejecución no altera la habilidad central evaluada en la entrevista: traducir la semántica de temporización de la interfaz de usuario en una pequeña máquina de estados. La implementación utiliza JavaScript y estado auxiliar O(1).
Qué evalúan los entrevistadores
La primera señal es si el candidato define el contrato antes de escribir un temporizador. Decir que “debounce espera; throttle limita” no resuelve el comportamiento leading, si una llamada leading solitaria también produce un trailing, qué argumentos prevalecen o qué significa la limpieza. Una respuesta sólida escribe una línea de tiempo de llamadas y hace explícitas esas decisiones.
La segunda señal es el razonamiento sobre el estado. Una sola variable de temporizador no es suficiente una vez que aparecen maxWait, desplazamientos del reloj, valores de retorno y flush(). La implementación necesita el tiempo de la última llamada, el tiempo de la última invocación real, los argumentos y el receptor pendientes, el identificador del temporizador y el resultado más reciente. Cada elemento debe corresponder a una regla del contrato.
La tercera señal es el criterio sobre el funcionamiento del navegador. Los temporizadores proporcionan un retraso mínimo, no un plazo exacto; las tareas largas y la limitación en segundo plano pueden ejecutar un callback tarde. Limitar el trabajo de scroll con requestAnimationFrame() alinea el trabajo con el repintado (paint), pero por sí mismo no reduce la frecuencia de los eventos. A veces, scrollend, IntersectionObserver o un hook de limpieza del framework eliminan la necesidad de una utilidad de temporizador genérica.
Finalmente, el entrevistador verifica las pruebas en lugar de la intuición visual. Usar pausas de tiempo real (wall-clock sleeps) genera pruebas lentas e inestables (flaky). Un candidato sólido inyecta o reemplaza el tiempo y los temporizadores, avanza un reloj simulado y valida secuencias de llamadas exactas para casos límite, cancelación, vaciado y entrada continua.
Preguntas para aclarar antes de responder
- ¿Cuáles son los valores por defecto? Esta respuesta utiliza para debounce
{ leading: false, trailing: true }y para throttle{ leading: true, trailing: true }. Diferentes valores por defecto cambian el comportamiento de llamadas aisladas y las pruebas. - ¿Qué sucede cuando ambos flancos están habilitados? Una sola llamada solo se invoca en el flanco leading. Una invocación trailing ocurre únicamente cuando llega otra llamada durante el período de espera. Esto evita duplicar una acción aislada.
- ¿Qué argumentos y receptor utiliza una llamada retrasada? Utiliza los argumentos de la última llamada pendiente y
this. Capturar el primer evento haría que el trabajo de autocompletado o redimensionamiento quede desactualizado. - ¿Puede la entrada continuar indefinidamente? Si es así, un debounce solo trailing podría no ejecutarse nunca.
maxWaitestablece el retraso máximo desde la invocación real anterior o el inicio de la ráfaga actual. - ¿Qué deben hacer
cancel()yflush()? Cancel elimina el trabajo pendiente y reinicia el estado de la ráfaga. Flush ejecuta inmediatamente una llamada trailing elegible y devuelve su resultado; no debe causar un duplicado posterior. - ¿Se requiere un tiempo transcurrido exacto? Ningún temporizador de navegador garantiza una programación exacta. El contrato controla la elegibilidad más temprana y el orden; las pruebas utilizan un reloj simulado para eliminar el ruido del planificador.
Respuesta de 30 segundos
“Primero definiría la semántica de leading y trailing con una línea de tiempo. Debounce agrupa una ráfaga y normalmente se invoca tras wait milisegundos sin otra llamada. Throttle garantiza un progreso periódico durante una ráfaga. Mantengo los argumentos y el receptor más recientes, la hora de la última llamada, la hora de la última invocación real, un temporizador y el último resultado.
En cada llamada, decido si el trabajo es elegible ahora. De lo contrario, programo la espera restante. El temporizador vuelve a comprobar la elegibilidad porque una llamada posterior puede haber movido el plazo límite trailing. maxWait evita la inanición (starvation); throttle es la misma máquina de estados con maxWait fijado en wait. cancel() limpia el estado pendiente y flush() realiza una invocación trailing pendiente. Pruebo con un reloj simulado, especialmente llamadas exactamente en el límite, leading más trailing, entrada continua, cancelación, vaciado y limpieza al desmontar.”
Análisis detallado paso a paso
Paso 1: Convertir palabras en líneas de tiempo
Asume wait = 100 ms y las llamadas A@0, B@40, C@90 y D@220. Un debounce de solo trailing produce C@190 y D@320: cada llamada mueve el plazo de período de calma y prevalece el último argumento. Un debounce con leading y trailing produce A@0, C@190 y D@220. D está aislada, por lo que no se vuelve a ejecutar en 320.
Un throttle con leading y trailing produce como máximo una llamada por ventana de 100 ms mientras preserva un valor pendiente final. Para la primera ráfaga, eso significa A inmediatamente y el valor pendiente más reciente, C, en el límite trailing. Las marcas de tiempo exactas en un navegador real pueden ser posteriores al límite conceptual, pero nunca anteriores.
Esta línea de tiempo revela las transiciones de estado: una llamada en reposo puede abrir una ráfaga; llamadas posteriores reemplazan los datos pendientes; un temporizador invoca o reprograma; una invocación borra los datos pendientes pero retiene su resultado. Escribir estas transiciones primero previene la mayoría de los errores por desfase de uno (off-by-one) y duplicación de trailing.
Paso 2: Implementar una máquina de estados explícita
La siguiente implementación sigue el contrato establecido. shouldInvoke() maneja la primera llamada, el límite del período de calma, el retroceso del reloj y maxWait. remainingWait() elige el plazo más temprano entre el límite trailing y el de espera máxima.
function debounce(fn, wait, options = {}) {
wait = Math.max(0, Number(wait) || 0);
const leading = options.leading === true;
const trailing = options.trailing !== false;
const hasMaxWait = Number.isFinite(options.maxWait);
const maxWait = hasMaxWait
? Math.max(wait, options.maxWait)
: 0;
let timerId;
let lastArgs;
let lastThis;
let lastCallTime;
let lastInvokeTime = 0;
let result;
function invoke(time) {
const args = lastArgs;
const receiver = lastThis;
lastArgs = undefined;
lastThis = undefined;
lastInvokeTime = time;
result = fn.apply(receiver, args);
return result;
}
function shouldInvoke(time) {
const sinceCall = time - lastCallTime;
const sinceInvoke = time - lastInvokeTime;
return lastCallTime === undefined
|| sinceCall >= wait
|| sinceCall < 0
|| (hasMaxWait && sinceInvoke >= maxWait);
}
function remainingWait(time) {
const sinceCall = time - lastCallTime;
const trailingWait = wait - sinceCall;
if (!hasMaxWait) return trailingWait;
const sinceInvoke = time - lastInvokeTime;
return Math.min(trailingWait, maxWait - sinceInvoke);
}
function trailingEdge(time) {
timerId = undefined;
if (trailing && lastArgs) return invoke(time);
lastArgs = undefined;
lastThis = undefined;
return result;
}
function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) return trailingEdge(time);
timerId = setTimeout(timerExpired, remainingWait(time));
}
function leadingEdge(time) {
lastInvokeTime = time;
timerId = setTimeout(timerExpired, wait);
return leading ? invoke(time) : result;
}
function cancel() {
if (timerId !== undefined) clearTimeout(timerId);
timerId = undefined;
lastArgs = undefined;
lastThis = undefined;
lastCallTime = undefined;
lastInvokeTime = 0;
}
function flush() {
if (timerId === undefined) return result;
clearTimeout(timerId);
return trailingEdge(Date.now());
}
function debounced(...args) {
const time = Date.now();
const invokeNow = shouldInvoke(time);
lastArgs = args;
lastThis = this;
lastCallTime = time;
if (invokeNow) {
if (timerId === undefined) return leadingEdge(time);
if (hasMaxWait) {
clearTimeout(timerId);
timerId = setTimeout(timerExpired, wait);
return invoke(time);
}
}
if (timerId === undefined) {
timerId = setTimeout(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
return debounced;
}
function throttle(fn, wait, options = {}) {
return debounce(fn, wait, {
leading: options.leading !== false,
trailing: options.trailing !== false,
maxWait: wait,
});
}El estado es O(1) y cada llamada al wrapper realiza un trabajo O(1). El costo del callback queda fuera de la complejidad de la utilidad. El código de producción puede importar una implementación mantenida; el valor en la entrevista radica en poder explicar y probar su contrato.
Paso 3: Explicar por qué el temporizador vuelve a verificar
Supongamos que la primera llamada programa un temporizador para 100 ms, y luego llega una segunda llamada a los 90 ms. Si el temporizador original invoca ciegamente a los 100, el período de calma fue de solo 10 ms. En su lugar, timerExpired() recalcula sinceCall, observa que no han transcurrido 100 ms de silencio y programa los 90 ms restantes.
Limpiar y crear un temporizador en cada evento es una implementación válida y más simple para solo trailing. La máquina de estados con recomprobación justifica su complejidad porque también soporta llamadas leading, maxWait, valores de retorno y semántica de throttle. Si la pregunta solo pide debounce básico trailing, utiliza la solución más pequeña y aclara que el contrato más completo requeriría más estado.
Paso 4: Evitar la inanición con maxWait
Una consulta de autocompletado usualmente debe esperar una pausa. Un búfer de telemetría o un autoguardado no pueden esperar eternamente mientras la entrada continúe. Con wait = 300 ms y maxWait = 1000 ms, llamadas repetidas cada 100 ms se activarán al menos una vez cerca de cada límite máximo de 1000 ms, sujeto al retraso de programación del entorno de ejecución.
maxWait es también el puente hacia throttle. Igualarlo a wait significa que llamadas continuas no pueden posponer la invocación más allá de un intervalo. Mantener una sola implementación evita que dos máquinas de estados diverjan en comportamientos límite. Esta derivación es una decisión de implementación, no la única definición válida de throttle; el contrato y las pruebas siguen siendo la referencia autoritativa.
Paso 5: Manejar el ciclo de vida y los efectos secundarios
El trabajo retrasado puede sobrevivir a la interfaz de usuario que lo programó. Un componente debe llamar a cancel() durante la limpieza para que un callback antiguo no actualice el estado desmontado, use props obsoletas o envíe una petición tras la navegación. Si el producto requiere confirmar el texto pendiente antes de desmontarse, llama a flush() deliberadamente y luego limpia; no hagas que cada desmontaje envíe datos silenciosamente.
Aplicar debounce a una búsqueda asíncrona controla la creación de peticiones, no el orden de las respuestas. Una vez que inicia una petición, una respuesta antigua más lenta aún puede sobrescribir un resultado más nuevo. Utiliza AbortController, una generación de petición o una verificación de la respuesta más reciente además de debounce. La limitación de tasa (rate limiting) y la protección contra respuestas obsoletas resuelven diferentes modos de falla.
Paso 6: Elegir una primitiva del navegador según la tarea real
Usa debounce cuando solo importe el valor final estabilizado, como la validación tras pausas en la escritura. Usa throttle cuando importe el progreso intermedio, como el muestreo periódico del puntero o del estado de desplazamiento. Usa requestAnimationFrame() para alinear escrituras visuales con el repintado, pero no asumas que reduce automáticamente la frecuencia de eventos de scroll. Usa IntersectionObserver para visibilidad basada en umbrales y scrollend cuando el evento requerido sea específicamente el final del scroll.
La regla de decisión es conductual: descartar estados intermedios, preservar el progreso periódico, alinear el trabajo con el repintado u observar un umbral definido por el navegador. Elegir una primitiva por costumbre puede desperdiciar trabajo u ocultar actualizaciones visibles para el usuario.
Paso 7: Probar con tiempo virtual
Reemplaza Date.now, setTimeout y clearTimeout con un reloj simulado, o usa los temporizadores simulados del ejecutor de pruebas. Registra valores y marcas de tiempo virtuales. Avanza justo antes y exactamente en cada plazo límite. No afirmes que un timeout real de 100 ms se ejecute con precisión exacta a los 100 ms.
La matriz mínima cubre ráfagas solo trailing, ráfagas solo leading, leading más trailing con una y múltiples llamadas, maxWait bajo entrada continua, una llamada exactamente en wait, argumentos y receptor más recientes, reutilización de resultados, cancelación antes del plazo, vaciado antes del plazo, vaciado repetido, espera cero y un callback que programa otra llamada al wrapper. Para una integración en UI, prueba también la limpieza y respuestas de red obsoletas.
Ejemplo de respuesta sólida
“Definiría una línea de tiempo antes de programar. Con un debounce trailing de 100 ms, llamadas en 0, 40 y 90 producen una sola llamada en el primer momento elegible después de 190 con los últimos argumentos. Una versión con leading y trailing ejecuta llamadas en 0 y 190, pero una llamada leading aislada no se duplica en el flanco trailing.
Mi estado comprende un temporizador, argumentos y receptor pendientes, tiempo de la última llamada, tiempo de la última invocación real y el resultado más reciente. Un temporizador nunca asume que todavía es elegible; vuelve a verificar porque una llamada más nueva puede haber movido el plazo de calma. maxWait añade un segundo plazo límite para que la entrada continua no prive al callback de ejecutarse. Throttle reutiliza la misma máquina de estados con maxWait igual a wait.
Preservo this, retorno el último resultado de invocación, limpio el estado pendiente en cancel() y hago que flush() realice como máximo una invocación trailing. En un componente cancelo en la limpieza. Para búsquedas, aborto o versiono peticiones por separado porque debounce no puede evitar que respuestas antiguas lleguen tarde. Verifico todo esto con tiempo simulado y secuencias exactas de llamadas, ya que los temporizadores del navegador pueden retrasarse.”
Errores comunes
- Programar antes de definir el comportamiento de los flancos → distintas implementaciones razonables fallan diferentes pruebas → escribe primero los valores por defecto y una secuencia de llamadas con marcas de tiempo.
- Mantener los primeros argumentos → el callback retrasado actúa sobre datos obsoletos → reemplaza los argumentos y el receptor pendientes en cada llamada.
- Ejecutar siempre una llamada trailing tras una leading → un clic causa dos acciones → haz trailing solo si otra llamada quedó pendiente durante el intervalo.
- Reiniciar indefinidamente sin
maxWait→ un flujo continuo puede privar al autoguardado o al procesamiento por lotes → agrega un plazo límite máximo cuando se requiera progreso periódico. - Tratar el retraso del temporizador como exacto → tareas largas y limitación del runtime rompen las aserciones de marcas de tiempo → considéralo como la elegibilidad más temprana y prueba con tiempo virtual.
- Usar
requestAnimationFrame()como throttle genérico → puede ejecutarse a la misma frecuencia que los eventos de scroll → úsalo para alineación de repintado y mide un intervalo separado cuando se requiera reducción de tasa. - Olvidar la limpieza → el trabajo retrasado se ejecuta tras la navegación o el desmontaje → cancela durante la limpieza del ciclo de vida o vacía explícitamente cuando la semántica del producto lo requiera.
- Asumir que debounce evita resultados de búsqueda obsoletos → peticiones ya iniciadas pueden resolverse desordenadamente → combínalo con cancelación o validación de la última petición.
- Construir la máquina de estados completa para un enunciado básico → código innecesario aumenta la superficie de bugs → implementa el contrato más pequeño solicitado, luego describe las extensiones.
Preguntas de seguimiento y respuestas
Pregunta de seguimiento 1: ¿Qué sucede cuando una llamada llega exactamente en el límite de espera?
Define el modelo de ordenamiento. En una prueba determinista, si la tarea del temporizador anterior se ejecuta antes que la tarea de la nueva llamada en la misma marca de tiempo virtual, la ráfaga anterior puede ejecutar su trailing y la nueva llamada inicia otra ráfaga. Si la nueva llamada se procesa primero, puede actualizar el estado pendiente antes de que el temporizador vuelva a comprobar. Las colas de tareas del navegador no hacen que los eventos externos simultáneos sean mágicamente atómicos. Las pruebas deben programar un orden específico y la implementación debe ser internamente consistente.
Pregunta de seguimiento 2: ¿Por qué no implementar throttle con setInterval?
Un intervalo se ejecuta periódicamente incluso cuando no hay trabajo pendiente, a menos que un estado adicional lo suprima. También dificulta razonar sobre el comportamiento leading, el valor trailing final, la cancelación y el reinicio tras la inactividad. Un temporizador de disparo único programado bajo demanda real ofrece control explícito sobre el siguiente límite. setInterval puede funcionar con un contrato preciso, pero no resulta más simple una vez incluidas todas las semánticas requeridas.
Pregunta de seguimiento 3: ¿Debería ejecutarse flush cuando trailing está deshabilitado?
No hay trabajo trailing pendiente elegible, por lo que flush() devuelve el resultado de la invocación más reciente sin llamar a fn. Esto sigue la misma compuerta de trailing que la expiración del temporizador. Si un producto necesita “forzar la ejecución independientemente de las opciones”, esa es una API diferente y debe recibir un nombre y pruebas distintos.
Pregunta de seguimiento 4: ¿Cómo probarías el retroceso del reloj?
Inyecta un reloj y reduce su valor por debajo de lastCallTime. La rama sinceCall < 0 trata ese estado como elegible en lugar de programar un tiempo restante negativo o enorme. Es preferible un reloj monotónico donde esté disponible, pero los contratos de utilidades del navegador a menudo usan el reloj del runtime indirectamente; la rama defensiva evita que el wrapper quede bloqueado.
Pregunta de seguimiento 5: ¿Cuándo debería un proyecto usar Lodash en lugar de esta implementación?
Usa la librería mantenida cuando su semántica documentada, estrategia de empaquetado y política de dependencias del proyecto encajen. Una utilidad propia necesita sus propias pruebas de compatibilidad, revisión y mantenimiento. Implementarla en una entrevista demuestra razonamiento; no prueba que duplicar una dependencia madura sea la mejor decisión de producción.