Skip to main content
Featured image for blog post: Un hilo, dos colas: cómo JavaScript decide qué corre después
William Craig
William Craig
26 de julio de 2026 · 12 mins de lectura

Un hilo, dos colas: cómo JavaScript decide qué corre después

Hoy la mayoría revisamos más JavaScript del que escribimos. Un modelo produce cuarenta líneas, se ven bien, las pruebas pasan, y se despacha.

Lo cual está perfecto, hasta que el bug es de orden. El código generado es buenísimo para tener la forma correcta y estar sutilmente equivocado sobre cuándo corre cada cosa. Un valor leído después de un await que otra cosa ya cambió. Un await metido dentro de un bucle, convirtiendo en silencio veinte peticiones paralelas en veinte secuenciales. Una limpieza que se dispara un tick tarde. Nada de eso parece un bug. Se lee precioso, no tiene ningún subrayado rojo, y ninguna prueba lo atrapa a menos que ya sospecharas que estaba ahí.

Hay una sola forma confiable de detectar esa clase de error, y es saber cómo decide el runtime qué corre a continuación. No más o menos. Exactamente.

Por suerte, la máquina que toma esas decisiones es pequeña: un hilo, dos colas, y una sola regla sobre cuál de las dos gana. Eso es casi todo. Es una de las pocas partes de esta plataforma que de verdad puedes terminar de aprender, y una vez que la tienes deja de tratarse solo de asincronía. El mismo modelo te explica por qué un solo bucle pesado congela una página entera, dónde se enchufan al lenguaje las APIs del navegador como los temporizadores o fetch, cuánto cuesta de verdad un await, por qué una promesa que ya tiene su valor igual te hace esperar, y en qué momento de todo esto el navegador encuentra un hueco para pintar.

Así que este artículo no describe la máquina, te la entrega. Abajo hay un botón que congela la página que estás leyendo, cuatro runtimes que puedes recorrer instrucción por instrucción, y una promesa desarmada hasta los slots internos que la especificación realmente le da.

Todo lo que sigue corre en tu navegador mientras lees. Si dudas de alguna afirmación, puedes presionarla.

Un solo hilo. Aquí está la prueba.

Todo el mundo repite que JavaScript es de un solo hilo. Casi nadie te lo hace sentir.

Abajo hay dos barras en movimiento. La verde la mueve tu JavaScript, un paso por cada requestAnimationFrame. La ámbar es una animación transform de CSS y nada más. También hay un campo de texto. Escribe en él, después presiona el botón rojo y sigue escribiendo.

En vivo, en esta página

Congela el hilo y mira qué se muere

requestAnimationFrame · movido por tu JavaScript0 cuadros dibujados
transform de CSS · movido por el compositor
Bloquear por

Esto bloquea la página de verdad. El scroll, los clics y el teclado se detienen hasta que termina el bucle.

La barra de CSS normalmente sigue moviéndose, porque casi todos los navegadores corren las animaciones de transform en un hilo compositor aparte. Es la misma razón por la que una página trabada puede seguir pareciendo animada mientras nada de tu código puede correr.

La barra verde se muere en seco. El contador de cuadros deja de contar. Tus teclas no se pierden: se acumulan en algún lado y aparecen todas juntas cuando el bucle termina. Y la barra ámbar, en la mayoría de los navegadores, la pasa de largo, porque una animación de transform se le entrega a un hilo compositor aparte al que no le importa que tu código esté atascado.

Ese último detalle vale la pena guardarlo. "El hilo principal" y "el navegador" no son lo mismo. Una página puede verse viva mientras nada de tu código puede correr. Por eso una app congelada sigue mostrando un spinner girando, y por eso ese spinner te está mintiendo.

Ese bucle bloqueante es todo el problema en miniatura. Hay un hilo que ejecuta tu código, ejecuta una cosa a la vez, y no se detiene hasta que esa cosa retorna. Todo el resto del artículo existe para responder una sola pregunta: qué pasa con el trabajo que aparece mientras el hilo está ocupado.

Dónde esperan los callbacks de verdad

En este fragmento hay tres cosas compitiendo, y terminan en un orden que sorprende la primera vez.

Recórrelo paso a paso. La máquina de abajo muestra cada push y cada pop. Usa los botones, arrastra el deslizador, o haz clic en el panel y usa las flechas del teclado.

Tres lugares donde un callback puede esperar

Paso 1 de 16
1console.log('A');
2setTimeout(() => console.log('B'), 0);
3Promise.resolve().then(() => console.log('C'));
4console.log('D');

Todavía no se ha ejecutado nada. Tu archivo entero es un solo trabajo esperando en la cola de tareas.

Consola
vacía
El hilo
vacía
Fuera del hiloterritorio del navegador
vacía
Puerta abierta. El hilo está libre, así que el event loop puede entregar algo.
Cola de microtareasse vacía primero, entera
vacía
Cola de tareasuna por turno, después
run script

Cuatro cosas que vale la pena sacar de ahí.

Tu archivo entero es una tarea. Evaluar el script no es nada especial. Es un trabajo que el event loop tomó, y hasta que retorne nadie más tiene turno. Por eso todos los callbacks de este ejemplo corren después de console.log('D'), incluso aquel cuyo temporizador venció hace rato.

setTimeout no es JavaScript. Busca en la especificación del lenguaje y no lo vas a encontrar. Es una función que te presta el navegador. Llamarla le entrega un callback al navegador y retorna de inmediato. La espera ocurre en un lugar donde tu hilo no está.

Cero milisegundos es una petición, no una promesa. setTimeout(fn, 0) significa "pon esto en la cola de tareas apenas venza el temporizador". No significa pronto. Significa después del trabajo actual, después de todas las microtareas, y después de lo que ya estuviera encolado.

Las dos colas no son iguales. La cola de microtareas se vacía por completo. La cola de tareas entrega un solo trabajo y después el bucle vuelve a revisar las microtareas. Esa asimetría es lo más útil que puedes saber de toda esta máquina.

Una promesa es un objeto, no un evento

Aquí es donde la mayoría de las explicaciones se ponen vagas, y donde deja de dar miedo apenas lo ves.

Una promesa no es una suscripción, ni un listener, ni un valor mágico que llega después. Es un objeto común y corriente con un puñado de slots internos que tu código no puede tocar. La especificación los escribe entre dobles corchetes: [[PromiseState]], [[PromiseResult]], [[PromiseFulfillReactions]], [[PromiseRejectReactions]], [[PromiseIsHandled]].

Esa es toda la estructura de datos. Dos campos para el resultado, dos listas de qué hacer al respecto, y una bandera.

Recorre este ejemplo con el panel de promesas abierto y mira cómo se van llenando los slots.

Qué es realmente una promesa

Paso 1 de 15
1const p = new Promise((resolve) => {
2 setTimeout(() => resolve('done'), 100);
3});
4
5p.then(value => console.log(value));

Una promesa es un objeto con slots internos que tu código nunca puede tocar. Aquí están.

Consola
vacía
El hilobusy
run script
Fuera del hiloterritorio del navegador
vacía
Puerta cerrada. El hilo está ocupado, así que las colas están congeladas.
Cola de microtareasse vacía primero, entera
vacía
Cola de tareasuna por turno, después
vacía
Objetos Promise
vacía

Hay dos momentos ahí que conviene mirar despacio.

El executor, esa función que le pasas a new Promise, corre de inmediato y de forma síncrona. No se difiere, no se encola, no tiene nada de asíncrono. new Promise(...) lo llama antes de que el constructor retorne. Toda la asincronía que consigas viene de lo que pongas adentro, normalmente una API del navegador como un temporizador o fetch.

Y .then hace dos trabajos distintos que es fácil confundir en uno solo. Construye un registro de reacción, un objeto pequeño que guarda tu callback, y retorna una promesa nueva. Si la promesa sobre la que lo llamaste sigue pendiente, la reacción se guarda dentro de su lista [[PromiseFulfillReactions]]. Si ya está resuelta, no hay nada que esperar, así que la reacción va directo a la cola de microtareas.

En cualquier caso, llamar a resolve() nunca llama a tu handler. Escribe dos slots y mueve las reacciones guardadas a la cola. Ejecutarlas es una vuelta aparte.

Lo que cuesta una cadena

Como .then retorna una promesa nueva, puedes encadenar. Y como cada eslabón es su propia promesa, cada eslabón es su propia vuelta por la cola de microtareas.

Lo que cuesta una cadena

Paso 1 de 14
1Promise.resolve(1)
2 .then(n => n * 2)
3 .then(n => n * 2)
4 .then(n => console.log(n));

Promise.resolve(1) devuelve una promesa que ya está cumplida con 1.

Consola
vacía
El hilobusy
run script
Fuera del hiloterritorio del navegador
vacía
Puerta cerrada. El hilo está ocupado, así que las colas están congeladas.
Cola de microtareasse vacía primero, entera
vacía
Cola de tareasuna por turno, después
vacía
Objetos Promise
P1
[[PromiseState]]fulfilled
[[PromiseResult]]1
[[PromiseIsHandled]]false
[[PromiseFulfillReactions]]
[]

Tres llamadas a .then, tres visitas distintas a la cola. Fíjate en que los tres registros de reacción se crean de forma síncrona, en una sola pasada, antes de que corra un solo callback. Dos de ellos quedan guardados dentro de promesas que todavía no tienen ningún valor.

Esto no es una advertencia de rendimiento. Una microtarea es realmente barata, y si estás encadenando doce tienes problemas de diseño más grandes que la cola. Es una corrección al modelo mental: una cadena no es un cálculo diferido, son N promesas que despiertan al bucle una vez cada una.

async / await es la misma máquina con mejor sintaxis. Cada await suspende la función, registra una reacción sobre la promesa esperada, y retoma dentro de una microtarea. Una función async con cuatro awaits hace cuatro vueltas. Por eso esto retorna al llamador antes de imprimir nada:

await null igual encola una microtarea, así que afuera se imprime primero. No existe un await que salga gratis.

La regla que de verdad importa

Casi todos aprenden que "las microtareas tienen prioridad sobre las tareas" y se quedan ahí. La versión precisa es más útil:

La cola de microtareas se vacía por completo después de cada tarea, no solo al final del script. Y cualquier microtarea que encole otra microtarea se vacía en esa misma pasada.

Lo que significa que un bucle de microtareas puede matar de hambre al navegador entero, mientras que el mismo bucle escrito con setTimeout no puede. Renderizar es un paso del event loop, y ese paso no llega hasta que la cola de microtareas esté vacía.

Corre los dos. El mismo bucle, el mismo presupuesto de tiempo, resultados opuestos.

En vivo, en esta página

El mismo bucle, dos colas, resultados opuestos

queueMicrotask

callbacks ejecutados
cuadros pintados
tiempo

sin ejecutar

setTimeout(fn, 0)

callbacks ejecutados
cuadros pintados
tiempo

sin ejecutar

Ambas corridas están limitadas a 1.2 segundos. Fíjate también en la cantidad de callbacks: la versión con tareas es muchísimo más lenta por callback, porque los navegadores limitan los setTimeout anidados a unos 4ms después de algunos niveles.

La cantidad de callbacks es la parte interesante. En mi máquina la versión con microtareas ejecuta unos dos millones de callbacks y pinta un cuadro. La de tareas ejecuta unos 260 y pinta setenta. El mismo presupuesto, el mismo cuerpo del bucle.

Ahí hay dos cosas distintas pasando. La versión con microtareas mata de hambre al paso de render, porque el navegador nunca llega a él. La de tareas es lenta por callback a propósito: los navegadores limitan los setTimeout anidados a unos 4ms después de unos cinco niveles, que es exactamente los 4.6ms por callback que dan esos números.

Si alguna vez necesitas masticar mucho trabajo sin congelar la página, ese es el intercambio que estás haciendo. Pártelo en tareas y dale al navegador la oportunidad de dibujar entre una y otra. O muévelo a un worker, que es la única forma de conseguir un segundo hilo de verdad.

Ahora adivina

Dos fragmentos. Elige antes de seguir bajando.

Adivina primero

¿En qué orden se imprimen?

1new Promise((resolve) => {
2 console.log(1);
3 resolve(2);
4}).then(result => console.log(result));
5
6console.log(3);

Aquí está ese mismo caso paso a paso, por si la respuesta se sintió como una trampa:

El que engaña a todo el mundo

Paso 1 de 13
1new Promise((resolve) => {
2 console.log(1);
3 resolve(2);
4}).then(result => console.log(result));
5
6console.log(3);

Objeto creado, estado pending.

Consola
vacía
El hilobusy
run script
new Promise(executor)
Fuera del hiloterritorio del navegador
vacía
Puerta cerrada. El hilo está ocupado, así que las colas están congeladas.
Cola de microtareasse vacía primero, entera
vacía
Cola de tareasuna por turno, después
vacía
Objetos Promise
p
[[PromiseState]]pending
[[PromiseResult]]undefined
[[PromiseIsHandled]]false
[[PromiseFulfillReactions]]
[]

resolve(2) corre en la línea 3. El log de ese valor sale al final. En el código no se movió nada, solo tu modelo de cuándo corren los handlers.

El segundo, y este es el que agarra desprevenida a la gente que ya se considera cómoda con todo esto:

Adivina primero

¿En qué orden se imprimen?

1setTimeout(() => {
2 console.log('t1');
3 Promise.resolve().then(() => console.log('m1'));
4}, 0);
5
6setTimeout(() => console.log('t2'), 0);

Para qué sirve todo esto

Cuatro cosas para las que uso este modelo de verdad.

Leer condiciones de carrera. Cuando dos callbacks salen en un orden que no esperabas, la pregunta casi nunca es "cuál es más rápido". Es "en qué cola está cada uno". Un callback de promesa y uno de temporizador no compiten por velocidad, compiten por categoría, y la promesa gana siempre.

Detectar UI congelada antes de que llegue a producción. Cualquier bucle síncrono sobre un arreglo grande, cualquier JSON.parse pesado, cualquier layout thrashing en un bucle apretado es exactamente el botón rojo de arriba. Si tarda 200ms, la página está muerta 200ms. No hay puntos parciales.

Saber que await es un punto de rendición. El estado puede cambiar entre la línea anterior a un await y la siguiente. Otro código corrió en ese hueco. Ahí nacen muchos bugs sutiles en código que se ve perfectamente secuencial.

No confiar en setTimeout(fn, 0). No significa "ejecuta esto a continuación". Significa "ejecuta esto después de todo lo que ya está encolado, más todas las microtareas que eso produzca, más un límite que yo no controlo".

Dónde leer más

El mecanismo no es folclore, está escrito. Las colas y el paso de render viven en el modelo de procesamiento del event loop de la especificación HTML. Los slots de las promesas y los registros de reacción están en ECMA-262, sección 27.2. Los dos son más áridos que cualquier artículo de blog, y los dos zanjan una discusión como ningún artículo puede.

Una advertencia antes de que te vayas: todo lo de arriba es el event loop del navegador. Node.js corre uno distinto, con fases, más process.nextTick, que se cuela incluso por delante de la cola de microtareas, y sin ningún paso de render, porque no hay nada que renderizar. La mitad de este artículo que habla de promesas se traslada intacta. La mitad que habla de la cola de tareas, no.

Todo lo demás, solo tienes que presionarlo.


Tags

  • JavaScript
  • Event Loop
  • Promesas
  • Desarrollo Web
  • Interactivo