Event loop en JavaScript: cómo funciona por dentro

D
DanisCh
• 13 min de lectura
Event loop en JavaScript: cómo funciona por dentro
JavaScript

JavaScript es un lenguaje de un solo hilo: solo puede ejecutar una cosa a la vez. Sin embargo, las aplicaciones web hacen peticiones HTTP, esperan respuestas de bases de datos, leen archivos y gestionan múltiples eventos del usuario sin bloquearse. ¿Cómo es posible?

La respuesta es el event loop. Entenderlo no es solo un requisito para las entrevistas técnicas: es la diferencia entre escribir código asíncrono que funciona como esperas y depurar comportamientos que parecen imposibles durante horas.

Las piezas que componen el sistema

El event loop no es una sola cosa. Es el resultado de la interacción de varias partes:

  • Call Stack (pila de llamadas): donde se ejecuta el código. Es una pila LIFO: lo último que entra es lo primero en salir. Cuando llamas a una función, se añade un frame a la pila. Cuando la función termina, el frame se elimina.
  • Heap: memoria donde se almacenan los objetos. No tiene estructura de pila ni cola.
  • Web APIs (en el navegador) / Node.js APIs (en Node): funciones proporcionadas por el entorno, no por el motor JavaScript. setTimeout, fetch, addEventListener, lectura de archivos... estas operaciones no las ejecuta V8 directamente.
  • Callback Queue (también llamada Task Queue o Macrotask Queue): cola donde esperan los callbacks listos para ejecutarse.
  • Microtask Queue: cola de mayor prioridad que la Callback Queue. Aquí van las promesas resueltas y MutationObserver.
  • Event Loop: el bucle que conecta todo. Comprueba si el Call Stack está vacío y, si lo está, mueve tareas de las colas al stack.

El Call Stack en detalle

function multiplicar(a, b) {
    return a * b;
}

function cuadrado(n) {
    return multiplicar(n, n);
}

function imprimirCuadrado(n) {
    const resultado = cuadrado(n);
    console.log(resultado);
}

imprimirCuadrado(5);

// Evolución del Call Stack:
//
// 1. [imprimirCuadrado]                     ← se llama imprimirCuadrado
// 2. [imprimirCuadrado, cuadrado]            ← cuadrado se apila encima
// 3. [imprimirCuadrado, cuadrado, multiplicar] ← multiplicar se apila
// 4. [imprimirCuadrado, cuadrado]            ← multiplicar termina, se elimina
// 5. [imprimirCuadrado]                      ← cuadrado termina, se elimina
// 6. [imprimirCuadrado, console.log]         ← console.log se apila
// 7. [imprimirCuadrado]                      ← console.log termina
// 8. []                                      ← imprimirCuadrado termina, stack vacío

Cuando el stack tiene demasiadas llamadas encadenadas (una función que se llama a sí misma infinitamente, por ejemplo), obtienes el error Maximum call stack size exceeded: el stack se ha desbordado.

// Stack overflow: recursión infinita
function infinita() {
    return infinita();   // se apila y se apila hasta desbordarse
}
// infinita();   // RangeError: Maximum call stack size exceeded

Por qué el código síncrono bloquea todo

Cuando una operación lenta es síncrona, ocupa el Call Stack durante todo el tiempo que tarda. Mientras está en el stack, el navegador no puede procesar clicks, no puede redibujar la pantalla, no puede hacer nada. Esto es lo que se llama bloquear el hilo principal.

// ❌ Código bloqueante: congela el navegador durante ~3 segundos
function esperarSincrona(ms) {
    const fin = Date.now() + ms;
    while (Date.now() < fin) { }   // bucle vacío que bloquea el stack
}

console.log("Antes");
esperarSincrona(3000);     // el navegador se congela 3 segundos
console.log("Después");    // solo se muestra cuando el bloqueo termina

// Durante esos 3 segundos:
// - Los clicks no responden
// - Las animaciones se detienen
// - El scroll no funciona
// - El navegador puede mostrar "Esta página no responde"

La solución al bloqueo es la asincronía: delegar las operaciones lentas a las Web APIs y continuar ejecutando el resto del código.

Cómo funciona setTimeout: la primera asincronía

console.log("1");

setTimeout(function callback() {
    console.log("2");
}, 1000);

console.log("3");

// Salida:
// 1
// 3
// 2   ← aparece 1 segundo después

Esto es lo que ocurre paso a paso:

// PASO 1: "console.log("1")" entra y sale del stack
// Stack: [console.log("1")]  →  []
// Output: "1"

// PASO 2: setTimeout entra al stack
// Stack: [setTimeout]
// JavaScript registra el callback en la Web API con el temporizador de 1 segundo
// setTimeout sale del stack INMEDIATAMENTE (no espera)
// Stack: []

// PASO 3: "console.log("3")" entra y sale del stack
// Stack: [console.log("3")]  →  []
// Output: "3"

// PASO 4: 1 segundo después, el temporizador de la Web API expira
// La Web API mueve el callback a la Callback Queue
// Callback Queue: [callback]

// PASO 5: el Event Loop comprueba si el stack está vacío (sí lo está)
// Mueve el callback al stack
// Stack: [callback]
// Output: "2"
// Stack: []

El mito del setTimeout(fn, 0)

console.log("1");

setTimeout(function() {
    console.log("2");
}, 0);   // ← 0 ms de delay

console.log("3");

// Salida:
// 1
// 3
// 2   ← DESPUÉS de "3", aunque el delay es 0

// ¿Por qué? Porque aunque el temporizador expire inmediatamente,
// el callback va a la Callback Queue y espera a que el stack esté vacío.
// El Event Loop solo lo moverá al stack cuando haya terminado todo
// el código síncrono del frame actual.

setTimeout(fn, 0) no significa "ejecutar ahora". Significa "ejecutar tan pronto como el stack esté vacío". Es útil para diferir código que no debería bloquear la respuesta inmediata, como actualizaciones de UI después de procesar un evento.

La Microtask Queue: promesas y queueMicrotask

Las promesas no usan la Callback Queue. Tienen su propia cola de mayor prioridad: la Microtask Queue. La regla es fundamental:

Después de cada tarea (macrotarea), el Event Loop vacía completamente la Microtask Queue antes de procesar la siguiente macrotarea.

console.log("1");

setTimeout(() => console.log("2"), 0);   // macrotarea

Promise.resolve().then(() => console.log("3"));   // microtarea

console.log("4");

// Salida:
// 1
// 4
// 3   ← la microtarea va antes que la macrotarea
// 2
// Trazando el orden con más detalle
console.log("Inicio");

setTimeout(() => {
    console.log("Timeout 1");
    Promise.resolve().then(() => console.log("Promesa dentro de timeout"));
}, 0);

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

Promise.resolve()
    .then(() => {
        console.log("Promesa 1");
        return "valor";
    })
    .then(() => console.log("Promesa 2 (encadenada)"));

Promise.resolve().then(() => console.log("Promesa 3"));

console.log("Fin");

// Salida:
// Inicio
// Fin
// Promesa 1                    ← microtask queue, primer .then
// Promesa 2 (encadenada)       ← microtask queue, segundo .then (añadido durante vaciado)
// Promesa 3                    ← microtask queue
// Timeout 1                    ← macrotask queue
// Promesa dentro de timeout    ← microtask queue (vaciada después de Timeout 1)
// Timeout 2                    ← macrotask queue

Este ejemplo ilustra algo importante: cuando la Microtask Queue se está vaciando, si se añaden nuevas microtareas (como el .then encadenado), estas también se procesan en el mismo ciclo antes de pasar a la siguiente macrotarea. La Microtask Queue se vacía hasta que está completamente vacía.

El peligro de las microtareas infinitas

// ❌ PELIGRO: microtareas que se generan recursivamente bloquean el event loop
function microtareaInfinita() {
    Promise.resolve().then(microtareaInfinita);
}
// microtareaInfinita();
// La Microtask Queue nunca se vacía → el Event Loop nunca procesa macrotareas
// → el navegador se congela (sin el error de stack overflow, más difícil de detectar)

// A diferencia de:
function macrotareaRecursiva() {
    setTimeout(macrotareaRecursiva, 0);
}
macrotareaRecursiva();
// Esto sí permite que otras tareas se intercalen entre cada iteración

Macrotareas vs microtareas: la tabla completa

TipoColaPrioridadEjemplos
Código síncronoCall Stack (directa)Máxima (se ejecuta ahora)Todo código normal
MicrotareaMicrotask QueueAlta (antes de la siguiente macrotarea)Promise.then/catch/finally, queueMicrotask(), MutationObserver, await
MacrotareaCallback Queue / Task QueueNormal (una por ciclo del event loop)setTimeout, setInterval, setImmediate (Node), eventos del DOM, requestAnimationFrame*

*requestAnimationFrame tiene un comportamiento especial: se ejecuta antes de que el navegador repinte, pero después de que se vacíe la Microtask Queue.

async/await y el event loop

async/await es azúcar sintáctico sobre las promesas. Un await pausa la ejecución de la función async (no del hilo completo) y devuelve el control al Event Loop. Cuando la promesa se resuelve, la continuación de la función se programa como una microtarea.

async function fetchDatos() {
    console.log("A: inicio de fetchDatos");       // síncrono

    const resultado = await Promise.resolve(42);  // pausa aquí

    console.log("B: después del await:", resultado); // microtarea
}

console.log("1: antes de llamar");
fetchDatos();
console.log("2: después de llamar");

// Salida:
// 1: antes de llamar
// A: inicio de fetchDatos    ← síncrono, dentro de fetchDatos
// 2: después de llamar       ← fetchDatos está pausado, continúa el código exterior
// B: después del await: 42   ← microtarea, cuando la promesa se resolvió
// Ejemplo más completo
async function obtenerUsuario(id) {
    console.log(`[A] Iniciando solicitud para usuario ${id}`);

    // Simular fetch
    const usuario = await new Promise(resolve => {
        setTimeout(() => resolve({ id, nombre: "Ana" }), 100);
    });

    console.log(`[B] Usuario recibido:`, usuario);
    return usuario;
}

async function main() {
    console.log("[1] Inicio de main");

    const promesa = obtenerUsuario(42);

    console.log("[2] La solicitud está en curso (no bloqueamos)");

    const usuario = await promesa;

    console.log("[3] Continuamos con el usuario:", usuario.nombre);
}

main();
console.log("[4] Código tras llamar a main");

// Salida:
// [1] Inicio de main
// [A] Iniciando solicitud para usuario 42
// [2] La solicitud está en curso (no bloqueamos)
// [4] Código tras llamar a main          ← main está pausada, sigue el exterior
// [B] Usuario recibido: {id: 42, nombre: 'Ana'}   ← tras ~100ms
// [3] Continuamos con el usuario: Ana

queueMicrotask: añadir microtareas directamente

// queueMicrotask() añade una función a la Microtask Queue directamente
// Es más explícito que Promise.resolve().then(fn)

console.log("inicio");

queueMicrotask(() => console.log("microtarea 1"));
queueMicrotask(() => console.log("microtarea 2"));
setTimeout(() => console.log("macrotarea"), 0);

console.log("fin");

// Salida:
// inicio
// fin
// microtarea 1
// microtarea 2
// macrotarea


// Caso de uso: operaciones que deben ocurrir "después del código actual"
// pero "antes del siguiente repintado" del navegador
function actualizarUI(datos) {
    procesarDatos(datos);

    queueMicrotask(() => {
        // Esto se ejecuta después de procesarDatos() pero antes de que
        // el navegador tenga oportunidad de repintar o de procesar más eventos
        document.getElementById("resultado").textContent = datos.valor;
    });
}

requestAnimationFrame: sincronizado con el repintado

// requestAnimationFrame ejecuta el callback justo antes del próximo repintado
// Es la forma correcta de hacer animaciones: sincronizado con ~60fps

function animar(timestamp) {
    // timestamp: milisegundos desde que comenzó la ejecución
    const elemento = document.getElementById("cuadrado");
    const posicion = Math.sin(timestamp / 1000) * 100;
    elemento.style.transform = `translateX(${posicion}px)`;

    requestAnimationFrame(animar);   // pedir el siguiente frame
}

requestAnimationFrame(animar);   // arrancar la animación

// A diferencia de setTimeout/setInterval, rAF:
// - Se sincroniza con la tasa de refresco del monitor (60fps, 120fps, etc.)
// - Se pausa automáticamente cuando la pestaña no está visible (ahorra batería)
// - Garantiza que la animación ocurre justo antes del repintado (sin glitches)


// Orden con respecto al event loop:
// 1. Código síncrono del frame actual
// 2. Microtask Queue (promesas, etc.)
// 3. requestAnimationFrame callbacks
// 4. Repintado del navegador
// 5. Siguientes macrotareas

El Event Loop en Node.js: fases

Node.js tiene un event loop más explícito, implementado con libuv. En lugar de una cola genérica de macrotareas, tiene fases con colas separadas que se procesan en orden:

// Las fases del event loop en Node.js (simplificado):
//
// 1. timers         → callbacks de setTimeout y setInterval cuyo tiempo ha expirado
// 2. pending        → callbacks de I/O diferidos de la iteración anterior
// 3. idle/prepare   → uso interno de Node
// 4. poll           → recuperar nuevos eventos de I/O; si no hay, esperar aquí
// 5. check          → callbacks de setImmediate
// 6. close          → callbacks de eventos 'close' (socket.on('close', ...))
//
// Entre cada fase: se vacía la Microtask Queue (process.nextTick primero, luego promesas)


// process.nextTick: cola especial de altísima prioridad (antes que las promesas)
console.log("1");

setTimeout(() => console.log("2 - setTimeout"), 0);
setImmediate(() => console.log("3 - setImmediate"));

Promise.resolve().then(() => console.log("4 - Promise"));
process.nextTick(() => console.log("5 - nextTick"));

console.log("6");

// Salida en Node.js:
// 1
// 6
// 5 - nextTick      ← process.nextTick antes que promesas
// 4 - Promise       ← microtareas
// 2 - setTimeout    ← fase timers (puede ir antes o después de setImmediate según la carga)
// 3 - setImmediate  ← fase check
// setImmediate vs setTimeout(fn, 0): el orden puede variar fuera de I/O
// Dentro de un callback de I/O, setImmediate siempre va primero:

const fs = require("fs");

fs.readFile("archivo.txt", () => {
    setTimeout(() => console.log("setTimeout"), 0);
    setImmediate(() => console.log("setImmediate"));
    // setImmediate siempre primero dentro de callbacks de I/O
});

// Fuera de I/O: el orden puede variar según el tiempo de inicialización

Patrones comunes y sus implicaciones

Por qué Promise.all no es paralelo de verdad

// Promise.all ejecuta las promesas "concurrentemente" (no en paralelo)
// JavaScript sigue siendo de un solo hilo
// Las operaciones de I/O se delegan a las Web APIs/libuv y ESAS sí son paralelas

const inicio = Date.now();

async function esperar(ms, nombre) {
    await new Promise(resolve => setTimeout(resolve, ms));
    console.log(`${nombre} completado en ${Date.now() - inicio}ms`);
}

// Secuencial: 100 + 200 + 300 = ~600ms
async function secuencial() {
    await esperar(100, "A");
    await esperar(200, "B");
    await esperar(300, "C");
}

// Concurrente con Promise.all: ~300ms (el más lento)
async function concurrente() {
    await Promise.all([
        esperar(100, "A"),
        esperar(200, "B"),
        esperar(300, "C")
    ]);
}

// Las tres solicitudes se inician casi simultáneamente
// El temporizador de cada una corre en paralelo en el entorno
// El event loop recibe los callbacks cuando cada uno termina

Evitar el bloqueo con tareas CPU-intensivas

// ❌ Tarea CPU-intensiva que bloquea el event loop
function calcularPrimosBloqueante(limite) {
    const primos = [];
    for (let n = 2; n <= limite; n++) {
        let esPrimo = true;
        for (let i = 2; i < n; i++) {
            if (n % i === 0) { esPrimo = false; break; }
        }
        if (esPrimo) primos.push(n);
    }
    return primos;
}
// calcularPrimosBloqueante(100000);  // el navegador se congela durante el cálculo


// ✅ Dividir la tarea en chunks usando setTimeout para ceder el control
async function calcularPrimosNoBloquante(limite) {
    const primos = [];
    const CHUNK = 1000;   // procesar 1000 números a la vez

    for (let inicio = 2; inicio <= limite; inicio += CHUNK) {
        const fin = Math.min(inicio + CHUNK, limite + 1);

        for (let n = inicio; n < fin; n++) {
            let esPrimo = true;
            for (let i = 2; i <= Math.sqrt(n); i++) {
                if (n % i === 0) { esPrimo = false; break; }
            }
            if (esPrimo) primos.push(n);
        }

        // Ceder el control al event loop entre chunks
        await new Promise(resolve => setTimeout(resolve, 0));
        // Durante este await, el event loop puede procesar clicks, repaints, etc.
    }

    return primos;
}


// ✅ Alternativa: Web Workers para cálculos verdaderamente paralelos
// worker.js
self.onmessage = function(e) {
    const { limite } = e.data;
    const primos = calcularPrimosBloqueante(limite);   // bloquea el worker, no el hilo principal
    self.postMessage(primos);
};

// main.js
const worker = new Worker("worker.js");
worker.postMessage({ limite: 100000 });
worker.onmessage = (e) => console.log("Primos:", e.data.length);
// El hilo principal sigue siendo responsivo durante el cálculo

Resumen visual del ciclo completo

// El Event Loop en pseudocódigo:
while (true) {
    // 1. Ejecutar todo el código síncrono hasta que el Call Stack esté vacío

    // 2. Vaciar la Microtask Queue completamente
    //    (process.nextTick en Node, luego Promise callbacks)
    while (microtaskQueue.length > 0) {
        ejecutar(microtaskQueue.shift());
        // Si esto genera nuevas microtareas, también se procesan aquí
    }

    // 3. requestAnimationFrame callbacks (solo en el navegador, antes del repintado)

    // 4. Repintado del navegador si es necesario (solo en el navegador)

    // 5. Tomar UNA macrotarea de la Callback Queue (si hay alguna)
    if (callbackQueue.length > 0) {
        ejecutar(callbackQueue.shift());
        // Después de esta macrotarea, volver a vaciar la Microtask Queue
    }

    // 6. Si no hay nada que hacer, esperar a que llegue algo
}

Resumen

  • JavaScript tiene un solo hilo de ejecución. El Call Stack es donde se ejecuta el código: solo puede hacer una cosa a la vez.
  • Las operaciones asíncronas (temporizadores, fetch, eventos) se delegan a las Web APIs o a libuv en Node. Cuando terminan, sus callbacks van a una cola.
  • El Event Loop comprueba continuamente si el Call Stack está vacío y, si lo está, mueve tareas de las colas al stack.
  • Hay dos tipos de colas: la Microtask Queue (alta prioridad: promesas, queueMicrotask) y la Callback/Task Queue (prioridad normal: setTimeout, setInterval, eventos DOM).
  • La Microtask Queue se vacía completamente después de cada tarea y antes de procesar cualquier macrotarea.
  • setTimeout(fn, 0) no ejecuta inmediatamente: espera a que el stack esté vacío y a que se procesen todas las microtareas pendientes.
  • async/await es azúcar sobre promesas. El await pausa la función async (no el hilo) y programa la continuación como microtarea.
  • Las tareas CPU-intensivas bloquean el event loop. La solución es dividirlas en chunks con setTimeout o moverlas a un Web Worker.
  • En Node.js, el event loop tiene fases explícitas. process.nextTick tiene prioridad incluso sobre las promesas. setImmediate se ejecuta en la fase "check", después de las callbacks de I/O.
Etiquetas: JavaScript

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a