DEV Community

Cover image for An Introduction to Async Stack Traces in Node.js
Omonigho Kenneth Jimmy for AppSignal

Posted on • Originally published at blog.appsignal.com

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().

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)
);
Enter fullscreen mode Exit fullscreen mode

This results in an output like so:

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)
Enter fullscreen mode Exit fullscreen mode

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

node ./asyncStackTraces.js

Error: Oops
    at funcThree (/Users/kennethjimmy/Desktop/asyncStackTraces.js:3:9)
    at <anonymous>
Enter fullscreen mode Exit fullscreen mode

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:

npm init -y
Enter fullscreen mode Exit fullscreen mode

Install AppSignal for Node.js:

npm install --save @appsignal/nodejs
Enter fullscreen mode Exit fullscreen mode

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

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
});
Enter fullscreen mode Exit fullscreen mode

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:

{
  "scripts": {
    "start": "node --require ‘./appsignal.cjs’ app.mjs"
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

import { expressErrorHandler } from "@appsignal/nodejs";

const app = express();

// add this after all routes, but before any other error handlers
app.use(expressErrorHandler());
Enter fullscreen mode Exit fullscreen mode

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:

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,
      },
    });
  })
);
Enter fullscreen mode Exit fullscreen mode

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

{
  "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"
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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}`);
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)