La pila asíncrona: cómo se reconstruye lo que el motor ya olvidó
El mecanismo por el que el depurador enseña quién programó una tarea asíncrona, sus límites de profundidad, y cómo hacer que tu propio planificador participe.
Cuando un callback se ejecuta, la pila de llamadas real es corta y no dice nada: el bucle de eventos llamó a tu función y punto. Toda la historia de cómo se llegó ahí —quién programó el temporizador, quién hizo la petición, quién creó la promesa— ocurrió en otro momento y ya no está en la pila. Que el depurador te la enseñe de todas formas no es magia: es un mecanismo concreto de captura y enlace que conviene entender, porque tiene límites y porque se puede extender al código propio.
- Explicar cómo el motor captura y enlaza las pilas a través de fronteras asíncronas.
- Leer una pila asíncrona distinguiendo los tramos síncronos de los saltos.
- Reconocer los tres casos en que la pila asíncrona se corta y por qué.
- Hacer que un planificador propio aparezca en las pilas con
console.createTask.
Qué hace el motor
El mecanismo es de dos partes.
En el momento de programar. Cuando el código llama a algo que difiere trabajo —setTimeout, fetch, then, queueMicrotask, requestAnimationFrame, un await— el motor, con el depurador conectado, captura la pila de llamadas actual y la asocia a la tarea que acaba de crear.
En el momento de ejecutar. Cuando esa tarea llega a ejecutarse, el motor tiene su pila real —corta— y además la pila capturada al programarla. El depurador las presenta juntas, con un separador que indica el salto.
De ahí sale la lectura característica de una pila asíncrona: bloques de marcos separados por líneas que dicen de qué tipo fue el salto. Cada bloque es un tramo síncrono real; cada separador es un momento en que el control volvió al bucle de eventos.
El resultado es que puedes leer, de arriba abajo: dónde estás ahora, quién programó esto, dónde estaba ese código cuando lo programó, quién programó aquello, y así hasta el origen, que suele ser un evento de usuario o el arranque de la aplicación.
Este mecanismo tiene coste. Capturar la pila en cada operación asíncrona no es gratis, y por eso solo ocurre con el depurador conectado. Es una de las razones por las que el rendimiento medido con las DevTools abiertas no se parece al real, y otra más para desactivar los breakpoints antes de grabar un perfil.
Leer una pila asíncrona
Un caso típico. Un usuario pulsa un botón, se hace una petición, la respuesta se procesa, y dentro de ese procesamiento hay un error. La pila que verás tiene esta forma.
El bloque superior es el procesamiento de la respuesta: tus funciones, de la más interna a la que recibió los datos.
Un separador indica que el tramo anterior se ejecutó como continuación de una promesa.
El siguiente bloque es el código que hizo la petición y encadenó el manejador.
Otro separador, esta vez indicando un manejador de eventos.
Y el bloque final es el manejador del clic, con el marco de la plataforma que lo invocó.
Esa lectura completa responde a la vez a tres preguntas: qué falló, con qué datos, y qué acción del usuario lo desencadenó. Sin la pila asíncrona, solo tendrías la primera.
Los tres casos donde se corta
El límite de profundidad. El motor no guarda pilas asíncronas indefinidamente: hay un tope de marcos que se conservan, precisamente para que el coste no crezca sin límite. En cadenas muy largas —una cola de reintentos, un flujo con veinte pasos encadenados— los tramos más antiguos se pierden y la pila termina abruptamente. No es un fallo: es el mecanismo funcionando dentro de su presupuesto.
Los planificadores propios. Este es el caso más importante y el que tiene solución. Si tu código —o una librería— implementa su propia cola de tareas, el motor no sabe que hay una relación entre el momento de encolar y el momento de ejecutar. Lo que ve es que alguien encoló algo en una estructura de datos y que más tarde una función lo sacó y lo ejecutó, y esas dos cosas no están enlazadas por ninguna primitiva del lenguaje. La pila se corta en el planificador.
Este caso es especialmente relevante porque todos los frameworks modernos tienen un planificador propio. Cuando un efecto se ejecuta, o cuando un render se aplica, ha pasado por una cola interna, y sin colaboración explícita el rastro hacia el código que lo provocó se pierde.
El código en otro contexto. Un mensaje de un worker, o de un iframe, cruza una frontera de contexto y la pila no la atraviesa. La forma de reconstruirlo es instrumentar los dos lados.
Extender la pila con console.createTask
Existe una API para el segundo caso, y es de las adiciones más útiles de los últimos años. console.createTask permite que un planificador propio participe en el mecanismo de pilas asíncronas.
El funcionamiento tiene dos pasos, simétricos a los del motor.
Al programar, se crea una tarea con un nombre. En ese momento se captura la pila actual.
Al ejecutar, se envuelve la ejecución en task.run(...). El depurador enlaza la pila capturada con la pila de ejecución.
// Un planificador propio que si aparece en las pilas asincronas
const cola = [];
function encolar(nombre, fn) {
const tarea = console.createTask(nombre);
cola.push(() => tarea.run(fn));
}
function procesarCola() {
while (cola.length) cola.shift()();
}
// Uso
encolar('guardar formulario', () => {
throw new Error('fallo al guardar');
});
setTimeout(procesarCola, 100);
Sin createTask, la pila del error apunta a procesarCola y ahí se acaba: no hay forma de saber quién encoló esa función. Con él, la pila incluye el tramo de quien llamó a encolar, etiquetado con el nombre que le diste.
Si console.createTask no está disponible en el entorno donde ejecutas, la degradación es sencilla.
const crearTarea = typeof console.createTask === 'function'
? console.createTask.bind(console)
: (nombre) => ({ run: (fn) => fn() });
Esto merece la pena en cualquier código con cola propia: un gestor de reintentos, un despachador de acciones, un sistema de eventos interno, un pool de tareas. El coste es una línea por cada lado y el beneficio es que todos los errores futuros de ese subsistema traen consigo su origen.
El valor de todo este mecanismo no se agota en la sesión de depuración: se extiende a lo que llega a tu sistema de monitorización de errores, y ahí la diferencia es entre un informe útil y uno inservible. La propiedad stack de un objeto Error incluye los tramos asíncronos cuando el motor los tiene enlazados, así que un error capturado en un manejador global y enviado a un servicio de seguimiento llega con la cadena completa de quién lo provocó, no solo con el marco donde estalló. La diferencia práctica es enorme y cualquiera que haya mirado un panel de errores de producción la reconoce: un informe que dice que hubo un TypeError dentro de una función de utilidad llamada desde el bucle de eventos no permite hacer absolutamente nada, porque esa función se llama desde cuarenta sitios; el mismo informe con la cadena asíncrona dice qué acción del usuario, en qué pantalla, con qué flujo, terminó en ese error. Hay tres consecuencias prácticas que merecen aplicarse. La primera: captura y envía la pila completa, no solo el mensaje, que es un error de instrumentación sorprendentemente común. La segunda: si tu aplicación tiene un planificador propio, instrumentarlo con console.createTask mejora todos los informes futuros de ese subsistema, y es de las inversiones con mejor retorno que existen porque se hace una vez y rinde indefinidamente. Y la tercera, que es un aviso: los source maps de producción son lo que hace legible esa pila, así que subirlos a tu sistema de seguimiento —sin publicarlos en el servidor si no quieres— es lo que convierte una pila de nombres minificados en una de nombres reales. Las tres juntas cambian la naturaleza del trabajo de mantenimiento: se pasa de intentar reproducir bugs a partir de descripciones vagas, a leer directamente el camino que llevó al fallo.