Async Node APIs and Errors
Node.js programs often wait for input/output work.
Examples:
- reading files
- writing files
- making HTTP requests
- receiving requests
- querying databases
- waiting for timers
Most modern Node APIs are asynchronous because waiting should not block the whole process.
Synchronous vs Asynchronous APIs
Some Node modules provide both sync and async versions.
Synchronous file read:
import { readFileSync } from "node:fs";
const text = readFileSync("notes.txt", "utf8");
console.log(text);Asynchronous file read:
import { readFile } from "node:fs/promises";
const text = await readFile("notes.txt", "utf8");
console.log(text);Synchronous APIs block the current thread until the work finishes.
Asynchronous APIs let Node keep handling other work.
Prefer Async for Servers
In a server, blocking work can delay every request.
// Avoid in request handlers
const text = readFileSync("large-file.txt", "utf8");If many users send requests, synchronous file or CPU-heavy work can make the server feel frozen.
Prefer async APIs in server code.
const text = await readFile("large-file.txt", "utf8");Small one-time scripts sometimes use sync APIs for simplicity, but async is the better default for server and library code.
Callback APIs
Older Node APIs often use callbacks.
import fs from "node:fs";
fs.readFile("notes.txt", "utf8", (error, text) => {
if (error) {
console.error(error);
return;
}
console.log(text);
});Node callbacks commonly follow this pattern:
(error, result) => {}The first argument is an error if something went wrong.
The result comes after the error.
Promise APIs
Promise APIs work cleanly with async and await.
import { readFile } from "node:fs/promises";
try {
const text = await readFile("notes.txt", "utf8");
console.log(text);
} catch (error) {
console.error("Could not read file");
console.error(error.message);
}When available, promise APIs are usually easier to read than callback APIs.
Promisifying Callback APIs
Some older APIs only support callbacks.
Node provides promisify().
import { promisify } from "node:util";
import fs from "node:fs";
const readFile = promisify(fs.readFile);
const text = await readFile("notes.txt", "utf8");
console.log(text);Use built-in promise APIs when they exist.
Use promisify() when you need to adapt an older callback API.
Error Handling with await
Use try / catch.
try {
const text = await readFile("missing.txt", "utf8");
console.log(text);
} catch (error) {
console.error(error.code);
console.error(error.message);
}Many Node errors include a code.
Examples:
ENOENT: file or directory does not existEACCES: permission deniedEADDRINUSE: network port is already in use
Error codes help you handle known failures.
Handling Expected Errors
Handle errors when you can do something useful.
try {
const text = await readFile("optional.txt", "utf8");
console.log(text);
} catch (error) {
if (error.code === "ENOENT") {
console.log("No optional file found");
} else {
throw error;
}
}This handles the missing file case but still throws unexpected errors.
Unhandled Rejections
If a Promise rejects and no code handles it, Node reports an unhandled rejection.
import { readFile } from "node:fs/promises";
readFile("missing.txt", "utf8");This starts an async operation but does not await or .catch() it.
Better:
try {
await readFile("missing.txt", "utf8");
} catch (error) {
console.error(error.message);
}Running Tasks in Parallel
Use Promise.all() for independent async work.
import { readFile } from "node:fs/promises";
const [usersText, settingsText] = await Promise.all([
readFile("users.json", "utf8"),
readFile("settings.json", "utf8"),
]);
console.log(usersText);
console.log(settingsText);This starts both reads before waiting for both to finish.
Use this only when tasks do not depend on each other.
Timeouts and Cancellation
Some Node APIs support AbortController.
Example with fetch:
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 3000);
try {
const response = await fetch("https://example.com", {
signal: controller.signal,
});
console.log(response.status);
} finally {
clearTimeout(timeoutId);
}Cancellation support depends on the API.
Always check the API documentation.
Process-Level Error Events
Node can report serious unhandled errors through process events.
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
process.exitCode = 1;
});process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exitCode = 1;
});These are last-resort safety nets.
Do not use them as a replacement for normal try / catch and Promise error handling.
Common Mistakes
Do not forget await when you need the result.
Do not use sync APIs inside request handlers unless you have a strong reason.
Do not catch every error and silently ignore it.
Do not treat all errors as missing-file errors.
Do not use process-level error handlers as normal control flow.
Summary
Node.js is built around asynchronous I/O.
Prefer promise-based APIs with async and await, handle expected errors specifically, let unexpected errors surface, and avoid blocking server code with synchronous APIs.