Inicio / Artículos

Explicación de la pila de llamadas de JavaScript: marcos, desbordamientos y rastros de pila de async/await

Cómo funciona la pila de llamadas de JavaScript frame por frame, por qué se desbordan las pilas, dónde residen los cierres y cómo leer las trazas de pila de async/await en DevTools y Node.js.

Editor: REACTAPP.TOP24 min de lectura5598 palabras

Fuentes: Day 6 of Learning JavaScript: JavaScript’s Traffic Cop: How the Call Stack Controls Everything Your Code Does

Una pregunta clásica de entrevista muestra un par de llamadas a funciones anidadas y pregunta qué se imprime y en qué orden. Cada función es sencilla por sí sola; lo que confunde a la gente es la secuencia. Esa secuencia no depende de la intuición. Está determinada por un mecanismo: la pila de llamadas. Una vez que puedes imaginarlo, las preguntas sobre la salida se convierten en un ejercicio mecánico, los desbordamientos de pila dejan de ser misteriosos y los rastros de errores se transforman en mapas precisos. Esta guía explica cómo funciona la pila, sigue el desarrollo de un programa frame por frame, explica por qué se producen los desbordamientos de pila y cómo evitarlos, y luego muestra cómo utilizar DevTools y los rastros de pila para depurar código real.

Por qué un lenguaje de un solo hilo necesita una pila de llamadas

Todo lo demás deriva de una decisión de diseño: JavaScript ejecuta tu código en un único hilo. Esto es intencional, no un descuido. En todo momento el motor ejecuta exactamente una parte de tu código, nunca dos en paralelo.

Eso no significa que no ocurra nada de forma concurrente. Los temporizadores, las solicitudes de red y las operaciones con archivos son gestionados por el entorno anfitrión (las Web APIs del navegador o Node.js), fuera del motor de JavaScript. Los navegadores delegan ese trabajo a grupos de hilos nativos o a mecanismos de E/S del sistema operativo como epoll, a veces comunicándose entre procesos a través de IPC. Cuando el trabajo en segundo plano se completa, su callback se coloca en una cola de tareas. La espera ocurre de forma concurrente, pero los callbacks en sí siguen ejecutándose uno tras otro en el mismo hilo único.

Si eso le parece abstracto, imagine un restaurante con exactamente un cocinero. El cocinero es rápido y bien organizado, pero no puede cortar verduras y revolver una olla al mismo tiempo. Cada tarea se completa y se deja de lado antes de que comience la siguiente. El motor de JavaScript se comporta de la misma manera.

La pregunta interesante es qué sucede cuando una tarea inicia otra. Considere tres funciones pequeñas donde cada una llama a la siguiente:

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

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(5);

Cuando printSquare llama a square, printSquare no puede simplemente continuar; primero necesita el resultado. Por lo tanto, algo debe recordar que printSquare está en pausa, dónde exactamente se detuvo, y ese control debe volver a square, y no al script de nivel superior, una vez que multiply termine.

Esa función de registro es la pila de llamadas. Es el registro del motor de lo que está ejecutándose en ese momento y de lo que espera ser reanudado. Sin ella, una función no tendría forma de volver al lugar correcto y su valor de retorno no tendría adónde ir. No se trata de una optimización en segundo plano; es el mecanismo real mediante el cual JavaScript ejecuta las llamadas a funciones.

Dos tipos de memoria: la pila, el montón y los cierres

Una pregunta común entre los principiantes es la siguiente: si una máquina tiene 16 o 32 GB de RAM, ¿por qué un programa en JavaScript se queda sin espacio en la pila después de solo unos pocos miles de llamadas anidadas? La respuesta es que motores como V8 dividen la memoria en dos regiones muy diferentes: la pila de llamadas y el montón.

TWO SIDES OF JAVASCRIPT MEMORY:

CALL STACK (Execution & Order)   MEMORY HEAP (Storage & Data)
┌─────────────────────────────┐  ┌─────────────────────────────┐
│ [Frame: square(5)]          │  │ Object: { name: "Alice" }   │
│   n: 5                      │  │ Array: [10, 20, 30, 40]     │
│   ref: 0x8F3A ──────────────┼─>│ Function: multiply()        │
├─────────────────────────────┤  │ Object: { role: "admin" }   │
│ [Global Execution Context]  │  │ Large Strings & Closures    │
└─────────────────────────────┘  └─────────────────────────────┘
- Fast, ordered (LIFO)           - Large, unstructured
- Small, fixed memory limit      - Gigabytes dynamic storage
- Auto-cleared on return         - Cleaned by Garbage Collect

La pila de llamadas está optimizada para la velocidad y un orden estricto. Es una región pequeña de tamaño fijo que alberga marcos de ejecución, direcciones de retorno y valores locales primitivos. Dado que sigue estrictamente el principio de último en entrar, primero en salir, descartar un marco ya finalizado es prácticamente gratuito: el motor simplemente mueve un puntero de pila.

El montón es un conjunto mucho más grande y organizado de forma flexible. Allí residen objetos, arrays, funciones y cierres, y la pila solo contiene una referencia ligera a ellos, representada en el diagrama como una dirección como 0x8F3A. La memoria del montón es recuperada por el recolector de basura y no al finalizar la ejecución de una función.

Esta división explica el paradoja. Un desbordamiento de pila no significa que su computadora esté sin memoria, sino que la pequeña región dedicada a rastrear las llamadas activas está llena. Considere el diagrama como un modelo mental: los motores reales tienen más matices; por ejemplo, las variables capturadas por cierres se almacenan en contextos asignados en el heap en lugar de dentro del propio marco.

La forma de la pila: último en entrar, primero en salir

La pila de llamadas es una estructura LIFO: último en entrar, primero en salir. Lo que se agregó más recientemente es lo primero en ser eliminado. Imagine la pila de platos limpios en una cafetería: los platos nuevos se colocan en la parte superior y siempre se toma el de arriba; nunca se toma uno del medio o de abajo.

Las llamadas a funciones siguen exactamente las mismas reglas:

Every time you call a function   → a new "plate" (frame) is placed on TOP
Every time a function finishes   → its "plate" is removed from the TOP
JavaScript always runs the function whose plate is currently on TOP

Qué contiene un marco de pila

Cada marco representa un marco de pila, estrechamente relacionado con lo que la especificación denomina contexto de ejecución. Conceptualmente, registra cinco elementos:

  1. La función y sus argumentos, como a = 5, b = 5.
  2. Variables locales declaradas dentro del cuerpo de la función.
  3. La dirección de retorno, es decir, la instrucción en la función llamante donde debe continuar la ejecución una vez finalizada esta función.
  4. Una referencia a la cadena de ámbitos, el entorno léxico externo que permite a la función leer variables de ámbitos anidados.
  5. El enlace de this que se aplica durante la llamada.

Hay un detalle que es fácil pasar por alto. Antes de que se ejecute cualquiera de sus funciones, el motor crea el contexto de ejecución global para el script y lo coloca primero. Este constituye la parte inferior de la pila mientras se ejecuta su código de nivel superior.

WHEN YOUR SCRIPT STARTS:

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Always here first
  └──────────────────────────────────┘

Cada llamada se apila sobre esa base, y una regla une todo: el motor siempre ejecuta el frame que está en la parte superior. En el momento en que se añade un nuevo frame, el que está debajo se pausa.

Rastreando un programa frame por frame

Seguyamos printSquare(5) a través de la pila. Hacerlo cuidadosamente una vez suele ser suficiente para que el proceso sea automático. Aquí está el programa nuevamente:

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

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(5);

La cadena de llamadas es printSquare, luego square dentro de ella, y después multiply dentro de ese.

Paso 1: el script comienza

Antes de que se ejecute printSquare(5), el motor inicia la ejecución del archivo y establece el contexto de ejecución global.

STEP 1: Script starts running

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Pushed first, always
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

El motor también registra las definiciones de multiply, square y printSquare. Definir una función no hace nada; solo al llamarla se crea un marco.

Paso 2: llamar a printSquare(5)

El motor llega a printSquare(5), crea un marco para él y lo coloca en la parte superior.

STEP 2: printSquare(5) is called

  CALL STACK:
  ┌──────────────────────────────────┐
  │  printSquare(5)                  │  <-- PUSHED on top
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

printSquare está ahora en la parte superior, por lo que se ejecuta. Su primera instrucción, const result = square(n), es otra llamada, así que printSquare debe ceder el control antes de poder hacer algo más.

Paso 3: printSquare llama a square(5)

Se coloca un marco para square(5) por encima de printSquare.

STEP 3: square(5) is called inside printSquare

  CALL STACK:
  ┌──────────────────────────────────┐
  │  square(5)                       │  <-- PUSHED on top
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Waiting, paused
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

printSquare está en pausa, esperando un valor. Toda la atención está en square, cuya única instrucción, return multiply(n, n), provoca otra llamada.

Paso 4: square llama a multiply(5, 5)

Pasa un frame más, y la pila alcanza cuatro frames, su punto más profundo en este programa.

STEP 4: multiply(5, 5) is called inside square -- Stack peak!

  CALL STACK:
  ┌──────────────────────────────────┐
  │  multiply(5, 5)                  │  <-- TOP - runs now
  ├──────────────────────────────────┤
  │  square(5)                       │  <-- Waiting
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Waiting
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

multiply no llama a ninguna otra función. Evalúa 5 * 5, devuelve 25 y termina, por lo que su marco puede ser descartado.

Paso 5: multiply devuelve el resultado y su marco se elimina

Cuando una función finaliza, su marco se elimina del extremo superior. El marco que está directamente debajo, en este caso square, vuelve a quedar en la parte superior y reanuda su ejecución exactamente desde donde se detuvo.

STEP 5: multiply(5, 5) returns 25 -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  square(5)                       │  <-- NOW at top, resumes
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Still waiting
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RETURN VALUES SO FAR:
  1. multiply(5, 5) --> returned 25 (passed back to square)

Para square, la expresión return multiply(n, n) ahora se ha convertido en return 25. Devuelve ese valor y es eliminado a su vez.

Paso 6: square devuelve y printSquare continúa

STEP 6: square(5) returns 25 -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  printSquare(5)                  │  <-- NOW at top, resumes
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RETURN VALUES SO FAR:
  1. multiply(5, 5) --> returned 25 (passed back to square)
  2. square(5)       --> returned 25 (stored as const result inside printSquare)

De vuelta en printSquare, la asignación const result = square(n) se completa con result igual a 25. La siguiente instrucción, console.log(result), imprime 25.

Paso 7: se ejecuta el registro y la pila se despliega

STEP 7: console.log(25) executes, then printSquare finishes -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Program done
  └──────────────────────────────────┘

  FINAL OUTPUT:
  1. multiply(5, 5) --> returned 25
  2. square(5)       --> returned 25
  3. console.log(25) --> PRINTED: 25
  4. printSquare(5)  --> finished (returns undefined implicitly)

printSquare devuelve implícitamente undefined y es eliminado, dejando solo el contexto global. El único valor que llega a la consola es el 25 registrado por printSquare; los valores de retorno de multiply y square se transmitieron en silencio.

Toda la traza de un vistazo

Los mismos siete pasos, comprimidos para una revisión rápida:

STEP 1: Script starts
  Stack: Global
  Output: (none)

STEP 2: printSquare(5) called
  Stack: printSquare -> Global
  Output: (none)

STEP 3: square(5) called
  Stack: square -> printSquare -> Global
  Output: (none)

STEP 4: multiply(5, 5) called
  Stack: multiply -> square -> printSquare -> Global
  Output: (none)

STEP 5: multiply returns 25
  Stack: square -> printSquare -> Global
  Output: (none)

STEP 6: square returns 25
  Stack: printSquare -> Global
  Output: (none)

STEP 7: console.log(25), printSquare done
  Stack: Global
  Output: 25

Four rules summarize everything above:

  1. A call pushes a new frame on top. Every time.
  2. A return pops that frame off. Every time.
  3. The topmost frame is the one executing. No exceptions.
  4. The caller is the frame directly beneath. That is where control goes when the current function returns.

When the stack runs out of room: stack overflow

What the error really means

The stack has a fixed size, reserved by the engine. Each frame consumes part of it for arguments, locals, the return address and bookkeeping. Push enough frames without popping them and the space runs out. The engine then throws a RangeError:

RangeError: Maximum call stack size exceeded

Eso es un stack overflow. El conocido sitio de preguntas y respuestas para programadores lleva, de hecho, su nombre por este error en particular. El desencadenante habitual es una función que sigue llamándose a sí misma sin que nada la detenga, lo cual representa una recursión mal ejecutada.

Recursión infinita

El ejemplo más sencillo posible es una función cuya única función es llamarse a sí misma:

// No base case -- this never stops
function forever() {
  return forever();
}

forever(); // RangeError: Maximum call stack size exceeded

Visualizado, la pila crece hasta que el motor se niega a seguir avanzando:

STACK GROWING OUT OF CONTROL:

  ┌──────────────────────────────────┐
  │  forever()                       │  <-- frame #10,000+ ... crash!
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  ...                             │  (thousands more)
  ├──────────────────────────────────┤
  │  forever()                       │  <-- frame #1
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RESULT: RangeError: Maximum call stack size exceeded

Cada llamada a forever() agrega un frame, y como nada vuelve nunca, nada es eliminado nunca. Con el tiempo se agota el espacio reservado y se lanza un RangeError.

La recursión en sí no es el problema. Es una técnica legítima y a menudo elegante. El problema es la recursión sin salida. Cada función recursiva necesita un caso base, una condición bajo la cual deja de llamarse a sí misma y devuelve.

Un caso base que nunca se alcanza

Una versión más sutil engaña a muchos desarrolladores. La función a continuación tiene un caso base, pero su paso recursivo avanza en la dirección incorrecta:

function countDown(n) {
  if (n === 0) return; // Base case IS here, but never reached
  countDown(n + 1);    // Bug: going UP, not down -- n never reaches 0
}

countDown(1); // RangeError: Maximum call stack size exceeded

Existe la condición de protección if (n === 0) return, pero la llamada recursiva pasa n + 1. Partiendo de 1, los valores aumentan hasta 2, 3, 4 y así sucesivamente, por lo que nunca se alcanza el cero y, de todos modos, la pila se desborda. La versión corregida cuenta hacia abajo y utiliza n <= 0 como medida de protección, de modo que incluso un valor de inicio negativo o fraccional inesperado hace que la función termine:

function countDown(n) {
  if (n <= 0) return; // Base case: stop when we reach or pass zero
  console.log(n);
  countDown(n - 1);   // Subtracting: we're actually getting closer to 0
}

countDown(5); // Prints: 5, 4, 3, 2, 1

Recursión indirecta

El ciclo también puede abarcar varias funciones. Aquí ping llama a pong, y pong llama de vuelta a ping:

function ping() {
  pong(); // ping calls pong
}

function pong() {
  ping(); // pong calls ping back: circular loop
}

ping(); // RangeError: Maximum call stack size exceeded

Ninguna de las funciones se llama a sí misma, pero la combinación de ambas forma un bucle infinito de llamadas, y el resultado es el mismo desbordamiento. Los ciclos indirectos son más difíciles de detectar en el código real porque las dos partes suelen encontrarse en módulos diferentes, como un manejador de eventos que desencadena una actualización que a su vez dispara nuevamente el mismo evento.

¿Hasta qué profundidad puede llegar la pila?

La profundidad máxima depende del motor y de cuanta memoria necesita cada frame. Las cifras que se presentan a continuación son valores aproximados y no garantías; varían según las versiones, plataformas y flags de configuración, por lo que debe medirlo en su propio entorno de destino si ese límite es importante:

CALL STACK LIMITS BY RUNTIME (APPROXIMATE):

  - Chrome & Node.js (V8 Engine):        ~10,000 to 12,000 frames
  - Safari (JavaScriptCore Engine):       ~40,000 to 45,000 frames
  - Firefox (SpiderMonkey Engine):        ~50,000+ frames

La razón de esta variación es que el límite es en realidad un presupuesto de memoria, y no un recuento de llamadas. Una función con muchos parámetros y variables locales genera marcos más grandes, por lo que caben menos de ellas antes de alcanzar ese límite.

Corrigiendo los desbordamientos de pila en la práctica

Cuando la recursión profunda provoca un fallo en código real, existen algunos patrones fiables para solucionarlo.

Sustituir la recursión por un bucle

Cuando la entrada puede ser grande, reescriba la recursión como un bucle for o while. Un bucle se ejecuta dentro de un único marco, por lo que millones de iteraciones no aumentan en absoluto la profundidad de la pila. En el ejemplo a continuación, la suma recursiva probablemente se desbordará a una profundidad de unos 20,000 en V8, mientras que la versión iterativa puede manejar un millón sin problemas:

// Recursive version: crashes if n is 20,000
function sumRecursive(n) {
  if (n === 0) return 0;
  return n + sumRecursive(n - 1);
}

// Iterative version: uses 1 stack frame, handles any n safely
function sumIterative(n) {
  let total = 0;
  for (let i = 1; i <= n; i++) {
    total += i;
  }
  return total;
}

console.log(sumIterative(1000000)); // 500000500000 (instant, zero stack growth)

Trampolín

Si prefiere un estilo recursivo, un trampolín permite mantenerlo sin que aumente la pila. En lugar de llamarse a sí mismo, la función recursiva devuelve un thunk, una función sin argumentos que describe el siguiente paso. El trampolín ejecuta estos thunks uno por uno en un bucle while simple; cada thunk se devuelve antes de que comience el siguiente, por lo que la profundidad de la pila permanece constante.

function trampoline(fn) {
  return function (...args) {
    let result = fn(...args);
    while (typeof result === "function") {
      result = result();
    }
    return result;
  };
}

// Return a function thunk instead of a direct recursive call
function safeSum(n, acc = 0) {
  if (n === 0) return acc;
  return () => safeSum(n - 1, acc + n);
}

const runSum = trampoline(safeSum);
console.log(runSum(100000)); // 5000050000 (no stack overflow!)

Observe el parámetro acumulador acc en safeSum: la suma acumulada se transfiere en los argumentos en lugar de permanecer en la pila. La contrapartida es una asignación adicional de función por paso y una convención que resulta menos evidente para los lectores, por lo que los trampolines son más adecuados para algoritmos verdaderamente recursivos.

Evite distribuir arrays enormes en llamadas

Una sorpresa frecuente: al distribuir un array muy grande en una llamada a función, cada elemento se pasa como un argumento separado, y esos argumentos también necesitan espacio en la pila.

const hugeArray = new Array(150000).fill(1);

// CRASH: RangeError: Maximum call stack size exceeded
// Math.max(...hugeArray);

// SAFE: Process with a loop or reduce
const maxVal = hugeArray.reduce((max, val) => (val > max ? val : max), -Infinity);

Dependiendo del motor, una llamada como Math.max(...hugeArray) puede generar el mismo RangeError una vez que el array contiene alrededor de cien mil elementos o más. Un reduce o un bucle simple procesan los datos sin ese límite.

No confíe en la optimización de llamadas de cola

Los tutoriales antiguos y la especificación ECMAScript 2015 describen las llamadas de cola adecuadas (PTC), a menudo denominadas optimización de llamadas de cola. La idea es que cuando la acción final de una función en modo estricto es devolver el resultado de otra llamada, el motor puede reutilizar el marco actual en lugar de crear uno nuevo, lo que permitiría que la recursión ilimitada funcione con un espacio de pila constante.

En la práctica, entre los principales motores, solo JavaScriptCore de Safari lo implementa. V8, utilizado por Chrome y Node.js, y SpiderMonkey de Firefox decidieron no incluirlo en el JavaScript estándar, principalmente porque eliminar los frames borra el historial del que dependen las trazas de pila y los depuradores. Considere las llamadas de cola como no disponibles en código de producción y utilice bucles o mecanismos de salto para la recursión profunda.

Predecir la salida en preguntas de entrevista

Las preguntas sobre salida son en realidad preguntas sobre la pila

Las preguntas como “¿Qué imprime este código?” aparecen en casi todas las entrevistas de JavaScript, y la mayoría son en realidad ejercicios sobre la pila de llamadas disfrazados. El entrevistador no está probando la memorización; está verificando si comprende el orden de ejecución, y ese orden está completamente determinado por la pila. Intente esta antes de continuar leyendo:

function a() {
  console.log("a start");
  b();
  console.log("a end");
}

function b() {
  console.log("b start");
  c();
  console.log("b end");
}

function c() {
  console.log("c");
}

a();

Rastreándolo frame por frame

El programa imprime:

a start
b start
c
b end
a end

Muchas personas esperan que aparezca a end antes. He aquí por qué no ocurre. Al llamar a a() se activa su marco, y a comienza a ejecutarse.

CALL STACK after a() is called:
  ┌──────────────────┐
  │  a()             │  <-- running
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

La primera declaración en a registra un inicio. La siguiente es la llamada b(), por lo que se coloca un marco para b en la parte superior.

CALL STACK after b() is called inside a:
  ┌──────────────────┐
  │  b()             │  <-- running now
  ├──────────────────┤
  │  a()             │  <-- paused, waiting for b to finish
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

a está en pausa ahora, y su última llamada a console.log("a end") aún no se ha ejecutado; no podrá hacerlo hasta que b haya finalizado por completo. Dentro de b, se registra b start, y luego se llama a c(), lo que agrega un tercer frame.

CALL STACK after c() is called inside b:
  ┌──────────────────┐
  │  c()             │  <-- running now
  ├──────────────────┤
  │  b()             │  <-- paused
  ├──────────────────┤
  │  a()             │  <-- paused
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

c registra c, ya no tiene nada más que hacer y regresa. Su marco se elimina y b vuelve a quedar en la parte superior.

CALL STACK after c() returns:
  ┌──────────────────┐
  │  b()             │  <-- resumes now
  ├──────────────────┤
  │  a()             │  <-- still waiting
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

b continúa inmediatamente después de su llamada a c(), registra b end y devuelve. Su marco se elimina, devolviendo a a a la parte superior.

CALL STACK after b() returns:
  ┌──────────────────┐
  │  a()             │  <-- resumes now
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

a continúa después de su llamada a b(), registra a end, devuelve el valor y es eliminado del stack. Solo queda el contexto global. La salida completa, en orden:

a start
b start
c
b end
a end

La regla a internalizar es: un llamante no continúa después de una llamada hasta que la función llamada haya finalizado por completo. a end tuvo que esperar a que terminara todo b, el cual a su vez esperó a que terminara todo c. a fue la primera función en hacer una llamada y, por lo tanto, la última en finalizar.

Un método de tres pasos para cualquier pregunta relacionada con la salida

  1. Enumere las llamadas en orden, comenzando por la más externa y avanzando hacia adentro. Cada llamada hace que su llamante se detenga.
  2. Supervise la parte superior de la pila. Lo que está en la parte superior se ejecuta; todo lo que está debajo espera.
  3. Coloque cada registro. Los registros dentro de una función llamada aparecen antes que cualquier registro que siga a la llamada en el llamante.

El patrón es siempre el mismo: bajar hasta la llamada más profunda y luego subir en orden inverso. Esto se aplica al código síncrono. Las devoluciones de llamada de temporizadores, promesas y eventos añaden una cola encima de este modelo; nuestro artículo sobre closures y el bucle de eventos aborda esa capa adicional.

Inspeccionar la pila en tiempo real en Chrome DevTools

El rastreo a mano es ideal para entrevistas, pero en el día a día normalmente se observará la pila directamente en el depurador del navegador.

Pausar con la instrucción debugger

Al agregar debugger; en una línea, se pausa la ejecución allí cada vez que se abren DevTools. Ábralos con F12, o haga clic con el botón derecho en la página, elija Inspeccionar y cambie a la pestaña Fuentes.

function calculateTax(subtotal) {
  const rate = 0.08;
  debugger; // Execution pauses right here
  return subtotal * rate;
}

function checkoutCart() {
  const total = 100;
  const tax = calculateTax(total);
  console.log(total + tax);
}

checkoutCart();

Cuando la ejecución se detiene en la línea debugger, la barra lateral muestra la pila actual junto con las variables en el ámbito de validez:

DEVTOOLS SOURCES PANEL BREAKDOWN:

  ┌───────────────────────┐  ┌──────────────────────────────────────────────┐
  │ CALL STACK PANE       │  │ SCOPE PANE (Updates based on clicked frame)  │
  ├───────────────────────┤  ├──────────────────────────────────────────────┤
  │ calculateTax  (line 3)│  │ Local Scope:                                 │
  │ checkoutCart  (line 9)│  │   subtotal: 100                              │
  │ (anonymous)   (line 13│  │   rate: 0.08                                 │
  └───────────────────────┘  └──────────────────────────────────────────────┘

El panel Call Stack muestra calculateTax en la parte superior, checkoutCart debajo de él y el script anónimo de nivel superior en la parte inferior, que es la misma estructura que se analizó anteriormente. Al hacer clic en checkoutCart, el editor se desplaza a la línea 9, y el panel Scope cambia a las variables de ese marco, mostrando total: 100 exactamente como estaba cuando se llamó a calculateTax. Poder inspeccionar el estado de un llamante en pausa es una de las técnicas de depuración más útiles disponibles.

Avanzar por las llamadas

La barra de herramientas del depurador ofrece tres controles de avance que se corresponden directamente con las operaciones de la pila:

  1. Step Over (F10) ejecuta la línea actual y se detiene en la siguiente línea de la misma función. Si esa línea llama a otra función, dicha llamada se ejecuta por completo sin que el depurador se detenga dentro de ella.
  2. Step Into (F11) sigue la llamada en la línea actual, inserta el nuevo marco y se detiene en su primera instrucción.
  3. Step Out (Shift+F11) ejecuta el resto de la función actual, elimina su marco y se detiene en la función que lo llamó justo después de que la llamada haya finalizado.

Con estos tres controles, la depuración se convierte en un recorrido deliberado por la pila en lugar de intentos aleatorios.

Rastros de pila: la pila de llamadas en el momento del fallo

Qué te indica un rastro de pila

Cuando se genera un error que no se captura, el motor informa un rastro de pila: una instantánea de cada marco que se encontraba en la pila en el instante del error. Te indica qué se estaba ejecutando, quién lo llamó y la línea exacta donde ocurrió el problema. Un rastro es simplemente la pila de llamadas escrita en formato de texto, por lo que todo lo explicado hasta ahora es aplicable al leerlo.

Las trazas a menudo parecen intimidantes, un bloque de rutas y números, pero una vez que se conoce su estructura, representan la vía más rápida para encontrar un error.

Anatomía de una traza

Tomemos una cadena pequeña en la que la función más interna lanza un error:

function c() {
  throw new Error("something went wrong");
}

function b() {
  c();
}

function a() {
  b();
}

a();

Al ejecutarse en Node.js, genera una salida como esta:

Error: something went wrong
    at c (/box/index.js:2:9)
    at b (/box/index.js:6:3)
    at a (/box/index.js:10:3)
    at Object.<anonymous> (/box/index.js:13:1)
    at Module._compile (node:internal/modules/cjs/loader:1705:14)
    at Object..js (node:internal/modules/cjs/loader:1838:10)
    at Module.load (node:internal/modules/cjs/loader:1441:32)
    at Function._load (node:internal/modules/cjs/loader:1263:12)
    at TracingChannel.traceSync (node:diagnostics_channel:328:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)

La primera línea indica el tipo de error y el mensaje: Error y algo salió mal. Esto es lo que sucedió. Otros tipos que se encuentran con frecuencia incluyen TypeError, ReferenceError, SyntaxError y RangeError.

Cada línea que comienza con at representa un nivel de la pila en el momento en que ocurrió el error, en este formato:

at functionName (filename:lineNumber:columnNumber)

Así que at c (/box/index.js:2:9) significa que la función c se estaba ejecutando en /box/index.js, en la línea 2, columna 9, que es precisamente la instrucción throw.

at Object.<anonymous> indica código que se ejecuta en el nivel superior de un módulo, fuera de cualquier función con nombre. Aquí a() se llama desde el nivel superior del módulo. En otras partes del rastro, <anonymous> suele señalar una función de callback integrada que no tiene nombre.

Las líneas inferiores, como Module._compile, Module.load y wrapModuleLoad, son componentes internos de Node.js que cargan y ejecutan tu archivo. Tú no los escribiste y generalmente puedes omitirlos.

¿Cómo leerlo?

El orden es lo que confunde a la gente. El marco más reciente se imprime primero, por lo que la lista sigue en sentido contrario al orden en que se realizaron las llamadas:

Read this direction (for debugging):
  ↑ TOP = where the error actually happened
  |
  | at c (/box/index.js:2:9)       <-- Error thrown HERE
  | at b (/box/index.js:6:3)       <-- b called c
  | at a (/box/index.js:10:3)      <-- a called b
  | at Object.<anonymous> (...)    <-- global code called a
  ↓
BOTTOM = where execution started

La parte inferior del rastro corresponde al punto de entrada, donde comenzó la ejecución. La parte superior es donde se produjo el error. La ejecución avanzó hacia arriba a través de los frames hasta que el error la detuvo.

Para depurar, comience en la parte superior y abra el archivo y la línea de la primera aparición de at: allí es donde ocurrió la caída. Luego lea hacia abajo, ya que la causa real suele encontrarse uno o dos frames más abajo. El error surgió en c, pero, ¿qué llamada proporcionó datos incorrectos a c? Revise b. ¿Y quién llamó a b de esa manera? Revise a. El frame superior muestra el síntoma; los frames debajo suelen contener la causa.

Un procedimiento fiable para cualquier rastro:

  1. Lea el tipo y el mensaje del error para comprender qué tipo de fallo ocurrió.
  2. Encuentre la primera línea at que haga referencia a su propio código, omitiendo los marcos de ejecución y de bibliotecas.
  3. Abra ese archivo y esa línea y examine qué está sucediendo allí.
  4. Inspeccione el marco inmediatamente inferior, ya que el valor defectuoso o la llamada errónea con frecuencia proviene del llamante.

Imprimir la pila bajo demanda con console.trace()

Puede imprimir la pila actual en cualquier momento, sin generar error, llamando a console.trace():

function deepFunction() {
  console.trace("Who called me?");
  // prints current call stack right here
}

function middle() {
  deepFunction();
}

function outer() {
  middle();
}

outer();

El resultado se ve así:

Trace: Who called me?
    at deepFunction (/box/index.js:2:11)
    at middle (/box/index.js:7:3)
    at outer (/box/index.js:11:3)
    at Object.<anonymous> (/box/index.js:15:1)

Esto es especialmente útil con código desconocido cuando no se puede determinar desde dónde se está llamando a una función: añada un console.trace(), ejecute el escenario y aparecerá la ruta completa. Elimine estas llamadas antes de publicar el código.

Asigne nombres a sus funciones

Las funciones verdaderamente anónimas aparecen como <anonymous> en los registros, lo que prácticamente no le ofrece nada con qué buscar. Los fragmentos a continuación ilustran la diferencia, aunque el primero necesita una corrección.

const doSomething = () => {
  throw new Error("broken");
};

doSomething();
Error: broken
    at <anonymous> (/box/index.js:2:9)  <-- useless for debugging

En los motores actuales, una función flecha asignada directamente a una const obtiene su nombre a partir de la variable, por lo que este ejemplo en particular muestra at doSomething en lugar de <anonymous>. Los poco útiles valores <anonymous> provienen de callbacks incrustados pasados directamente a funciones como map, then o a los listeners de eventos. Una declaración con nombre es inequívoca:

function doSomething() {
  throw new Error("broken");
}

doSomething();
Error: broken
    at doSomething (/box/index.js:2:9)  <-- instantly tells you what crashed

En un código repleto de callbacks inline, docenas de marcos <anonymous> idénticos hacen que los rastros sean difíciles de seguir. Nombrar los callbacks o extraerlos en funciones con nombre mantiene los rastros legibles.

Rastros de pila Async/await y herramientas de producción en V8 y Node.js

Para servicios backend y aplicaciones front-end grandes, V8 proporciona APIs para controlar cómo se capturan los rastros.

Capturar más marcos con Error.stackTraceLimit

Por defecto, V8 registra como máximo 10 marcos en error.stack. En código con muchas capas, como los pipelines de solicitudes de Express o NestJS o las cadenas de middleware de Redux, esos diez marcos pueden ser consumidos por el código del framework antes de que aparezcan tus propias funciones. Puedes aumentar el límite de forma global:

// Capture up to 50 frames for deep diagnostics
Error.stackTraceLimit = 50;

// Or capture everything without any limit:
// Error.stackTraceLimit = Infinity;

Esta configuración solo controla cuántos frames se registran en el rastro. No tiene efecto en la profundidad máxima real del stack, y valores muy altos, especialmente Infinity, hacen que la creación de cada error sea más costosa; por lo tanto, en entornos de producción es preferible utilizar un número moderado.

Errores personalizados más limpios con Error.captureStackTrace

Cuando defines tus propias clases de error, el constructor en sí normalmente aparecería en el rastro. Error.captureStackTrace te permite excluirlo, de modo que el rastro comience donde realmente se produjo el error:

class DatabaseError extends Error {
  constructor(message) {
    super(message);
    this.name = "DatabaseError";
    // Omit the constructor call itself from the stack trace
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DatabaseError);
    }
  }
}

El segundo argumento indica a V8 que omita esa función y todos los frames superiores. La condición if permite que la clase siga funcionando en motores que carecen de esta API específica de V8.

Trazas de pila asíncronas con async/await

Historialmente, el código asíncrono generaba trazas truncadas. Una función de callback de setTimeout o una promesa se ejecuta en una pila nueva mucho después de que el llamante original haya terminado, por lo que la cadena de llamantes desaparece.

El V8 moderno, tanto en Chrome como en Node.js, ofrece trazas de pila asíncronas sin costo adicional para async/await. Si algo lanza un error después de que un await haya reanudado su ejecución, V8 reconstruye la cadena de funciones en espera solo cuando realmente se necesita la traza, marcando esos frames con async:

Error: Query failed
    at executeQuery (db.js:14:11)
    at async getUserData (user.js:22:9)
    at async handleRequest (server.js:45:5)

La ventaja se aplica a async/await nativos. Las largas cadenas de .then() escritas a mano y el código basado en callbacks no reciben la misma reconstrucción, lo cual es otra razón más para preferir await en el código que se pretenda depurar.

La pila de llamadas en pocas reglas

  • JavaScript ejecuta tu código en un único hilo. El entorno anfitrión maneja el trabajo en segundo plano de forma concurrente, pero cada callback resultante espera su turno en ese hilo.
  • La pila de llamadas es una estructura pequeña, rápida y de tamaño fijo de tipo LIFO que registra qué función se está ejecutando y dónde continuará cada llamante; los objetos y cierres viven en el heap y son referenciados desde la pila.
  • Una llamada inserta un marco y un retorno lo elimina. El marco superior siempre se ejecuta, mientras que todos los marcos debajo de él quedan en pausa.
  • Un desbordamiento de pila, reportado como RangeError: Maximum call stack size exceeded, significa que se ha agotado el presupuesto de memoria de la pila, generalmente debido a una recursión sin un caso base alcanzable, ya sea directo o indirecto.
  • Para recursiones profundas, prefiera bucles o mecanismos de salto, y evite distribuir arrays muy grandes en llamadas a funciones. Las llamadas de cola adecuadas solo están disponibles en Safari, por lo que no dependa de ellas.
  • En DevTools, Step Over, Step Into y Step Out corresponden a ejecutar una llamada, entrar en un marco y salir de él.
  • Una traza de pila es la representación de la pila en el momento del fallo: la parte superior indica donde se lanzó el error, la inferior es el punto de entrada, y la causa suele encontrarse uno o dos marcos por debajo de la parte superior.
  • console.trace(), las funciones nombradas, Error.stackTraceLimit, Error.captureStackTrace y los constructores nativos async/await hacen que las trazas sean más informativas.
  • Los callbacks en cola, como los manejadores de setTimeout, solo se ejecutan una vez que la pila se ha vaciado por completo, y por eso la pila de llamadas es la base para comprender el JavaScript asíncrono.

Por qué esto vale la pena

Muchos de los errores que consumen horas de trabajo se deben en realidad a problemas con la pila: una función que devuelve antes de lo esperado, una función de callback que se ejecuta en un orden inesperado, un valor que es undefined cuando no debería serlo. Los desarrolladores que resuelven estos problemas rápidamente no son más inteligentes; simplemente aprendieron la estructura de la pila y sus trazas una vez y ahora van directamente al marco relevante. Un rastro de pila es un registro mecánico de lo que realmente se ejecutó, y una vez que uno confía en él, depurar se vuelve considerablemente menos doloroso.

Leer más