javascript

An Introduction to Async Stack Traces in Node.js

Omonigho Kenneth Jimmy

Omonigho Kenneth Jimmy on

An Introduction to Async Stack Traces in Node.js

Node.js 12.x onwards introduced async stack traces. Async stack traces allow developers to view the call stack of asynchronous code, making it easier to trace and debug code issues.

In this post, we’ll see how async stack traces work, how you can use them to debug code, and how to track them using AppSignal. Ready?

Let's get going!

How Async Stack Traces Work in Node.js

In JavaScript, asynchronous code is executed through callbacks, promises, and async/await. When an error occurs in asynchronous code, it can be difficult to trace its origin because the call stack is not captured at the point of the error. Async stack traces capture the call stack when the asynchronous code is scheduled rather than when it is executed, allowing developers to see an error's full context.

Node.js uses an event-driven, non-blocking I/O model for handling asynchronous code execution. An event loop constantly checks for new tasks, executes them one by one, and registers callback functions for tasks that do not block the execution. This allows Node.js to handle multiple tasks simultaneously, improving performance and scalability.

Async stack traces capture the call stack when an async operation begins using "long stack traces". When an async operation starts, Node.js creates a new execution context that includes information about the current call stack and the location where the async operation was initiated. This execution context is then passed along with the async operation as it is scheduled and executed.

When an error occurs, Node.js can reference the execution context to determine the entire call stack at the point where the async operation began. It enriches the stack property of Error instances with async stack frames, i.e., await locations in the code. These async frames are marked with async in the stack string. You can see the full context of an error, including the function calls and variables that led to the error.

Node.js Async Stack Traces: A Code Example

Consider the following code. An async function init() calls funcOne(), which then calls funcTwo(), which then calls and throws funcThree(). Async stack traces will print a stack trace with init(), funcOne(), funcTwo(), and funcThree().

javascript
const funcThree = async () => { await Promise.resolve(); throw new Error("Oops"); }; const funcTwo = async () => { await Promise.resolve(); await funcThree(); }; const funcOne = async () => { await new Promise((resolve) => setTimeout(resolve, 10)); await funcTwo(); }; const init = async () => { await new Promise((resolve) => setTimeout(resolve, 10)); await funcOne(); }; init().then( () => console.log("success"), (error) => console.error(error.stack) );

This results in an output like so:

shell
node ./asyncStackTraces.js Error: Oops at funcThree (/Users/kennethjimmy/Desktop/asyncStackTraces.js:3:9) at async funcTwo (/Users/kennethjimmy/Desktop/asyncStackTraces.js:8:3) at async funcOne (/Users/kennethjimmy/Desktop/asyncStackTraces.js:13:3) at async init (/Users/kennethjimmy/Desktop/asyncStackTraces.js:18:3)

If we run the same code on a version of Node.js older than 12, the output looks like this:

shell
node ./asyncStackTraces.js Error: Oops at funcThree (/Users/kennethjimmy/Desktop/asyncStackTraces.js:3:9) at <anonymous>

In this case, at <anonymous> isn’t very helpful in revealing the full context of the error.

However, this works differently with async stack traces. When an error occurs in a callback function, the call stack is captured at the point when a callback is scheduled (the init() function in this case), rather than when it is executed.

Set Up AppSignal for Your Express.js App

AppSignal can assist you in identifying and troubleshooting problems with your app, including issues related to slow performance, errors, crashes, and downtime. You can resolve these issues quickly before they negatively impact your users.

In this tutorial, we will walk you through how to integrate AppSignal into your Express.js app.

Note: You can follow along with the source code for the following tutorial here.

Firstly, sign up for an account on appsignal.com and create a new app by following these guides.

Then start a new Node.js project:

shell
npm init -y

Install AppSignal for Node.js:

shell
npm install --save @appsignal/nodejs

In your repo’s root directory, create an appsignal.cjs file and paste the following code to require and configure AppSignal:

javascript
require("dotenv").config(); const { Appsignal } = require("@appsignal/nodejs"); new Appsignal({ active: true, name: process.env.APPSIGNAL_APP_NAME, pushApiKey: process.env.APPSIGNAL_PUSH_API_KEY, // Note: renamed from `apiKey` in version 2.2.5 });

Note: An application's Push API key is given during setup and can be found in the App settings > Push & deploy tab.

In your package.json file, edit the start script to load AppSignal's configuration before any other library using the --require flag, like so:

json
{ "scripts": { "start": "node --require ‘./appsignal.cjs’ app.mjs" } }

Integrating AppSignal into your Express app is very easy. You just need to require the expressErrorHandler module and then use it in your app with app.use, as shown below:

javascript
import { expressErrorHandler } from "@appsignal/nodejs"; const app = express(); // add this after all routes, but before any other error handlers app.use(expressErrorHandler());

The expressErrorHandler module consists of middleware designed to forward any errors in your Express application to AppSignal.

Tracking Async Callback Errors with AppSignal

Let's create an error inside an asynchronous callback to see how AppSignal handles it:

javascript
const funcThree = async () => { await Promise.resolve(); throw new Error("Oops"); }; const funcTwo = async () => { await Promise.resolve(); await funcThree(); }; const funcOne = async () => { await new Promise((resolve) => setTimeout(resolve, 10)); await funcTwo(); }; app.get( "/trigger_error", catchAsync(async (req, res) => { await new Promise((resolve) => setTimeout(resolve, 10)); await funcOne(); res.status(200).json({ statusbar: "success", data: { mentors, }, }); }) );

When accessing GET /trigger_error, an error is triggered. The server will return the error along with an asynchronous stack trace, as shown below:

shell
{ "status": "error", "error": { "statusCode": 500, "status": "error" }, "message": "Oops", "stack": "Error: Oops\n at funcThree (file:///Users/kennethjimmy/Desktop/app.mjs:29:9)\n at async funcTwo (file:///Users/kennethjimmy/Desktop/app.mjs:34:3)\n at async funcOne (file:///Users/kennethjimmy/Desktop/app.mjs:39:3)\n at async file:///Users/kennethjimmy/Desktop/app.mjs:55:5" }

After the error is thrown, AppSignal captures and reports the exception. To view your application's errors and stack traces, navigate to the AppSignal Dashboard, select "Errors", and click on "Issue list."

Error list

Click on a particular error to see its details, including the error message and stack trace.

Error message

Increasing Stack Trace Size in Node.js

By default, stack trace size is limited, making it difficult to pinpoint the exact source of an error. In development, we want as much context as we can get. Fortunately, it's possible to increase stack trace size in Node.js.

To do so, you can use the stackTraceLimit property of the Error object/class. By default, this property is set to 10, so only the last 10 calls will be included in the stack trace. If the value is set to 0, this stops the collection of stack traces. However, any positive integer can be assigned as the maximum limit for collecting frames. Alternatively, setting the property to Infinity will collect all the frames.

For example, to set the stack trace limit to Infinity in an Express.js application in development mode, you can use the following code:

shell
// Set the stack trace limit to Infinity in development mode if (process.env.NODE_ENV !== 'production') { Error.stackTraceLimit = Infinity } // Initialize Express.js app const express = require('express'); const app = express(); // Define routes // ... // Start the server const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`Server started on port ${port}`); });

By increasing stack trace size, you can get more information about the execution of your code and better understand the context in which an error occurs. However, it's important to consider the following:

  • Performance impact: Increasing stack trace size can negatively impact performance, particularly in cases where a large number of errors occur, or the stack trace size is set to an excessively high value.
  • Memory usage: A larger stack trace size means more memory usage, which can be a concern if your application is already memory-intensive.
  • Security risks: Longer stack traces can potentially expose sensitive information, such as credentials or source code, if not properly handled.
  • Complexity: It can be more difficult to navigate and analyze a larger stack trace, particularly for complex applications.

With that in mind, you should use a reasonable limit that provides the necessary information without causing performance issues.

Wrapping Up

In this post, we've seen how async stack traces in Node.js can help you debug and trace errors in your asynchronous code by capturing call stacks.

We also explored how integrating AppSignal into your Node.js application makes it a lot easier to detect and resolve async callback errors in real time.

Finally, we discussed how expanding stack trace size in Node.js can offer more insights into the execution of your code and help you understand an error's context. However, it's important to use this feature judiciously, taking into account its possible impact on performance, memory usage, security, and navigability.

Happy coding!

P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.

P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.

Omonigho Kenneth Jimmy

Omonigho Kenneth Jimmy

Guest author Omonigho is a full-stack Software Developer with extensive experience in JavaScript. He has a passion for chess and music, and a talent for writing.

All articles by Omonigho Kenneth Jimmy

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps