Inicio / Artículos

Comprendiendo los cierres de JavaScript y el bucle de eventos

Las cierres de JavaScript mantienen vivas las variables externas; el bucle de eventos organiza la pila de llamadas, las microtareas y las macrotareas. Ambos conceptos se explican con ejemplos de código ejecutables.

Editor: REACTAPP.TOP16 min de lectura3605 palabras

Fuentes: JavaScript Closures Explained in 2 Minutes, The JavaScript Event Loop — Finally Explained With Zero Confusion

JavaScript a menudo se describe como un lenguaje lleno de peculiaridades, pero dos de sus comportamientos más malentendidos: los cierres y el bucle de eventos, en realidad provienen de la misma idea fundamental: las funciones y las tareas programadas no desaparecen solo porque el código que las rodea continúa ejecutándose. Comprender cómo una función puede “recordar” variables después de que su función padre haya terminado, y cómo el entorno de ejecución decide cuándo ejecutar código que no está listo de inmediato, requiere dejar de ver a JavaScript como un simple script línea por línea y empezar a considerarlo como un sistema con reglas de memoria y temporización. Este artículo explica ambas ideas, comenzando por los cierres.

Comprendiendo los cierres

Qué es realmente un cierre

Una cierre ocurre cuando una función mantiene variables del ámbito en el que fue creada y puede acceder a ellas incluso después de que la función externa haya finalizado su ejecución.

Una forma útil de imaginarlo es pensar en una función que lleva consigo un pequeño “mochila” con variables a dondequiera que vaya.

function createCounter() {
  let count = 0;
return function () {
    count++;
    return count;
  };
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

Obsérvese que createCounter() se ejecuta y devuelve un valor, pero la función interna que devuelve sigue teniendo acceso a count. Ese acceso persistente, incluso después de que la llamada externa haya terminado técnicamente, es lo que constituye el cierre.

Situaciones típicas en las que aparecen los cierres

Los cierres son importantes siempre que una función necesita retener cierta información después de que la función que la creó ya haya finalizado. Se encontrará con este patrón en varios escenarios cotidianos:

  • Variables privadas – ocultar datos para que el código externo no pueda acceder a ellos directamente.
  • Fábricas de funciones – que generan funciones personalizadas en función de parámetros.
  • Callback – que capturan valores para que sigan estando disponibles cuando finalmente se ejecute el callback.
  • Timer – que permiten acceder a variables desde el interior de setTimeout o setInterval.
  • Manejadores de eventos – que almacenan datos relacionados con un evento específico.
  • React – que ayuda a comprender los callbacks, los efectos y el problema de la “cierre obsoleto”.
  • Por qué son importantes

    Los cierres permiten mantener el estado sin depender de variables globales, lo que hace que el código sea más limpio y seguro.

    function createAccount(initialBalance) {
      let balance = initialBalance;
      return {
        deposit(amount) {
          balance += amount;
        },
        getBalance() {
          return balance;
        }
      };
    }
    const account = createAccount(100);
    account.deposit(50);
    console.log(account.getBalance()); // 150
    console.log(account.balance);      // undefined
    

    En ese ejemplo, balance no puede accederse directamente desde fuera de la función. Solo los métodos del objeto, devueltos por createAccount, tienen acceso a él.

    Esa estructura te brinda algunas ventajas reales:

    • Mejor encapsulación
    • Estatuto verdaderamente privado
    • Código más simple y legible
    • Menos dependencia de variables globales

    El ejemplo clásico del bucle

    Una demostración típica de cómo funcionan los cierres es la diferencia entre usar var y let dentro de un bucle que programa callbacks.

    Así es como ocurre con var:

    for (var i = 1; i <= 3; i++) {
      setTimeout(() => {
        console.log(i);
      }, 1000);
    }
    // Output:
    // 4
    // 4
    // 4
    

    El resultado no es el que muchas personas esperan al principio, y la razón es que var tiene ámbito de función en lugar de ámbito de bloque. Las tres callbacks de setTimeout terminan refiriéndose a la misma variable i. Para cuando alguna de ellas se ejecuta realmente, el bucle ya ha finalizado y i ya ha alcanzado el valor 4.

    Ahora compáralo con let:

    for (let i = 1; i <= 3; i++) {
      setTimeout(() => {
        console.log(i);
      }, 1000);
    }
    // Output:
    // 1
    // 2
    // 3
    

    Dado que let tiene ámbito de bloque, cada iteración del bucle cuenta con su propia asignación independiente de i. Eso significa que cada función de callback conserva un valor diferente, por lo que los números se imprimen en el orden que uno esperaría naturalmente.

    Una conversación común en entrevistas

    Pregunta: ¿Qué es un cierre en JavaScript?

    Un cierre es una función que recuerda y puede seguir accediendo a variables del ámbito en el que fue definida, incluso después de que la función exterior haya finalizado su ejecución.

    Pregunta adicional: ¿Dónde se utilizarían realmente los cierres?

    Una respuesta sólida abarca usos prácticos además de solo la definición:

    Las cierres aparecen con mayor frecuencia en callbacks, temporizadores, manejadores de eventos y fábricas de funciones. También son útiles siempre que se necesita que un estado permanezca privado en lugar de estar en el ámbito global.

    La conclusión

    La idea central se resume en una sola ecuación: una función más las variables que la rodean equivalen a un cierre. Si se queda en su mente la imagen de una mochila que lleva consigo las variables capturadas a dondequiera que sea llamada, todo el concepto se vuelve mucho menos misterioso.

    Comprendiendo el bucle de eventos

    Los cierres explican cómo una función puede retener datos a lo largo del tiempo. El bucle de eventos explica cómo JavaScript decide cuándo ejecutar realmente el código que no está listo de inmediato, y de ahí proviene gran parte de las salidas confusas en la consola.

    Casi todo desarrollador de JavaScript se encuentra eventualmente con alguna versión exacta de este fragmento, ya sea en una entrevista o por casualidad:

    console.log("1");
    
    setTimeout(() => {
      console.log("2");
    }, 0);
    
    console.log("3");
    

    Al leerlo de arriba hacia abajo, uno esperaría que la consola mostrara 1, luego 2 y después 3; el tiempo de espera está establecido en cero milisegundos, por lo que no debería haber ninguna demora.

    Pero al ejecutarlo en la consola de un navegador o en Node, el resultado real es este:

    1
    3
    2
    

    Entonces, ¿por qué el código programado para ejecutarse “inmediatamente” en realidad se ejecuta *después* del código escrito más tarde en el archivo?

    Para responder a esto es necesario comprender el mecanismo que subyace a tu código: el bucle de eventos. En lugar de perderse en vocabulario técnico sobre contextos de ejecución y algoritmos de programación, resulta útil crear un modelo mental simple y claro de lo que realmente ocurre.

    La contradicción fundamental en el corazón de JavaScript

    Antes de los diagramas y las definiciones, hay un hecho que debes asimilar:

    JavaScript es estrictamente de un solo hilo.

    Eser de un solo hilo significa que JavaScript funciona con exactamente una pila de llamadas. Tiene un “cerebro” de procesamiento único y solo puede ejecutar una línea de código en cualquier momento dado. No puede ejecutar dos funciones en paralelo, ni dividir un cálculo entre varios núcleos por sí mismo.

    Esa restricción plantea una pregunta obvia. Imagina un fragmento de código que necesita esperar tres segundos completos a que responda una API lenta: con solo un hilo disponible y sin nada más que ayude, ¿qué podrá hacer el resto de la página durante esa espera?

    En un entorno puramente de una sola hilera sin ninguna ayuda, todo el navegador se congelaría. No podrías hacer clic en nada, desplazarte, escribir en un campo ni ver cómo se reproduce una animación; todo se detendría hasta que finalmente llegara la respuesta de la red.

    Pero eso no es lo que realmente sucede. Las aplicaciones web modernas transmiten video de forma continua, envían solicitudes de red en segundo plano, actualizan los datos analíticos silenciosamente y aceptan entradas del teclado al mismo tiempo, sin bloquearse.

    Entonces, ¿cómo lo logra un lenguaje de una sola hilera?

    La respuesta: el propio JavaScript es de una sola hilera, pero el entorno que lo rodea —el navegador o Node.js— no lo es.

    Un restaurante como modelo mental

    La forma más clara de comprender esta arquitectura es imaginar una cocina ocupada de comida rápida:

    [ Customer Orders ] ──> [ The Solo Chef ] (Call Stack)
                                  │
                                  ▼ Hands off slow jobs
                            [ The Kitchen Staff ] (Web APIs)
                                  │
                                  ▼ Ready order slips
                            [ Order Counter ] (Queues)
                                  ▲
                                  │ Checks constantly
                            [ The Floor Manager ] (The Event Loop)
    

    Así es como se distribuyen las funciones:

    1. El chef solista (la pila de llamadas). Rápido y hábil, pero capaz de trabajar solo en un pedido a la vez, terminándolo por completo antes de comenzar el siguiente.
    2. Los asistentes de cocina (APIs web). Cuando un pedido requiere una espera larga —un temporizador o una respuesta lenta del repartidor— el chef no se queda parado mirando el reloj. En lugar de eso, entrega el pedido a un asistente con instrucciones para vigilarlo y avisar cuando esté listo, mientras el chef vuelve a ocuparse de la tarea siguiente.
    3. El mostrador de pedidos (las colas de callbacks y microtareas). Una vez que termina el temporizador, el asistente no interrumpe al chef en medio de su tarea entregándole el pedido directamente. Eso causaría caos. En su lugar, el pedido terminado se coloca en el mostrador a la espera.
  • El gerente del local (el bucle de eventos). Este gerente se mueve continuamente de un lado a otro entre el mostrador y el chef, siguiendo una regla estricta: nunca interrumpir al chef mientras trabaja. En el instante en que las manos del chef quedan completamente libres, el gerente toma la siguiente ficha que espera en el mostrador y se la entrega.
  • Ese es todo el sistema en miniatura. Teniendo en mente la imagen del restaurante, es hora de sustituir esos roles por sus nombres reales en el motor de JavaScript.

    El motor denomina de manera diferente a estas cuatro partes, pero cada una se corresponde directamente con un rol del restaurante.

    El diagrama a continuación muestra cómo el entorno de ejecución (navegador o Node.js) rodea al motor de JavaScript y conecta estas partes entre sí.

    ┌───────────────────────────────────────────────────────────┐
    │                     BROWSER / RUNTIME                     │
    │                                                           │
    │   ┌─────────────────┐             ┌───────────────────┐   │
    │   │   CALL STACK    │             │     WEB APIs      │   │
    │   │ (JS Engine/V8)  │             │                   │   │
    │   │                 │ ──────────> │  setTimeout()     │   │
    │   │  fnA()          │             │  fetch()          │   │
    │   │  console.log()  │             │  DOM Events       │   │
    │   └────────┬────────┘             └─────────┬─────────┘   │
    │            ▲                                │             │
    │            │                                │ Finished    │
    │            │                                ▼             │
    │            │                      ┌───────────────────┐   │
    │            │                      │  MICROTASK QUEUE  │   │
    │            │                      │  (Promises, VIP)  │   │
    │            │                      └─────────┬─────────┘   │
    │            │                                │             │
    │      EVENT LOOP                             ▼             │
    │   "Is Stack empty?"               ┌───────────────────┐   │
    │            └───────────────────── │  MACROTASK QUEUE  │   │
    │                                   │ (Timers, Clicks)  │   │
    │                                   └───────────────────┘   │
    └───────────────────────────────────────────────────────────┘
    

    La pila de llamadas — “lo que está sucediendo en este momento”

    La pila de llamadas es una estructura ordinaria LIFO (Último en entrar, primero en salir) que gestiona el propio motor de JavaScript; se trata del mismo mecanismo utilizado dentro de V8 en Chrome y Node, o JavaScriptCore en Safari.

    Cada vez que se llama a una función, esta se agrega a la parte superior de dicha pila. Una vez que la función devuelve o termina de ejecutarse, se elimina de la parte superior.

    function stepThree() {
      console.log("Done!");
    }
    
    function stepTwo() {
      stepThree();
    }
    
    function stepOne() {
      stepTwo();
    }
    
    stepOne();
    

    Analizar una pequeña cadena de llamadas como esta muestra claramente su funcionamiento:

    • stepOne se coloca en la pila.
    • stepOne llama a stepTwo, por lo que stepTwo se coloca también en la pila.
    • stepTwo llama a stepThree, de modo que stepThree se coloca encima de ese elemento.
    • stepThree ejecuta console.log, lo que imprime “Done!” y luego se elimina de la pila.
  • stepThree finaliza, por lo que se elimina.
  • stepTwo finaliza, por lo que se elimina.
  • stepOne finaliza, por lo que se elimina.
  • La pila vuelve a quedar vacía.
  • Si alguna función de esta pila necesita, digamos, diez segundos seguidos de cálculos intensivos, todo el tiempo de ejecución se congela durante esos diez segundos. Nada más puede ejecutarse hasta que la pila se vacíe; esta es la consecuencia directa de que el “cocinero” solo tenga dos manos.

    Web APIs: donde realmente ocurre la “espera”

    Esto es algo que sorprende a muchas personas al principio de sus carreras: setTimeout, fetch y los listeners de eventos del DOM no son características del lenguaje JavaScript en sí.

    No los encontrarás en ninguna parte de la especificación ECMAScript. Son proporcionados por el entorno que aloja tu código: el navegador, o en el caso de Node.js, una combinación de enlaces en C++ y la biblioteca libuv.

    Considera esta llamada:

    setTimeout(callback, 2000);
    

    Cuando la pila de llamadas ejecuta esa invocación a setTimeout, lo único que hace en realidad es indicar al mecanismo interno de temporizador del navegador que comience a contar hacia atrás 2000 milisegundos. Tan pronto como se transfiere esa instrucción, setTimeout es eliminado inmediatamente de la pila de llamadas, y la ejecución continúa con lo que sigue en tu código. El conteo hacia atrás se lleva a cabo completamente fuera del hilo único de JavaScript, en el entorno anfitrión.

    La cola de macrotareas: la fila regular

    Esta cola también se conoce como cola de callbacks o cola de tareas, dependiendo de a quién le preguntes.

    Cuando una Web API finaliza su tarea en segundo plano —un temporizador llega a cero, termina de llegar una respuesta de red, un usuario hace clic en algo— el entorno host coloca la función de callback asociada en esta cola.

    Todo lo que se encuentra en esta cola se comporta de manera ordenada: es una fila estándar FIFO (Primero en entrar, primero en salir), donde cada elemento espera su turno para ejecutarse en la pila de llamadas.

    Ejemplos típicos de macrotareas incluyen:

    • Funciones de callback programadas con setTimeout() y setInterval()
    • Escuchadores de eventos DOM como click, scroll o keydown
    • setImmediate(), que es específico de Node.js

    La cola de microtareas: la vía rápida

    En paralelo a esa cola regular existe una cola más pequeña con mucha mayor prioridad: la cola de microtareas.

    Cualquier elemento colocado aquí pasa efectivamente al principio de la cola. Tan pronto como la pila de llamadas se vacía, el bucle de eventos examina primero la cola de microtareas, antes incluso de considerar lo que está esperando en la cola de macrotareas.

    Los elementos que terminan en la cola de microtareas incluyen:

    • Manejadores asociados mediante .then(), .catch() o .finally() en Promises
    • El código que se ejecuta después de un await dentro de una función async
    • Cualquier elemento programado con queueMicrotask()
    • Callback activados por un MutationObserver

    La regla que rige el bucle

    Si eliminamos las metáforas, el bucle de eventos es en realidad un bucle while infinito que se ejecuta dentro del entorno anfitrión, siguiendo repetidamente cinco pasos estrictos:

    1. Drenar la pila de llamadas. Continuar ejecutando código síncrono hasta que no quede absolutamente nada en la pila.
    2. Vaciar por completo la cola de microtareas. Mientras haya una micro tarea esperando, ejecutarla. Si al ejecutarla se genera otra micro tarea, ejecutar también esa. El bucle no pasará a la siguiente etapa hasta que la cola de microtareas esté completamente vacía.
    3. Gestionar el renderizado, si se está en un navegador. El motor verifica si ha llegado el momento de volver a pintar la pantalla, normalmente unas 60 veces por segundo, aproximadamente cada 16.6 milisegundos; si es así, recalcula el diseño y vuelve a dibujar.
    4. Ejecutar exactamente una macrotarea. Revisar la cola de macrotareas, seleccionar la tarea que haya estado esperando más tiempo, colocarla en la pila de llamadas y permitir que se ejecute hasta su finalización.
    5. Reiniciar el ciclo desde el primer paso.

    El detalle que confunde a la gente es esta asimetría: las microtareas se agotan por completo, una tras otra, antes de que el bucle avance; en cambio, las macrotareas se procesan estrictamente una a la vez, con una verificación completa de la pila y de la cola de microtareas entre cada una de ellas.

    Análisis de un acertijo de entrevista muy conocido

    Una vez establecidas las mecánicas, vale la pena examinar un fragmento que confunde a una gran cantidad de desarrolladores:

    console.log("Start");
    
    setTimeout(() => {
      console.log("Timeout Callback");
    }, 0);
    
    Promise.resolve().then(() => {
      console.log("Promise Callback");
    });
    
    console.log("End");
    

    Al seguir esta secuencia línea por línea a través de los cuatro componentes, el resultado sorprendente cobra sentido.

    Paso síncrono primero

    console.log("Start") se coloca en la pila, imprime "Start", y luego se retira de ella.

    A continuación, setTimeout(...) se coloca en la pila. Indica a la capa de Web API del navegador que inicie un temporizador de 0 milisegundos con la función de callback proporcionada. Dado que el retraso es cero, ese temporizador vence prácticamente de inmediato en segundo plano, por lo que el navegador coloca de forma inmediata () => console.log("Timeout Callback") en la cola de Macrotareas. La llamada a setTimeout misma luego se retira de la pila.

    Después de eso, Promise.resolve().then(...) se coloca en la pila. La promesa se resuelve al instante, y su manejador .then() — () => console.log("Promise Callback") — se coloca directamente en la cola de Microtareas. Esta llamada también se retira de la pila.

    Finalmente, se coloca console.log("End"), este imprime “End” y luego se retira.

    Una vez que la pila está vacía

    En este punto ya no queda nada en la pila de llamadas. El bucle de eventos se activa y examina sus dos colas: la cola de microtareas contiene el callback de Promise, mientras que la cola de macrotareas contiene el callback de tiempo de espera.

    Las microtareas se procesan primero

    El bucle verifica la cola de microtareas antes que nada else. Encuentra el callback de Promise, lo coloca en la pila y se imprime “Promise Callback”. La pila vuelve a vaciarse, y ahora también está vacía la cola de microtareas.

    Luego, una macrotarea

    Al no quedar microtareas, el bucle finalmente se dirige a la cola de macrotareas. Toma la entrada más antigua —el callback de tiempo de espera—, lo coloca en la pila, imprime “Timeout Callback” y lo elimina de ella.

    Si se combina todo, la secuencia final impresa es la siguiente:

    Start
    End
    Promise Callback
    Timeout Callback
    

    Aunque el tiempo de espera estaba programado para 0 milisegundos y se escribió antes que la promesa en el código, la función de callback de la promesa se ejecutó primero. Esto confirma la regla de que las microtareas siempre tienen prioridad sobre las macrotareas, independientemente del pequeño retraso que se le dé al temporizador.

    Esto genera un malentendido común sobre lo que realmente promete un valor de retraso. Muchos principiantes asumen que setTimeout(fn, 1000) significa “ejecutar esta función exactamente 1,000 milisegundos a partir de ahora”. Eso no es lo que ocurre. Lo que realmente garantiza es que la función de callback no se añadirá a la cola de tareas hasta que hayan pasado al menos 1,000 milisegundos. Una vez que llega a la cola, aún debe esperar su turno hasta que la pila de llamadas esté vacía y todas las microtareas pendientes se hayan ejecutado.

    Así que si su pila de llamadas está ocupada durante cinco segundos al ejecutar un ordenamiento intensivo o procesar una imagen, una función de callback setTimeout(fn, 100) permanecerá inactiva en la cola durante esos cinco segundos completos antes de que el bucle de eventos pueda siquiera prestarle atención. En resumen, un retraso por tiempo de espera es un tiempo mínimo de espera, nunca un momento garantizado para que se ejecute.

    Este comportamiento de agotamiento de la cola tiene un efecto secundario aún más peligroso que merece ser comprendido por separado. Dado que el bucle de eventos se niega a continuar hasta que la cola de microtareas esté completamente vacía, es posible bloquear su aplicación sin necesariamente bloquear técnicamente la pila de llamadas síncronas. Este modo de fallo se conoce como inanición de microtareas, a veces denominado inanición de cola.

    Imagínese una función como esta:

    function infiniteMicrotasks() {
      Promise.resolve().then(() => {
        infiniteMicrotasks();
      });
    }
    
    infiniteMicrotasks();
    

    Analicemos qué ocurre: la llamada inicial finaliza y la pila se vacía. Luego, el bucle de eventos verifica la cola de microtareas y encuentra un elemento. Ejecuta ese elemento, pero al hacerlo se programa inmediatamente otra microtarea. El bucle de eventos vuelve a verificar la cola, descubre que sigue ocupada y ejecuta el siguiente elemento, lo que a su vez programa otra más. La pila sigue vaciándose después de cada microtarea, pero el bucle de eventos nunca sale de este paso de verificación. Nunca avanza lo suficiente como para permitir que el navegador vuelva a dibujar la interfaz de usuario ni llegar a los elementos que esperan en la cola de macrotareas.

    El resultado práctico es que la página parece completamente congelada. Los clics no se registran, las animaciones dejan de avanzar y, con el tiempo, el navegador podría marcar la pestaña con una advertencia de script no respondiente.

    Para resumir cómo se programa y prioriza cada tipo de tarea a lo largo del tiempo de ejecución, resulta útil analizar una comparación lado a lado que muestre dónde encajan el código síncrono, las microtareas, las macrotareas y el proceso de renderizado dentro del ciclo, tal como se muestra en el diagrama adjunto.

    Comprender cómo funciona el bucle de eventos no es solo información útil para las entrevistas: influye directamente en la forma en que se deben diseñar las aplicaciones reales, y de ello surgen tres hábitos prácticos.

    Primero, descomponga los bucles síncronos costosos. Si necesita procesar un array con, digamos, 500,000 elementos en el hilo principal, evite hacerlo en un único bucle for ininterrumpido, ya que eso congelará la interfaz. Divida el trabajo en partes y devuelva el control al bucle de eventos entre cada parte utilizando setTimeout(chunkHandler, 0) o requestIdleCallback(). Esto le da al navegador tiempo para manejar clics y volver a dibujar los frames entre cada parte.

    Segundo, sea preciso respecto a lo que realmente hace async/await en el fondo. Se trata de un “azúcar sintáctico” aplicado sobre las promesas: cualquier código escrito antes del primer await se ejecuta de forma síncrona, mientras que todo lo que sigue a un await se envuelve en una llamada de callback de microtarea que permanece en pausa hasta que la promesa esperada se resuelva.

    Tercero, nunca trate los temporizadores como cronómetros precisos. Si está creando algo como un temporizador dentro de una aplicación o un secuenciador de audio, no dependa de setInterval o setTimeout para alcanzar marcas exactas en milisegundos. La carga del sistema, la limitación de las pestañas en segundo plano por parte de los navegadores para ahorrar batería y la congestión en las colas provocarán desviaciones. En su lugar, mida el tiempo transcurrido en relación con el reloj del sistema utilizando performance.now().

    JavaScript que se ejecuta en un único hilo no es una limitación que haya que evitar; es una de las decisiones de diseño más sólidas del lenguaje. Al combinar un motor síncrono rápido con un conjunto de APIs en segundo plano del navegador y un bucle de eventos gestionado cuidadosamente, JavaScript crea la sensación de poder hacer muchas cosas al mismo tiempo, evitando al mismo tiempo las condiciones de carrera, los atascos y los problemas de seguridad de hilos que surgen con el verdadero multihilo en otros lenguajes.

    Una vez que entienda y respete cómo funcionan las colas, la forma en que se ejecuta su código dejará de ser una sorpresa.

    Lecturas relacionadas

  • El hilo único de JavaScript frente al motor multi-proceso del navegador — Explore por qué JavaScript se ejecuta en un solo hilo mientras que los navegadores manejan simultáneamente tareas de red, renderizado y temporizadores a través del mecanismo del bucle de eventos.
  • JavaScript Currying explicado: cierres, aplicación parcial y reutilización — Aprenda cómo el currying convierte funciones de JavaScript con múltiples argumentos en cadenas reutilizables con un solo argumento, cómo difiere de la aplicación parcial y cuándo omitirlo.