Richard C.
—When developing your Node.js application, you probably wrote some unit tests to ensure your logic worked as expected. But what do you do when you get an unexplainable error at runtime? And how do you ensure that your app runs as efficiently as possible without memory leaks?
All of us have used console.log()
statements to debug errors but there are easier ways. In this guide, you’ll learn all of them.
Let’s start with the basics: how exceptions work in Node.
An exception is any value thrown by a throw
statement in Node. You could write the following:
throw "An error occurred";
But it’s more useful for error handlers to throw exceptions using an Error
that contains a stack trace of where it was thrown:
try { throw Error("Low level error"); } catch (error) { throw Error("New error message", { cause: error }); }
An Error
is a Node class. All system exceptions are of type Error
or its subtypes like SyntaxError
or RangeError
. The Error.code
property pinpoints the cause of an error, such as EEXIST
or EACCES
.
If you are using callback-style functions instead of await
, check for errors that are returned instead of thrown:
fs.readFile("does not exist", (error, data) => { if (error) { console.error("There was an error reading the file!", error); } });
For common errors and how to fix them, see our Sentry answers series on Node.
There are related activities to detect and prevent errors:
Testing aims to prevent errors before they occur and is done by a human or a program with either a unit test or an integration test. Testing is a big topic and is not discussed in this guide. Note that Node provides a native test package so you rarely need an external framework.
Logging and monitoring detect existing errors or find areas to improve an application’s performance. Logging involves writing lines to a log file to create an auditable history. Monitoring is concerned only with the present and shows a dashboard of the current status of an application or alerts you when an error occurs.
Debugging is investigating the cause of an existing error and fixing it.
In this section, you’ll learn how and what to log.
Depending on whether the app is running in production or locally, or whether you are trying to fix an error or improve performance, you can filter log messages by their level of importance.
Messages can be ranked into log levels from least to most severe:
debug
is occasionally useful when working on a specific programming task. Examples are logging successful downloads or opening a file.info
or log
is used when things are working as expected and auditors might want to know about it in the future. Examples are user login or payment made.warn
indicates something might cause an error or is a security risk. Users might need to see this. Examples are login password was wrong or RAM use is 90% of maximum.error
— indicates that something has gone wrong. Users have to see this. Examples include a failure to connect to the server or a calculation result that’s not a number.fatal
indicates something has gone wrong so badly the application will exit after logging this message. Examples include an out-of-memory error or failure to connect to a database.Node has two ways of logging to the terminal with multiple function names that do the same thing.
To print to stdout, use these commands (they are synonyms):
console.debug("File opened: %d", code); console.log("File opened: %d", code); console.info("File opened: %d", code);
To print to stderr, use these commands (they are synonyms):
console.error("File error: %d", code); console.warn("File error: %d", code);
These functions take a string as the first parameter and an infinite number of additional parameters you can use as string substitutions in the first string.
Despite these console functions appearing to match the standard log-level groups discussed earlier, you cannot separate the output other than by stdout and stderr.
If you want to use basic console logging in your application rather than an external logging package, write a facade between the console functions and your own code. So if you ever want to use a library in the future, or disable or redirect a certain log level to a file, you can make the changes in one place and not everywhere you’ve made a log call. Below is a simple example of a log facade:
const log = { debug: function (msg, ...args) { console.log(msg, ...args); }, info: function (msg, ...args) { console.log(msg, ...args); }, error: function (msg, ...args) { console.error(msg, ...args); }, warn: function (msg, ...args) { console.error(msg, ...args); }, fatal: function (msg, ...args) { console.error(msg, ...args); } }; log.warn("Cannot connect. Retrying...");
There are two more utility functions you need to know about. The first is trace
, which will log a message and the stack trace of the current line to stderr. You could use trace
in place of console.error
in the facade above.
console.trace("File error: %d", code); // Trace: File error: 5198 // at repl:2:9 // at REPLServer.defaultEval (repl.js:248:27) // at bound (domain.js:287:14) // at REPLServer.runBound [as eval] (domain.js:300:12) // at REPLServer.<anonymous> (repl.js:412:12) // at emitOne (events.js:82:20) // at REPLServer.emit (events.js:169:7) // at REPLServer.Interface._onLine (readline.js:210:10) // at REPLServer.Interface._line (readline.js:549:8) // at REPLServer.Interface._ttyWrite (readline.js:826:14)
The second utility function is time
, which works with timeEnd
and writes the elapsed time to stdout.
console.time("computePi"); computePi(); console.timeEnd("computepi"); // Prints: computePi: 225.438ms
To log console statements to a file, redirect your stdout and stdin to log files:
node app.js > out.log 2> err.log # or log both outputs to the same file: node app.js > logfile.txt 2>&1
Use >>
to append to a file instead of >
to overwrite a file.
Alternatively, you can add code to the log facade shown earlier to save log messages of error
severity or higher to a file.
Rather log an object than a string or error code alone:
console.error({ severity: "warning", area: "finance", user: "bob183", message: "Payment failed" });
Logging objects enables your log file to be read as JSON by auditing tools so that they can filter errors into categories. This effectively turns your log file into a key-value database. Define a list of properties you want to log in advance to keep the number of keys in the log file to a known and understandable amount.
If you want to log messages to different files, a database, and the console, as well as automatically format logs as JSON with dates and a stack trace, it is faster to use an external logging package than writing the logging functionality yourself. Winston is the most popular logging package on GitHub.
Here is how you create a logger with Winston:
import * as winston from "winston"; const logger = winston.createLogger({ level: "info", format: winston.format.combine( winston.format.timestamp(), winston.format.json(), ), transports: [ new winston.transports.File({ filename: "error.log", level: "warn" }), new winston.transports.File({ filename: "info.log" }), ], }); if (process.env.NODE_ENV !== "production") { logger.add( new winston.transports.Console({ format: winston.format.simple(), }), ); } logger.error("winston error");
This code will write to error.log
the line {"level":"error","message":"winston error","timestamp":"2024-05-24T09:46:05.534Z"}
.
Winston’s log levels are:
const levels = { error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6 };
After your errors are logged to a file, you need to analyze the file or write code to analyze it to find problems, which takes time away from running your business.
An alternative is to use an external service to help you track errors. A tool like Sentry tracks errors in your Node server, client-side web app, and mobile app. Sentry also provides a dashboard that groups frequently occurring errors into issues to show your quality assurance team what needs attention.
Logging is a way to detect errors. By adding log statements to your code with the value of variables, you can investigate and debug errors. But logging generally isn’t the best debugging tool, because:
A better alternative is using the Node inspector, which addresses these limitations.
On rare occasions, you might want to debug your Node script directly in the terminal, instead of using a GUI. Debugging quickly in a shell session is useful when you have an error that occurs only on a production server.
To start debugging, run node inspect app.js
. Type help
to see all available commands. There are only a few commands you need. Here are the commands for navigation, breakpoints, and watchers:
Command and abbreviation | Effect |
---|---|
list(3) | List 3 lines around the current line that is running. |
next , n | Next line. |
step , s | Step into. |
out , o | Step out. |
cont , c | Continue execution. |
_ | _ |
setBreakpoint , sb(3) , sb('app.js', 3) | Set a breakpoint on line 3 in the current script, or a script you specify. |
clearBreakpoint , cb("app.js", 3) | Clear the breakpoint. You have to specify the script by name, or you will get the error Could not find breakpoint at 3 . |
_ | _ |
watch('log') | Add an object to a list of variables to watch. Note you must quote the object name, 'log' , not log . |
watchers | List all variables you are watching and their values. |
exec("log = 'log is now a string'") | Execute a command. |
_ | _ |
ctrl+c | Exit the debugger. |
The Node documentation says you can start debugging with the command node debug app.js
, but it does not work.
If you run node --inspect app.js
or node inspect app.js
, Node allows a debugging client to attach to it. By default, a WebSocket is available on localhost port 9229, where each app has a UUID like Debugger listening on ws://127.0.0.1:9229/a90c83e7-a9c9-48b2-9e1e-394e1f02dabe
.
You need an app that runs Node’s V8 JavaScript engine to connect to this WebSocket, like Chromium, Chrome, Brave, or VS Code. All these apps are built on Chromium, which uses the V8 engine. You cannot use Firefox or Safari to debug Node because they use different JavaScript engines.
In your browser, browse to a URL like chrome://inspect/#devices
and replace chrome
with your Chromium-based browser name. Click inspect
under your app filename on the page to start debugging.
Now you can use the debugger in the same way you use DevTools to debug a webpage.
If your Node app runs inside Docker, take two steps to ensure connectivity:
node --inspect=0.0.0.0:9229 app.js
.chrome://inspect/#devices
, enable Discover network targets
, click Configure
, and add 127.0.0.1:9229
.Why are these steps needed?
127.0.0.1
loopback address that only listens for connections on the same computer. Using 0.0.0.0
causes the app to listen to connections on all IPs.localhost
, which might point to 127.0.0.1
or might point to an IPv6 address that Docker is not connected to.Visual Studio Code (VS Code) has many options for debugging JavaScript and TypeScript. This guide demonstrates only the most common. For more, see the official documentation.
While you can attach a debugger to running Node processes or launch debuggers in the VS Code integrated terminal, the easiest way to debug your app is to use the Run and Debug side panel.
Open a JavaScript file and in this panel click Run and Debug and choose Node.js. For this to work you must have Node installed on your computer.
At the top of your screen are navigation commands identical to those in the terminal debugger, but with different hotkeys. You can create breakpoints by clicking a line number in your source code. Right-click the breakpoint to set a condition that will pause the code execution. The DEBUG CONSOLE at the bottom lets you enter JavaScript commands when execution is paused. You can see the values of variables in the pane on the left side, and enter expressions to watch their values as execution proceeds.
If you want to run your app through an npm script in your package.json
file, or set certain environment variables for the app, you need to create a launch configuration.
In the Run and Debug panel, click Create a launch.json file and choose Node. This action creates a hidden folder and file: .vscode/launch.json
. See the documentation on how to set properties like env
and runtimeExecutable
.
If you run your Node app in a Docker container for security reasons, or to have an identical runtime environment among all developers on your team, debugging is a little trickier. VS Code supports remote debugging by attaching to the Node inspector on a network port, but it’s less powerful than debugging on the same machine.
The best solution is to run VS Code and Node in the same Docker container by using the Dev Containers extension. See the official documentation here.
.devcontainer
.dockerfile
file for the app into .devcontainer
.FROM --platform=linux/amd64 ubuntu:24.04 ARG DEBIAN_FRONTEND=noninteractive RUN apt update && apt install -y nodejs npm && rm -rf /var/lib/apt/lists/*
.devcontainer/devcontainer.json
. For example:{ "name": "Node Development Container", "dockerFile": "dockerfile", "forwardPorts": [], "postCreateCommand": "", "remoteUser": "root", "customizations": { "vscode": { "settings": { "terminal.integrated.shell.linux": "/bin/bash" }, "extensions": [ "adrianwilczynski.toggle-hidden" ] } } }
When you open this folder in VS Code, a notification should ask you to reopen in a container. Click the button to confirm.
When the container finishes building for the first time and opens, you can work in VS Code in the same way as on your physical machine. Your code folder will now be located in a subfolder of /workspaces
.
If you try to run and debug a TypeScript file in VS Code you’ll immediately get a SyntaxError
when Node encounters TypeScript type definitions. Take the following steps to allow TypeScript debugging in your repository folder:
npm install typescript
to install the Typescript compiler (tsc).tsconfig.json
file. Use the following content to map the JavaScript output to the dist
folder and your source TypeScript:{ "compilerOptions": { "target": "ESNext", "module": "ESNext", "outDir": "dist", "sourceMap": true } }
launch.json
file by going to the Run and Debug panel and clicking Create a launch.json file. Use a config similar to the following to compile to your dist
folder before debugging:{ "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Launch Program", "skipFiles": [ "<node_internals>/**" ], "program": "${workspaceFolder}/app.ts", "preLaunchTask": "tsc: build - tsconfig.json", "outFiles": [ "${workspaceFolder}/dist/**/*.js" ] } ] }
Now you can run and debug TypeScript code using the same methods discussed earlier in this guide for JavaScript.
The inspector has full access to Node and will run any dangerous code it’s given, including accessing your disk drive and sending information out to the internet. The inspector also accepts any debuggers that want to connect to it, with no authentication security.
To prevent malicious programs from accessing your Node debugger, only allow connections to localhost or a port without internet access.
It’s best to run Node applications in a Docker container on your development machine anyway, as a malicious npm package has full permission to do anything on your machine.
Besides the inspector, a Node app has many security vulnerabilities. Please read the guide on how to protect your server.
When you’ve used logging and debugging to detect and fix errors, work on improving the speed of your app through profiling to detect functions that take too long to run.
Below are some of the most common Node performance problems:
By default, Node runs apps in development mode. To use production mode, start your app with an environment variable:
NODE_ENV=production node app.js
Node packages check the NODE_ENV
value when running and behave differently in production
and development
. For example, using production
means packages will cache return values for faster performance and reduce logging. You can use NODE_ENV
in your code to determine how Node packages behave. For example, in Express.js:
if (process.env.NODE_ENV === "development") { app.use(express.errorHandler({ dumpExceptions: true, showStack: true })); } if (process.env.NODE_ENV === "production") { app.use(express.errorHandler()); }
To find out which functions your app is spending most of its time in, run node --prof app.js
. Running the command will create a file named something like isolate-0x5f6d590b7320-9654-v8.log
, which isn’t understandable by humans. The profiler takes a snapshot of the Node environment at regular intervals (ticks) and logs it to the file.
To summarize the log file, run node --prof-process isolate-0x5f6d590b7320-9654-v8.log > profile.txt
.
Below is a snippet from the profile file section on function duration:
[Bottom up (heavy) profile]: Note: percentage shows a share of a particular caller in the total amount of its parent calls. Callers occupying less than 1.0% are not shown. ticks parent name 6735 88.6% /usr/lib/x86_64-linux-gnu/libnode.so.109 6542 97.1% /usr/lib/x86_64-linux-gnu/libnode.so.109 3196 48.9% LazyCompile: ~go /app/app.js:1:12 3196 100.0% Function: ~<anonymous> /app/app.js:1:1 3196 100.0% LazyCompile: ~Module._compile node:internal/modules/cjs/loader:1310:37 3196 100.0% LazyCompile: ~Module._extensions..js node:internal/modules/cjs/loader:1369:37
In the above summary, we can see the app spent 88% of its time running in the main Node library, 48% of which was running a function called go
on line 1 of the source code.
You can also see your app profile in Chromium DevTools. After running node inspect app.js
and connecting the debugger in Chromium to the inspector, click the Performance tab, then Record.
Once you stop recording the profile will be displayed.
You’ll see the order of the function executions and the time spent in each function.
Constantly checking your application’s speed and RAM usage can be time-consuming for developers. An alternative is to let an external tool monitor performance for you, providing a dashboard to your team and alerting you by email or on Slack if performance degrades.
Sentry can do this for you. It has an SDK that hooks into your runtime environment and automatically reports errors, uncaught exceptions, unhandled rejections, and other error types.
NODE_ENV=production
to optimize packages that use it.if (process.env.NODE_ENV === 'production')
in your code to optimize it for production.Error
s in exceptions and not strings.node inspect app.js
and then help
for commands if you are not using an IDE. But it’s better to debug by running your app from VS Code.Tasty treats for web developers brought to you by Sentry. Get tips and tricks from Wes Bos and Scott Tolinski.
SEE EPISODESConsidered “not bad” by 4 million developers and more than 100,000 organizations worldwide, Sentry provides code-level observability to many of the world’s best-known companies like Disney, Peloton, Cloudflare, Eventbrite, Slack, Supercell, and Rockstar Games. Each month we process billions of exceptions from the most popular products on the internet.
Here’s a quick look at how Sentry handles your personal information (PII).
×We collect PII about people browsing our website, users of the Sentry service, prospective customers, and people who otherwise interact with us.
What if my PII is included in data sent to Sentry by a Sentry customer (e.g., someone using Sentry to monitor their app)? In this case you have to contact the Sentry customer (e.g., the maker of the app). We do not control the data that is sent to us through the Sentry service for the purposes of application monitoring.
Am I included?We may disclose your PII to the following type of recipients:
You may have the following rights related to your PII:
If you have any questions or concerns about your privacy at Sentry, please email us at compliance@sentry.io.
If you are a California resident, see our Supplemental notice.