How it works
The spec's try-statement rules evaluate the block, look at the resulting completion, and run catch if it is a throw. Evaluation of the block ends when the block's statements are done, so the catch clause has a fixed lifetime: the synchronous run of the block.
A callback is stored by the host (a timer, a socket, an event target). When the host fires it, the engine pushes a new execution context onto an empty stack. When that function throws, no enclosing try exists, so the throw completion reaches the host. The host reports it (browser: the error event on the global object, covered in the uncaught errors question; Node.js: the 'uncaughtException' handling).
Promises make failure a value instead of control flow. The spec does this in three places: the Promise constructor catches a throw from the executor and calls reject. A reaction job runs a .then handler and, if it throws, rejects the derived promise. AsyncBlockStart runs an async function body, and a throw completion rejects the function's promise. All three convert a throw to a rejection, so errors keep flowing down the chain until a handler takes them.
new Promise(() => { throw new Error('executor'); })
.catch((error) => console.log('executor ->', error.message));
Promise.resolve()
.then(() => { throw new Error('then'); })
.catch((error) => console.log('then ->', error.message));
// → executor -> executor
// → then -> then
Edge cases
Without await, the error escapes the try even inside an async function:
process.on('uncaughtException', (error, origin) => console.log(error.message, origin));
try {
setTimeout(() => {
throw new Error('thrown in a timer');
}, 0);
} catch {
console.log('never runs');
}
const failLater = async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
throw new Error('rejected later');
};
async function withoutAwait() {
try {
return failLater(); // returned, not awaited
} catch {
console.log('never runs');
}
}
async function withAwait() {
try {
return await failLater();
} catch (error) {
console.log('caught', error.message);
}
}
await withoutAwait().catch((error) => console.log('escaped try:', error.message));
await withAwait();
// → thrown in a timer uncaughtException
// → escaped try: rejected later
// → caught rejected later
Node.js documents this split: synchronous APIs throw, promise APIs reject, callback APIs pass an error as the first argument, and EventEmitter uses 'error' events. Which one applies depends on the API, so read its documentation.
Trade-offs
Callbacks need manual error plumbing at every level and are easy to call twice or never. Promises and await give one error path and normal try/catch, at the cost of remembering that every promise needs an owner that handles its rejection. Use await in application code, and keep callback-to-promise adapters at the boundary where a callback API enters your code.