Tema representativo de entrevista

Entrevista Frontend: ¿Cómo funciona el Event Loop de JavaScript?

FrontendDifícil
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Un manejador de clics programa una reacción de Promise, queueMicrotask, setTimeout y una larga cadena de microtareas recursivas. Predice el orden de los registros, explica por qué los temporizadores, la entrada del usuario y el pintado se retrasan, y rediseña el cálculo para mantener la página receptiva.

Pregunta y cuándo utilizarla

Estás manejando un clic en un panel de datos. Comienza prediciendo la salida de este código:

javascript
console.log("A");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => {
  console.log("promise");
  queueMicrotask(() => console.log("nested"));
});

queueMicrotask(() => console.log("microtask"));

console.log("B");

Explica cada paso y luego analiza este problema de rendimiento:

javascript
let remaining = 100_000;

function continueInMicrotask() {
  remaining -= 1;
  if (remaining > 0) {
    queueMicrotask(continueInMicrotask);
  }
}

queueMicrotask(continueInMicrotask);
setTimeout(() => console.log("timer can run"), 0);
requestAnimationFrame(() => console.log("frame can render"));

Explica por qué el temporizador, los clics posteriores y el pintado se retrasan, y luego rediseña el cálculo por lotes para que la página siga siendo receptiva. Esta pregunta se refiere a JavaScript en el hilo principal del navegador. Un worker tiene su propio event loop, y Node.js tiene un modelo de fases que no debe copiarse directamente en una respuesta sobre navegadores.

Esto es útil para entrevistas de frontend de nivel intermedio y sénior, rendimiento web y full-stack. Una respuesta sólida conecta el modelo de planificación con la experiencia de usuario observable: que algo termine eventualmente no significa que la página haya permanecido interactiva mientras el trabajo se ejecutaba.

Qué está evaluando el entrevistador

La primera señal es un modelo preciso. La ejecución inicial del script, las devoluciones de llamada de clics y las devoluciones de llamada de temporizadores vencidos se ejecutan como tareas. Las reacciones de Promise, las devoluciones de llamada de queueMicrotask() y las de MutationObserver utilizan microtareas. Después de que una tarea finaliza, el event loop realiza un punto de control de microtareas (microtask checkpoint) y continúa procesando microtareas hasta que la cola esté vacía.

La segunda señal es si el candidato evita describir el navegador como si tuviera exactamente una única «cola de macrotareas» permanente. El estándar HTML permite que diferentes fuentes de tareas estén asociadas con diferentes colas de tareas. Un navegador puede tomar una decisión definida por la implementación entre las colas ejecutables mientras preserva el orden dentro de una misma fuente de tareas. «Macrotarea» puede ser una jerga coloquial, pero tarea (task) es el término más preciso según el estándar.

La tercera señal es un límite de renderizado correcto. Solo después de que finaliza el punto de control de microtareas puede el navegador pasar a otras tareas o a una actualización de renderizado, y el renderizado no está garantizado después de cada tarea. Si el código continúa agregando otra microtarea antes de que la cola se vacíe, los eventos de entrada, los temporizadores y las oportunidades de renderizado pueden sufrir inanición (starvation).

La cuarta señal es elegir la primitiva de planificación adecuada. await Promise.resolve() solo reanuda la función en una microtarea, por lo que no permite que una tarea posterior o un pintado se ejecuten primero. Una cesión real del hilo principal programa la continuación en una tarea futura, como scheduler.yield() donde sea compatible o una alternativa con setTimeout(). El trabajo intensivo de CPU que no se puede dividir de forma segura pertenece a un worker.

Preguntas para aclarar antes de responder

  • ¿Qué navegadores deben soportarse? scheduler.yield() no está disponible en todos los navegadores de uso generalizado, por lo que un soporte amplio requiere detección de características y una alternativa (fallback).
  • ¿Se puede dividir el trabajo entre registros o una sola llamada puede bloquear durante mucho tiempo? Un bucle divisible puede ceder el control entre lotes. Si un solo registro es costoso por sí mismo, procesar por lotes en el hilo principal aún permite un bloqueo prolongado; usa un worker o cambia el algoritmo.
  • ¿Debe pintarse el progreso después de cada lote o solo se necesita el resultado final? El progreso visible requiere ceder el hilo principal después de actualizar el estado. Un resultado solo final puede evitar costos repetidos de DOM, diseño (layout) y pintado.
  • ¿Deben confirmarse los resultados en un orden estricto? Los workers pueden calcular simultáneamente, pero la finalización fuera de orden necesita números de secuencia, una regla de combinación o un búfer de confirmación ordenado.
  • ¿Debe continuar el procesamiento en una pestaña en segundo plano? requestAnimationFrame() se pausa en la mayoría de las pestañas en segundo plano, por lo que es un mal planificador general para el progreso obligatorio en segundo plano.
  • ¿Cómo se aceptará la capacidad de respuesta? Acuerda la latencia de interacción, el presupuesto por lote, el rendimiento total y el comportamiento de cancelación para que «no se congele» sea verificable.

Marco de respuesta de 30 segundos

«El navegador selecciona una tarea ejecutable, la ejecuta y luego realiza un punto de control de microtareas que vacía la cola de microtareas. Solo después puede pasar al renderizado o a otra tarea. El primer fragmento registra A y B de forma sincrónica, luego promise y microtask en orden de encolado. La devolución de llamada de la promesa agrega nested detrás de la microtarea que ya estaba esperando, y timeout se ejecuta al final. Las microtareas recursivas mantienen abierto el punto de control, privando de recursos a los temporizadores, la entrada y el renderizado. await Promise.resolve() sigue siendo una microtarea, por lo que no es una cesión real del hilo principal. Dividiría el trabajo divisible en intervalos de tiempo y llamaría a scheduler.yield() detectado por características entre lotes, con setTimeout() como alternativa. Si una unidad sigue siendo pesada para la CPU, la movería a un worker y luego verificaría la latencia de interacción con un registro de rendimiento y entradas reales».

Solución paso a paso

Paso 1: Deducir la salida a partir del momento de encolado

Todo el script se está ejecutando actualmente como una sola tarea. Las sentencias sincrónicas no esperan al event loop, por lo que la primera salida es:

text
A
B

setTimeout(..., 0) significa que después de que se cumpla la condición de tiempo, su devolución de llamada puede convertirse en una tarea futura. Cero no interrumpe el script actual. Promise.resolve().then(...) encola una reacción de Promise como la primera microtarea, y el siguiente queueMicrotask(...) encola la segunda microtarea.

Cuando finaliza la tarea del script, comienza el punto de control de microtareas. La primera microtarea registra promise y agrega nested al final de la cola de microtareas. La microtarea explícita ya estaba esperando, por lo que registra microtask antes de nested. Una vez que la cola de microtareas está vacía, la tarea del temporizador tiene la oportunidad de ejecutarse:

text
A
B
promise
microtask
nested
timeout

Para cada línea, registra cuándo se encola la devolución de llamada y en qué categoría de planificación entra. «Las microtareas tienen prioridad» no es suficiente si una de las devoluciones de llamada competidoras aún no se ha encolado.

Paso 2: Establecer los límites de tareas, microtareas y renderizado

Una secuencia simplificada y reutilizable es:

  1. El navegador selecciona una tarea de una cola de tareas ejecutable.
  2. Ejecuta esa tarea hasta que la pila de llamadas de JavaScript esté vacía.
  3. Realiza un punto de control de microtareas; las microtareas agregadas durante el punto de control se procesan en el mismo punto de control.
  4. Según las oportunidades de renderizado, la visibilidad del documento y la política de implementación, el navegador puede actualizar el renderizado.
  5. El bucle continúa y puede procesar otra tarea.

Esto explica dos observaciones prácticas. Primero, si un manejador de clics cambia el DOM e inmediatamente inicia un cálculo sincrónico extenso, el usuario generalmente no ve el estado intermedio porque el navegador no ha recuperado una oportunidad de pintado. Segundo, las microtareas son adecuadas para trabajos breves de consistencia que deben ocurrir antes de otros eventos y temporizadores, pero no para cálculos recursivos grandes o ilimitados.

Paso 3: Diagnosticar la inanición por microtareas

Cada invocación de la primera microtarea en el segundo fragmento encola otra microtarea. El punto de control no puede finalizar hasta que su cola esté vacía, por lo que las 100.000 devoluciones de llamada se completan antes de que la tarea del temporizador o la siguiente tarea de clic puedan ejecutarse.

Sin una condición de terminación, la cola de microtareas nunca se vacía en el modelo de planificación. Un navegador podría eventualmente mostrar una advertencia de página que no responde, terminar la página o aplicar una protección de implementación, pero la corrección de la aplicación no puede depender de eso. requestAnimationFrame() no interrumpe el JavaScript en ejecución; solicita una devolución de llamada antes de un repintado futuro. Si el hilo principal nunca alcanza los pasos de renderizado pertinentes, la devolución de llamada espera.

Esta aparente cesión de control sigue siendo incorrecta:

javascript
async function processAll(records) {
  for (const record of records) {
    normalize(record);
    await Promise.resolve();
  }
}

Cada continuación de await se reanuda a través de una microtarea de Promise. La pila de llamadas se vacía brevemente, pero el punto de control continúa consumiendo esas microtareas, por lo que las tareas posteriores de entrada y de temporizador aún no pueden entrar.

Paso 4: Dividir el trabajo fragmentable entre tareas

Asigna a cada lote un presupuesto de tiempo medible y luego cede genuinamente el control entre lotes:

javascript
function yieldToMain() {
  return globalThis.scheduler?.yield
    ? globalThis.scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));
}

async function processRecords(records, budgetMs = 5) {
  let index = 0;

  while (index < records.length) {
    const deadline = performance.now() + budgetMs;

    while (index < records.length && performance.now() < deadline) {
      normalize(records[index]);
      index += 1;
    }

    updateProgress(index / records.length);

    if (index < records.length) {
      await yieldToMain();
    }
  }
}

El valor de 5 milisegundos es una suposición inicial para este ejercicio, no un estándar aplicable a todos los dispositivos. Ajústalo en los dispositivos de destino en función del costo por elemento, la latencia de interacción y el rendimiento total. scheduler.yield() programa la continuación como una tarea priorizada posterior, lo que le da al navegador la oportunidad de manejar primero el trabajo necesario. Debido a que su soporte en navegadores es incompleto, el ejemplo detecta la característica. La alternativa con setTimeout() es más amplia, pero se ve afectada por la limitación de temporizadores (timer clamping), las políticas de segundo plano y la competencia de otras tareas.

Existe otro límite: el presupuesto solo se puede verificar entre llamadas a normalize(). Si una llamada se bloquea durante 80 milisegundos, un presupuesto de cinco milisegundos no puede ayudar. Divide normalize(), reemplaza el algoritmo o mueve el cálculo a un worker.

Paso 5: Asociar la primitiva con el trabajo

RequisitoElecciónCosto principal o límite
Ejecutar una limpieza breve o notificación de consistencia después de la tarea actual pero antes de otros eventosqueueMicrotask()La recursión o el cálculo pesado privan de recursos a otros trabajos
Dividir el trabajo prolongado del hilo principal manteniendo una continuación priorizadascheduler.yield()Requiere detección de características; el soporte es incompleto
Mover la continuación a una tarea futura con amplia compatibilidadsetTimeout()El retraso del temporizador y la planificación no son deterministas
Actualizar el estado de la animación antes de un repintado futurorequestAnimationFrame()El trabajo pesado en la devolución de llamada aún bloquea ese pintado; las pestañas en segundo plano suelen pausarlo
Ejecutar trabajo pesado de CPU que no se puede dividir de forma seguraWeb WorkerCosto de protocolo de mensajes, copia o memoria compartida

requestAnimationFrame() alinea el trabajo visual con el pintado; no es una cola general de trabajos en segundo plano. Úsalo para enviar cambios visuales ligeros, no para ocultar un cálculo grande inmediatamente antes de pintar. Un worker elimina el cálculo de CPU del hilo principal de la página, pero no resuelve automáticamente la cancelación, el informe de progreso, el orden de los resultados o el costo de transferencia. Esos aspectos necesitan un protocolo explícito.

Paso 6: Verificar la capacidad de respuesta, no solo la finalización

La verificación debe cubrir al menos cuatro niveles:

  1. Prueba de orden: Registra código sincrónico, reacciones de Promise, queueMicrotask() y temporizadores en una página mínima y confirma que el orden observado coincida con la deducción.
  2. Inspección de la línea de tiempo: Graba el clic, los lotes y el pintado del progreso en las herramientas de rendimiento del navegador. Inspecciona tareas largas, microtareas continuas, espacios entre cuadros (frames) y cuándo se ejecutan realmente las devoluciones de llamada de entrada.
  3. Estrés y cancelación: Aumenta el recuento de registros, limita la CPU (throttling), haz clic y desplázate durante el procesamiento, y cancela la operación. Verifica que las colas no crezcan sin límite.
  4. Entornos límite: Verifica la alternativa en un navegador sin scheduler.yield(). Mueve la página a una pestaña en segundo plano y confirma que el flujo de trabajo del negocio no dependa incorrectamente de devoluciones de llamada continuas de requestAnimationFrame().

La aceptación requiere tanto el tiempo de finalización como la latencia de interacción. El procesamiento por lotes agrega sobrecarga de planificación y puede aumentar ligeramente la duración total; su propósito es dejar ventanas de ejecución para la entrada, el pintado y otras tareas necesarias. Si ese costo en el rendimiento general es inaceptable, optimiza el algoritmo o utiliza un worker en lugar de llenar nuevamente el hilo principal con microtareas.

Ejemplo de una respuesta sólida

«Deduciría el orden a partir del momento de encolado. El script actual es una sola tarea, por lo que A y B son sincrónicos. El temporizador solo programa una tarea futura. La reacción de Promise entra en la cola de microtareas antes de la devolución de llamada explícita de queueMicrotask, por lo que el punto de control registra promise primero. Esa devolución de llamada agrega nested detrás de la microtarea que ya estaba esperando. El orden final es A, B, promise, microtask, nested, timeout.

Después de una tarea, el navegador realiza un punto de control de microtareas y continúa hasta que la cola de microtareas esté vacía. El segundo fragmento continúa reponiendo esa cola, por lo que el punto de control permanece abierto durante mucho tiempo. Los temporizadores y los clics son tareas posteriores, y el pintado necesita que el hilo principal alcance una oportunidad de renderizado, por lo que todos ellos se retrasan. requestAnimationFrame no puede interrumpir JavaScript y await Promise.resolve solo se reanuda en otra microtarea, por lo que ninguno de los dos soluciona la inanición.

Si cada registro es rápido, procesaría con base en un presupuesto de tiempo y llamaría a scheduler.yield entre lotes. Como no está disponible en todos los navegadores, detectaría la característica y recurriría a setTimeout como alternativa. Comenzar con un presupuesto de cinco milisegundos es solo un experimento; lo ajustaría en los dispositivos de destino en función de la latencia de entrada y el rendimiento total. Si un registro es costoso por sí mismo, dividiría esa operación o la movería a un worker.

Finalmente, registraría una línea de tiempo de rendimiento y confirmaría que no haya una cascada continua de microtareas, que el progreso realmente se pinte, que los clics y el desplazamiento se ejecuten durante el procesamiento, y que la cancelación, las pestañas en segundo plano y la alternativa de compatibilidad se comporten correctamente. El diseño acepta cierta sobrecarga de planificación a cambio de una capacidad de respuesta medible».

Errores comunes

  • Recitar «sincrónico, microtarea, macrotarea» → No puede explicar por qué una microtarea anidada se ejecuta detrás de una que ya estaba en cola e ignora las múltiples fuentes de tareas → Anota el momento de encolado, la categoría y el estado de la cola línea por línea.
  • Llamar inmediato a setTimeout(..., 0) Un temporizador vencido solo hace que una tarea futura sea elegible y no puede interrumpir la tarea actual ni el punto de control → Indica la elegibilidad más temprana sin prometer un tiempo exacto.
  • Asumir que el pintado ocurre después de cada microtarea → Un punto de control vacía las microtareas continuamente y una oportunidad de renderizado posterior aún puede omitirse → Analiza el pintado después del punto de control completo.
  • Fragmentar con await Promise.resolve() La continuación sigue siendo una microtarea y no admite tareas posteriores de entrada o temporizadores → Programa la continuación en una tarea futura.
  • Usar llamadas infinitas a queueMicrotask() para tener «prioridad» → Privan de recursos a otras tareas y al renderizado → Reserva las microtareas para trabajos de consistencia cortos y finitos.
  • Mover trabajo pesado a requestAnimationFrame() La devolución de llamada aún se ejecuta en el hilo principal antes de pintar, por lo que el trabajo pesado retrasa el cuadro → Mantén el trabajo visual de rAF ligero y divide o descarga el trabajo de CPU.
  • Llamar a scheduler.yield() incondicionalmente → Algunos navegadores de uso extendido no lo admiten → Detecta la característica y prueba la alternativa con setTimeout().
  • Medir únicamente la duración total → Un rendimiento global normal puede ocultar largos períodos de entrada no receptiva → Inspecciona también la latencia de interacción, las tareas largas, los cuadros y el crecimiento de la cola.
  • Usar un tamaño de lote fijo en todas partes → El costo por elemento, la frecuencia de actualización y la velocidad del dispositivo varían → Comienza con un presupuesto de tiempo y ajústalo en el entorno de destino.

Preguntas de seguimiento y respuestas

Pregunta de seguimiento 1: ¿Qué se ejecuta primero, dos temporizadores con retraso cero o un clic?

Los nombres de las API por sí solos no establecen un orden universal entre diferentes fuentes de tareas. Un navegador puede mantener múltiples colas de tareas y elegir entre las colas ejecutables mientras preserva el orden dentro de una misma fuente de tareas. Aclara cuándo los temporizadores pasaron a ser elegibles, cuándo ocurrió el clic y qué más ocupaba la página; luego observa la implementación de destino. Los fragmentos deterministas de entrevistas normalmente controlan estas condiciones.

Pregunta de seguimiento 2: Si scheduler.yield() devuelve una Promise, ¿por qué es una cesión real?

La propiedad importante es cómo la API programa la resolución, no el tipo de retorno. Una continuación después de una Promise ya resuelta se une al punto de control de microtareas actual. scheduler.yield() programa una tarea priorizada posterior que resuelve su Promise, permitiendo que el trabajo pendiente, como la entrada, se ejecute antes de la continuación. Aún necesita detección de características, y las operaciones diminutas no deberían convertirse cada una en una tarea separada.

Pregunta de seguimiento 3: ¿Procesar 100.000 registros en un worker aún puede congelar la página?

Sí. El hilo principal todavía puede sobrecargarse por mensajes excesivos, copias de datos grandes, cambios frecuentes en el DOM o una combinación costosa de resultados. Agrupa los mensajes de progreso en lotes, limita su frecuencia, evalúa objetos transferibles o un protocolo justificado de memoria compartida, y confirma solo los cambios visuales necesarios en el hilo principal.

Pregunta de seguimiento 4: ¿Cuándo se debe usar queueMicrotask()?

Úsalo para trabajos cortos y finitos que deben ejecutarse después de la lógica sincrónica actual pero antes de otros eventos, como dar a un acierto de caché sincrónico y a un fallo basado en Promise el mismo orden de devolución de llamada, o agrupar una notificación interna de una biblioteca. No es un planificador para trabajos largos. Establece tanto la regla de terminación como el límite de trabajo.

Pregunta de seguimiento 5: ¿Cómo cancelarías la operación fragmentada?

Verifica una AbortSignal al inicio de cada lote y después de cada cesión de control, deja de tomar registros y evita que una operación anterior sobrescriba el progreso o el resultado de una solicitud más nueva. Un diseño con workers necesita un mensaje de cancelación y una regla para descartar resultados tardíos. Las comprobaciones demasiado espaciadas reaccionan lentamente; las comprobaciones alrededor de cada operación diminuta agregan sobrecarga, así que mide en los límites de los lotes.

Pregunta de seguimiento 6: ¿Garantiza requestAnimationFrame() un pintado inmediato?

No. Solicita una devolución de llamada antes de un repintado futuro, mientras que el navegador puede programar u omitir el renderizado según la visibilidad y la política de renderizado. La mayoría de los navegadores pausan estas devoluciones de llamada en pestañas en segundo plano. Incluso cuando la devolución de llamada se ejecuta, el trabajo prolongado dentro de ella continúa retrasando el pintado real.

Fuentes públicas

Preguntas relacionadas