How to handle errors in Node.js: try/catch, error-first callbacks, promises, and event emitters
Node.js errors come in several asynchronous styles, and each has its own correct way to be caught. This guide explains try/catch for synchronous code, error-first callbacks, promise rejection handling, and the 'error' event for streams and event emitters.
On this page
- How errors propagate in Node.js
- Handling synchronous errors with try/catch
- Error-first callbacks
- Using promises and async/await
- Handling 'error' events on event emitters and streams
- Global fallbacks: uncaughtException and unhandledRejection
- Building an Express error-handling middleware
- FAQ
- What is the error-first callback pattern in Node.js?
- Why can't I catch an asynchronous error with try/catch?
- What happens if you do not listen for the 'error' event on an EventEmitter?
- Is using uncaughtException to keep the server alive a good idea?
Node.js handles errors differently depending on the style of the API that produced them. Getting this right matters because an uncaught error in the wrong place will crash the whole process, taking down every other request being served. In this guide you will learn how to catch errors in each asynchronous style Node.js uses, so your server stays alive and your users get a clear, safe response instead of a crashed process.
How errors propagate in Node.js
All JavaScript errors are handled as exceptions that immediately throw using the standard throw mechanism, and synchronous code catches them with try...catch. But Node.js has few purely synchronous APIs: most of the standard library is asynchronous, and it reports failures in one of three ways depending on the API:
- Callbacks that pass an error as the first argument (the "error-first callback" pattern).
- Promises that reject, which you handle with
catch. EventEmitterobjects that emit an'error'event, which you must listen for.
Every style needs its own interception technique, because try...catch alone cannot catch an error thrown asynchronously after the calling code has already returned. If you ignore the way an API signals failure, the error escapes to the event loop and, with no handler, the Node.js process exits. Understanding which style an API uses is the first step to handling its errors correctly.
Handling synchronous errors with try/catch
For synchronous code, the try...catch block lets you catch an exception thrown at runtime and run recovery logic instead of letting it crash. A try block contains the code that may throw; the catch block receives the error object. Older JavaScript also supports a finally block, which runs whether or not an error was thrown, typically for cleanup.
try {
const config = JSON.parse(fs.readFileSync("config.json", "utf8"));
console.log(config.port);
} catch (err) {
console.error("Could not read config:", err.message);
}
Note that only synchronous functions can be handled this way. If fs.readFileSync becomes fs.readFile (its asynchronous version), the error will no longer be thrown inside try, because the read completes later, outside the block. Trying to wrap an asynchronous call in try...catch is one of the most common Node.js mistakes — it looks correct but silently lets errors escape.
Error-first callbacks
The most common pattern in the Node.js core modules is the error-first callback. When an asynchronous operation finishes or fails, the callback is called with an error object as its first argument, or null if there is no error. Data (if any) comes as later arguments.
const fs = require("node:fs");
fs.readFile("notes.txt", "utf8", (err, data) => {
if (err) {
// Handle the failure here — never throw from an async callback.
console.error("Read failed:", err.message);
return;
}
console.log(data);
});
The critical rule is that you must check err at the top of every callback. If the callback ignores the error argument, a failed operation silently does nothing, which can corrupt state or mislead the rest of your program. Never throw inside a callback — the thrown error is not caught by any surrounding try...catch (the callback runs later, on the event loop), so it becomes an uncaught exception and crashes the process.
Using promises and async/await
Modern Node.js APIs, and the node:fs/promises module, return promises. A rejected promise is thrown for you automatically when you await it, which means try...catch works again — as long as you await inside the try block:
const fs = require("node:fs/promises");
async function readConfig() {
try {
const config = await fs.readFile("config.json", "utf8");
return JSON.parse(config);
} catch (err) {
console.error("Failed to load config:", err.message);
throw err; // re-throw so the caller sees the failure
}
}
Because you await the promise inside try, a rejection is caught there. If you instead return the promise without awaiting, an error in a later catch or in the caller must be handled separately. Re-throwing the error (with throw err) after logging it is a common pattern: it lets the immediate function log context while still propagating the failure to the caller. You can also attach a catch handler directly to a promise you are not awaiting, to avoid an unhandled rejection.
Handling 'error' events on event emitters and streams
Streams and event emitters such as HTTP servers, sockets, and child_process report errors by emitting an 'error' event. If no listener is attached for 'error', Node.js throws the error, and unless something handles it as an uncaught exception, the process crashes. So every EventEmitter you create or consume should register an 'error' handler:
const fs = require("node:fs");
const stream = fs.createReadStream("big.bin");
stream.on("error", (err) => {
console.error("Stream failed:", err.message);
});
An 'error' event cannot be caught with try...catch, because it is emitted after the calling code has returned. The handler must be attached before the error can occur — attach it as soon as you create the emitter, not later. For a TCP or HTTP server, an unhandled 'error' event on the server object is a common cause of a sudden crash in production.
Global fallbacks: uncaughtException and unhandledRejection
For errors that still escape your code, Node.js provides two process-level events. uncaughtException fires when an exception is not caught anywhere and the runtime is about to exit; unhandledRejection fires when a promise is rejected with no handler. The official guidance is to use these as a last resort: after uncaughtException the state of the process is unreliable, because there is no guarantee the stack is intact or internal data is consistent. The recommended practice is to log the error and shut the process down cleanly, letting an external supervisor (such as a process manager) restart it, rather than trying to keep running on a suspect state.
process.on("uncaughtException", (err) => {
console.error("Uncaught exception, exiting:", err);
process.exit(1);
});
Logging the error and exiting gives you a chance to record what happened while avoiding undefined behavior. Relying on these events to "keep the server alive" is discouraged: the safer, more maintainable design is to catch errors at the point where they occur, and to let truly unexpected failures crash the process so the supervisor restarts it fresh.
Building an Express error-handling middleware
In Express, an error thrown inside a route handler is caught by Express and passed to special error-handling middleware, which is a middleware function with four parameters: (err, req, res, next). This is how you return a safe JSON response to the client instead of a raw stack trace:
app.get("/users/:id", async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) return res.status(404).json({ error: "Not found" });
res.json(user);
} catch (err) {
next(err); // forward to the error middleware
}
});
// Error-handling middleware — must have 4 parameters.
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "Internal server error" });
});
The four-parameter signature is what tells Express this is error-handling middleware, not a normal one. next(err) forwards the error so it skips regular middleware and reaches your handler, where you can log it, choose a status code, and send a safe response. Never send the raw error message back to the client, because it can leak internal details such as file paths or connection strings.
FAQ
What is the error-first callback pattern in Node.js?
It is the convention used by most Node.js core APIs where an asynchronous callback receives an error as its first argument, or null if the operation succeeded. You must check this argument at the top of the callback and handle it; if you ignore it, the failure silently does nothing, and if you throw inside the callback it becomes an uncaught exception.
Why can't I catch an asynchronous error with try/catch?
try/catch only catches errors thrown synchronously inside the try block. Asynchronous callbacks and 'error' events fire later, after the calling code has already returned, so a throw there escapes the block. To catch asynchronous failures you must use promise catch/await inside try, check the error argument of a callback, or attach an 'error' event handler.
What happens if you do not listen for the 'error' event on an EventEmitter?
If an EventEmitter emits an 'error' event and no listener is attached, Node.js treats it as an uncaught exception. Unless the process handles it (for example via uncaughtException), it terminates, which is why a missing 'error' listener on a server, stream, or socket commonly causes a crash.
Is using uncaughtException to keep the server alive a good idea?
No. Official Node.js guidance recommends treating uncaughtException as a last resort: after it fires there is no guarantee the process is in a consistent state. The recommended approach is to log the error and exit cleanly so an external supervisor can restart the process, rather than attempting to continue running on an unreliable state.
Sources
- Node.js Documentation — Errors (nodejs.org/api/errors.html
- MIT license)
- Node.js Documentation — Error propagation and interception (nodejs.org/api/errors.html#error-propagation-and-interception
- MIT license)
- MDN Web Docs — try...catch (developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/try...catch
- CC-BY-SA 2.5)
- MDN Web Docs — JavaScript Error object (developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
- CC-BY-SA 2.5)
- MDN Web Docs — Error event (developer.mozilla.org/en-US/docs/Web/API/EventTarget/error_event
- CC-BY-SA 2.5)
- Wikipedia — Exception handling (en.wikipedia.org/wiki/Exception_handling
- CC BY-SA)
Share this guide
Related guides
- How to implement rate limiting in Node.js: algorithms and practical examples
Rate limiting protects Node.js APIs from abuse and overload. This guide explains fixed window, sliding window, and token bucket algorithms, shows how to add rate limiting to an Express API, and how to test it.
- How to build a REST API with Node.js and Express
Learn to create a production-ready RESTful API using Node.js and Express: setup, routing, middleware, environment variables, error handling, and deployment best practices — with a practical CRUD example for managing resources.
- How to Use Environment Variables in Node.js: process.env, .env files, and secure secrets
Environment variables are the standard way to configure a Node.js app without hardcoding values. Node reads them through process.env, you can load them from .env files with the built-in --env-file flag or the dotenv package, and secrets should stay out of your code and version control. This guide walks through each approach and when to use it.