One thread, many things in flight
Your JavaScript runs on one thread. Disk reads, network calls and timers are handed to the operating system, and their results come back as callbacks the event loop runs when your code is idle. That is how one process serves thousands of connections without a thread each.
// a request arrives every 10 ms; this handler takes 2 s of CPU
app.get("/report", (req, res) => {
const rows = buildHugeReport(); // nothing else runs for two seconds
res.json(rows);
});
// 200 waiting requests, all slow, and the health check times out too
The callback convention
Every callback-style API in Node takes (err, value). That consistency is what makes it mechanical to convert them.
fs.readFile("doors.json", "utf8", (err, data) => {
if (err) return console.error(err); // the first argument is the error
console.log(data);
});
Promisifying
import { promisify } from "node:util";
const readFile = promisify(fs.readFile);
const data = await readFile("doors.json", "utf8");
// what it does, in six lines
function promisify(fn) {
return (...args) => new Promise((resolve, reject) => {
fn(...args, (err, value) => (err ? reject(err) : resolve(value)));
});
}
Most built-ins ship a promise version already: fs/promises, dns/promises, stream/promises, timers/promises. Reach for promisify for older third-party packages.
Sequential or parallel
Promise.all— all of them, and the first failure rejects the whole thing.Promise.allSettled— all of them, and you get every outcome:{ status: "fulfilled" | "rejected" }.Promise.race— the first to settle, good for a timeout.Promise.any— the first to succeed.
// 3 × 200 ms = 600 ms — each waits for the one before
for (const id of ids) results.push(await load(id));
// 200 ms — all three start at once
const results = await Promise.all(ids.map(load));
Firing 5 000 requests at a database at once is a denial-of-service attack on yourself. Batch them, or use a small concurrency limit.
Errors
An async function never throws to its caller synchronously — it returns a rejected promise. Forgetting await in front of a call inside a try means the catch is never reached.
try {
const data = await load();
} catch (err) {
if (err.code === "ENOENT") return null;
throw err; // do not swallow what you cannot handle
}
// a rejected promise nobody awaited crashes the process (Node 15+)
process.on("unhandledRejection", (err) => { log.fatal(err); process.exit(1); });
Timing out anything
const result = await Promise.race([
load(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out")), 2000)),
]);
// better where it is supported: actually cancel the work
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
CPU work does not belong here
- Hashing, image processing, big parsing, compression: all of it blocks the loop.
- Move it to a worker thread (
node:worker_threads), a child process, or a background queue. awaitdoes not help — awaiting a synchronous calculation still runs it on this thread.
// this awaits nothing and blocks for a second
async function slow() {
let x = 0;
for (let i = 0; i < 1e9; i++) x += i;
return x;
}