#Error-first callbacks
Before promises existed, every asynchronous function in Node core took a callback as its last argument and called it exactly once when the work was done, with the signature (err, result). That's the error-first callback convention. It isn't a language feature — it's an agreement that Node core, npm libraries and your own code all follow, so any caller knows how to read the answer.
Think of the callback as a reply envelope with two compartments. The first compartment is for a problem report; the rest are for the goods. You always open the problem compartment first: if there's something in it, you deal with it and don't touch the goods, because they're missing or untrustworthy. If it's empty (null or undefined), the result is real.
Why put the error first? Because it's the one argument every callback has. Some APIs return one value, some two ((err, bytesRead, buffer)), some none — but slot 0 is always the error. That fixed position is what lets generic tools like util.promisify adapt any callback API without knowing anything about it.
const fs = require("node:fs");
fs.readFile("missing.txt", "utf8", (error, data) => {
if (error) {
console.log("code:", error.code, "| errno:", error.errno, "| syscall:", error.syscall);
console.log("message:", error.message);
console.log("data:", data);
return;
}
console.log("File content:", data);
});
console.log("readFile called");
// readFile called
// code: ENOENT | errno: -2 | syscall: open
// message: ENOENT: no such file or directory, open 'missing.txt'
// data: undefinedfs.readFiledoesn't read anything on the main thread. It hands the open/read work to libuv's thread pool (see Call stack, libuv and the thread pool) and returnsundefinedstraight away, soreadFile calledprints first.- On a pool thread, the
open()system call fails because the file doesn't exist. libuv records the error code-2(ENOENT, "no such entry") and queues the completion. - On a later loop iteration, Node turns that code into a real
Errorobject with extra properties —code: "ENOENT",errno: -2,syscall: "open",path: "missing.txt"— and calls your callback with it as the first argument. dataisundefined: on failure the result slots are not filled in. That's why the success path must not run — it would be working with nothing.returnends the callback early. It doesn't "return the error to Node" (Node ignores the callback's return value); it's purely there to stop the success-path code below from running.
The same shape is how you propagate errors through your own callback APIs: on failure call callback(err), on success call callback(null, value), and return every time you call it.
// A fake database with a Node-style API: it answers on a later turn of the event loop.
const users = { 1: { id: 1, name: "Rohit" } };
const db = {
getUser(id, callback) {
setImmediate(() => {
if (typeof id !== "number") return callback(new TypeError("id must be a number"));
callback(null, users[id] ?? null);
});
},
};
function loadUser(id, callback) {
db.getUser(id, (err, user) => {
if (err) return callback(err); // propagate unchanged
if (!user) return callback(new Error(`User ${id} not found`));
callback(null, user); // success: error slot is null
});
}
loadUser(1, (err, user) => console.log("1 →", err, user));
loadUser(2, (err, user) => console.log("2 →", err?.message, user));
loadUser("x", (err, user) => console.log("x →", err?.name, err?.message, user));
// 1 → null { id: 1, name: 'Rohit' }
// 2 → User 2 not found undefined
// x → TypeError id must be a number undefined- All three
loadUsercalls run synchronously and each one callsdb.getUser, which schedules its answer withsetImmediate. Nothing prints yet; three callbacks are waiting in the check phase. - Call 1: the fake DB finds the user and calls back with
(null, { id: 1, … }).loadUser's inner callback sees no error and a user, so it callscallback(null, user)→1 → null { id: 1, name: 'Rohit' }. - Call 2: the DB succeeds (
errisnull) but the user isnull. "Not found" is a business failure, soloadUsercreates an error and passes it up →2 → User 2 not found undefined. - Call
"x": the DB itself fails with aTypeError.loadUsercan't add anything useful, so it passes the same error object up unchanged withreturn callback(err). - Each layer either handles an error or hands it to its caller. This manual hand-off at every level is exactly the boilerplate promises remove: a rejection travels up the chain on its own.
try/catch can't catch callback errorsThe biggest mental shift is that a callback runs later, on a different call stack, so a try/catch around the call that started the work can't catch anything the callback throws:
const fs = require("node:fs");
try {
fs.readFile("missing.txt", "utf8", (err, data) => {
if (err) throw err;
console.log(data);
});
console.log("try block finished");
} catch (err) {
console.log("outer catch:", err.message); // never runs
}trytry block finished
/…/outer-try.cjs:5
if (err) throw err;
^
[Error: ENOENT: no such file or directory, open 'missing.txt'] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: 'missing.txt'
}
Node.js v26.7.0- The
tryblock callsfs.readFile, which only starts the read and returns. Thentry block finishedprints and thetryblock is over — thecatchis now irrelevant. - Some milliseconds later the event loop calls the callback. The call stack is now just "event loop → your callback"; the
try/catchframe disappeared long ago. throw errtherefore has no surroundingcatch. It becomes an uncaught exception: Node prints the error and exits with code1.- The fix is to handle the error inside the callback (log it, call your own callback with it, send a 500), or switch to the promise API and
awaitit inside thetry—awaitbrings the result back onto a stack that does have yourtry/catch(see Async/await error handling).
Rules for writing a well-behaved callback API:
- Call the callback exactly once — never zero times, never twice.
- Always call it asynchronously. A function that sometimes calls back synchronously (cache hit) and sometimes later (cache miss) makes caller code run in an unpredictable order — the "Zalgo" problem;
process.nextTickfixes it. - Pass a real
Errorin the first slot (it has a stack trace), andnullthere on success. - Don't throw from inside an async callback — pass the error on instead.
- Pick one completion style per function. An
async function f(callback)that both callscallback(null, v)and returnsvreports completion twice, and a caller that uses both will act twice.
Callbacks are not gone in modern Node. Many core APIs still have callback forms, and when you implement streams you write callback code directly: _write(chunk, encoding, callback) and _transform(chunk, encoding, callback) use error-first callbacks (see Transform streams).