The Death of a Node.js Process

This post is based on content from my recently published book, Distributed Systems with Node.js. If you're interested in building Node.js services, especially those that interact with external systems, then I highly recommend checking it out. It contains a wealth of information, and this post provides but a tiny taste.

There are several things that can cause a Node.js process to terminate. Some of them are preventable, like when an error is thrown, while others cannot be prevented, like running out of memory. The process global is an Event Emitter instance and, when a graceful exit is performed, will emit an exit event. Application code can then listen to this event to perform some last minute synchronous cleanup work.

Here are some ways that a process termination can be intentionally triggered:

Operation Example
Manual process exit process.exit(1)
Uncaught exception throw new Error()
Unhandled promise rejection Promise.reject()
Ignored error event EventEmitter#emit('error')
Unhandled signals $ kill <PROCESS_ID>

A lot of these are often triggered accidentally, like with uncaught errors or unhandled rejections, but one of them was created with the intention of directly causing a process to terminate.

Process Exit

The process.exit(code) approach to process termination is the most straightforward tool at your disposal. It's very useful when building scripts when you know that your process has reached the end of its lifetime. The code value is optional and defaults to 0 and can be set to a number as high as 255. A 0 represents a successful process run, while any non-zero number means a failure happened. These values are used by many different external tools. For example, when a test suite runs, a non-zero value means the tests have failed.

When process.exit() is called directly there is no implicit text written to the console. If you have written code that calls this method in representation of an error, then your code should print an error for the user to help them out. For example, run the following code:

$ node -e "process.exit(42)"
$ echo $?

In this case no message was printed by the one-line Node.js application, though the shell did print the exit status. A user encountering such a process exit not going to understand what's happening. On the other hand, consider this code that might run when a program is configured incorrectly:

function checkConfig(config) {
  if (!config.host) {
    console.error("Configuration is missing 'host' parameter!");
    process.exit(1);
  }
}

In this situation there is no ambiguity for the user. They run the app, an error is printed to the console, and they're able to rectify the situation.

It's worth noting that the process.exit() method is extremely powerful. While it has its purposes in application code it should really never make its way into a reusable library. If an error does happen in a library, it should be thrown so that the application may decide what to do with the error.

Exceptions, Rejections, and Emitted Errors

While process.exit() is helpful when it comes to startup / configuration errors, for runtime errors you'll need to use a different tool. For example, when an application is handling an HTTP request, an error probably shouldn't terminate the process, instead it should just return an error response. Having information about where the error happened is also useful. This is where thrown Error objects are useful.

Instances of the Error class contain metadata that is useful for determining what caused the error, such as stack traces and message strings. It's common to extend from Error with your own application-specific error classes. Instantiating an error on its own doesn't have much side effect, for that to happen an error must be thrown.

An error is thrown when the throw keyword is used, or when certain logical errors occur. When this happens the current stack "unwinds", meaning each function exits until one of the calling functions has wrapped the call in a try/catch statement. Once this statement is encountered the catch branch is called. If the error is never wrapped in a try/catch then the error is considered uncaught.

While you should use the throw keyword with an Error, like throw new Error('foo'), you can technically throw anything. Once something has been thrown it is considered an exception. It's important to throw Error instances as code that catches those errors will likely expect error properties.

Another pattern, made popular by internal Node.js libraries, is to provide a .code property which is a documented string value that should remain consistent between releases. An example of an error code is ERR_INVALID_URI which, even though the associated human-readable .message property may change, this code value shouldn't.

Sadly, one of the more common patterns used for differentiating errors is to inspect the .message property, which is often dynamic and could require typo changes. This is risky and error prone. There is no perfect solution within the Node.js ecosystem for differentiating errors across all libraries.

When an uncaught error is thrown the stack trace is printed in the console and the process terminates with an exit status of 1. Here's an example of such an exception:

/tmp/foo.js:1
throw new TypeError('invalid foo');
^
Error: invalid foo
    at Object.<anonymous> (/tmp/foo.js:2:11)
    ... TRUNCATED ...
    at internal/main/run_main_module.js:17:47

This truncated stack traces suggests the error happened on line 2, column 11 in a file named foo.js.

The process global is an Event Emitter and can be used to intercept such uncaught errors by listening for the uncaughtException event. Here's an example of how to use it, intercepting an error to send an asynchronous message before exiting:

const logger = require('./lib/logger.js');
process.on('uncaughtException', (error) => {
  logger.send("An uncaught exception has occured", error, () => {
    console.error(error);
    process.exit(1);
  });
});

Promise Rejections are pretty similar to thrown errors. A promise can reject either if the reject() method in a promise is called, or an error is thrown inside of an asynchronous function. The following two examples are mostly equivalent in this regard:

Promise.reject(new Error('oh no'));

(async () => {
  throw new Error('oh no');
})();

Here's an example of what the message printed to the console looks like:

(node:52298) UnhandledPromiseRejectionWarning: Error: oh no
    at Object.<anonymous> (/tmp/reject.js:1:16)
    ... TRUNCATED ...
    at internal/main/run_main_module.js:17:47
(node:52298) UnhandledPromiseRejectionWarning: Unhandled promise
  rejection. This error originated either by throwing inside of an
  async function without a catch block, or by rejecting a promise
  which was not handled with .catch().

Unlike with an uncaught exception, these rejections won't crash a process as of Node.js v14. In future versions of Node.js this will crash the process. You can also intercept events when these unhandled rejections happen, listening for another event on the process object:

process.on('unhandledRejection', (reason, promise) => {});

Event Emitters are a common pattern in Node.js, with many object instances that extend from this base class used in libraries and applications alike. They're so popular that they're worth discussing alongside errors and rejections.

When an Event Emitter emits an error event that doesn't have a listener on it, the Event Emitter will throw the argument that was emitted. This will then spit out an error and cause the process to exit. Here's an example of what is printed in the console:

events.js:306
    throw err; // Unhandled 'error' event
    ^
Error [ERR_UNHANDLED_ERROR]: Unhandled error. (undefined)
    at EventEmitter.emit (events.js:304:17)
    at Object.<anonymous> (/tmp/foo.js:1:40)
    ... TRUNCATED ...
    at internal/main/run_main_module.js:17:47 {
  code: 'ERR_UNHANDLED_ERROR',
  context: undefined
}

Be sure to listen for error events in the Event Emitter instances you work with so that your application may gracefully handle the event without crashing.

Signals

Signals are an operating system-provided mechanism for sending small numeric messages from one program to another. These numbers are often referred by a constant string equivalent. For example, the signal SIGKILL represents a numeric signal of 9. Signals can have different purposes but are often used to terminate a program in some capacity.

Different operating systems can have different signals defined, but the following list is mostly universal:

Name Number Handleable Node.js Default Signal Purpose
SIGHUP 1 Yes Terminate Parent terminal has been closed
SIGINT 2 Yes Terminate Terminal trying to interrupt, à la Ctrl + C
SIGQUIT 3 Yes Terminate Terminal trying to quit, à la Ctrl + D
SIGKILL 9 No Terminate Process is being forcefully killed
SIGUSR1 10 Yes Start Debugger User-defined signal 1
SIGUSR2 12 Yes Terminate User-defined signal 2
SIGTERM 12 Yes Terminate Represents a graceful termination
SIGSTOP 19 No Terminate Process is being forcefully stopped

If a program may choose to implement a signal handler then the Handleable column contains a Yes. The two signals with a No cannot be handled. The Node.js Default column tells you what the default action is with a Node.js program when the signal is received. The last Signal Purpose states what the signal is usually used for in the wild.

Handling these signals in your Node.js programs can be done by listening for more events on the process object:

#!/usr/bin/env node
console.log(`Process ID: ${process.pid}`);
process.on('SIGHUP', () => console.log('Received: SIGHUP'));
process.on('SIGINT', () => console.log('Received: SIGINT'));
setTimeout(() => {}, 5 * 60 * 1000); // keep process alive

Run this program in a terminal window, then press Ctrl + C, and the process won't die. Instead, it'll state that it has received the SIGINT signal. Switch to another terminal window and execute the following command based on the printed Process ID value:

$ kill -s SIGHUP <PROCESS_ID>

This shows how one program is able to send a signal to another program and your Node.js program running in the first terminal will print that it received the SIGHUP signal.

As you might have guessed, Node.js is also able to send commands to other programs. Execute the following command to send a signal from an ephemeral Node.js process to your existing process:

$ node -e "process.kill(<PROCESS_ID>, 'SIGHUP')"

This should also display the SIGHUP message in your first program. Now, if you would like to actually terminate the first process, run the following command to send it an un-handleable SIGKILL signal:

$ kill -9 <PROCESS_ID>

At this point the application should exit.

These signals are used a lot in Node.js applications for handling graceful shutdown events. For example, when a Kubernetes pod terminate, it will send a SIGTERM signal to the applications, and then start a 30 second timer. The application can then gracefully close itself in those 30 seconds, closing connections and saving data. If the process remains alive after this timer then Kubernetes will send it a SIGKILL.

If you like what you read here, then check out my book Distributed Systems with Node.js. It contains a ton of information about running a Node.js process in a setting where it needs to interact with external services.

Tags: #nodejs
Thomas Hunter II Avatar

Thomas has contributed to dozens of enterprise Node.js services and has worked for a company dedicated to securing Node.js. He has spoken at several conferences on Node.js and JavaScript and is an O'Reilly published author.