JAVASCRIPT / PROMISES AND ASYNC PATTERNS
Error handling across async boundaries
Route errors across timers, callbacks and await points so every failure lands in a catch instead of vanishing as an unhandled rejection.
What you will learn
- Convert callback failures into reject(err) so they travel as a rejected promise
- Put try/catch around the await, since a bare async call only stores the rejection
- Return promises from .then handlers so their rejections reach the tail .catch
- Recognise a never-settling promise as the failure mode that produces no error at all
Understanding Error handling across async boundaries
A throw only travels up the call stack that is running at that moment. When you schedule a callback, the scheduling function returns, its stack unwinds, and the callback later runs on a fresh stack whose only caller is the host's task runner. That is the async boundary: the try/catch that started the work is not an ancestor of the code that fails, so it cannot catch it. Crossing the boundary means turning the error into a value the other side can inspect, either an error argument in a callback or a rejected promise.
Promises make that value routable. A rejection moves along the chain until it reaches a handler that accepts rejections (.catch, or the second argument of .then), skipping fulfilment handlers on the way, and a throw inside any handler rejects the promise that handler's .then returned, which is what makes a single tail catch work. An async function is the same machinery seen from the other side: a throw in its body rejects the promise it returns, and await converts a rejection back into a throw at the await expression, on the stack of the awaiting function. The chain is connected only where you connect it, so a promise you neither return nor await is a dead end for errors.
When nothing handles a rejection it is not lost, only unobserved, and the host reports it: browsers fire an unhandledrejection event on window and log to the console, while Node emits process 'unhandledRejection' and since v15 exits with a non-zero code. The quieter failure is worse. If an error escapes inside a callback instead of calling reject, the promise never settles at all, so an await on it waits forever and neither a catch nor an unhandled-rejection warning ever fires.
Deciding at each boundary whether an error is rejected, rethrown with a cause, or deliberately swallowed is what keeps failures visible.
function loadConfig(callback) {
setTimeout(() => callback(new Error('disk offline')), 10);
}
function plainTryCatch() {
try {
loadConfig(err => console.log('callback ran with err:', err.message));
console.log('try block finished, the callback has not run yet');
} catch (err) {
console.log('this line can never run');
}
}
function loadConfigAsync() {
return new Promise((resolve, reject) => {
loadConfig(err => (err ? reject(err) : resolve('config')));
});
}
async function awaited() {
try {
await loadConfigAsync();
} catch (err) {
console.log('await moved the error back onto the stack:', err.message);
} finally {
console.log('finally still runs after the rejection');
}
}
plainTryCatch();
awaited();An error crosses an async boundary only as data, namely a rejected promise, and becomes a throw again exactly at an await or a rejection handler.
Worked examples
A missing return breaks the error path
Shows that a promise created inside a .then handler but not returned keeps its rejection out of the chain.
function saveRecord() {
return Promise.reject(new Error('write conflict'));
}
function detached() {
return Promise.resolve()
.then(() => {
saveRecord().catch(err => console.log('handled locally:', err.message));
})
.then(() => console.log('the outer chain thinks the save succeeded'))
.catch(err => console.log('outer catch, never reached:', err.message));
}
function connected() {
return Promise.resolve()
.then(() => saveRecord())
.then(() => console.log('never reached'))
.catch(err => console.log('outer catch got it:', err.message));
}
detached().then(connected);Example explained
Line 1The first handler has no return, so it fulfils with undefined and the chain moves on as if the write worked.
Line 2Only the local .catch observes the rejection; remove it and the same rejection becomes an unhandled rejection while the tail .catch still stays silent.
Line 3In connected, the concise arrow `() => saveRecord()` returns the promise, so the chain adopts it and the rejection reaches the tail .catch.
Line 4The tail .catch of detached never logs, which is the point: an unconnected promise cannot deliver its error to it.
An async call without await stores the rejection
Demonstrates that a throw inside an async function becomes a rejection value, so a surrounding try/catch sees nothing until an await.
async function fetchUser(id) {
if (id < 0) throw new Error(`bad id: ${id}`);
return { id };
}
function noAwait() {
let result;
try {
result = fetchUser(-1);
console.log('try/catch saw nothing, it got a', result.constructor.name);
} catch (err) {
console.log('never printed');
}
return result;
}
async function withAwait() {
try {
await fetchUser(-1);
} catch (err) {
console.log('await rethrew it here:', err.message);
}
}
noAwait().catch(err => console.log('the rejection was sitting in the promise:', err.message));
withAwait();Example explained
Line 1The throw runs synchronously inside fetchUser, yet it never leaves as a throw: async functions convert it into the rejection of the returned promise.
Line 2So the assignment completes normally, the try block has nothing to catch, and its catch clause is dead code.
Line 3Attaching .catch later still retrieves the error, proving the rejection was stored on the promise rather than discarded.
Line 4await is the only place that converts that stored rejection back into a throw, on a stack the try/catch actually owns.
The executor boundary inside new Promise
Contrasts a throw during the synchronous executor, which becomes a rejection, with a failure in a later callback, which must be rejected by hand.
function executorThrow() {
return new Promise(() => {
throw new Error('thrown while the executor is still running');
});
}
function timerFailure() {
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
JSON.parse('{oops');
} catch (err) {
reject(err);
}
}, 0);
});
}
executorThrow().catch(err => console.log('executor throw became a rejection:', err.message));
timerFailure().catch(err => console.log('timer failure arrived as a', err.name));Example explained
Line 1The Promise constructor calls the executor synchronously and is specified to turn a throw from it into a rejection, so catch works with no extra code.
Line 2That wrapper is gone once the executor returns, so a throw inside the timer callback would reach the host and leave the promise pending forever.
Line 3try/catch inside the callback plus reject(err) is the manual replacement for the wrapper the constructor gave you.
Line 4err.name is logged rather than err.message because the parser's wording for malformed JSON differs between engine versions.
Important notes
A try/catch around an await also catches errors thrown before the first await inside the async function, because those errors are the same rejection; it catches nothing from a promise you created but never awaited.
A detached rejection is only a console warning in a browser but terminates the process in Node since v15, so working in devtools is not evidence that a boundary is handled.
Common mistakes
Wrapping the call instead of the await: `try { doAsync(); } catch (e) {}` finishes before the promise settles, so the catch never runs and the failure resurfaces later as an unhandled rejection.
Throwing inside a setTimeout or event callback that sits in a promise executor: the throw hits the host, the promise never settles, and the awaiting code hangs with no error reported anywhere.
Adding `.catch(err => console.log(err))` in the middle of a chain and continuing: the catch returns undefined, the chain becomes fulfilled, and the next .then runs with undefined as if the operation had worked.
Try it yourself
Change, predict, then run
In a browser console, write delay(ms) plus a flaky() that rejects with new Error('offline') after 100ms, call it from an async function inside try/catch/finally and confirm the catch and finally both run. Then delete the await from that call and confirm the catch stops firing while devtools reports an uncaught error in a promise instead.
Open the JavaScript workspaceCheck your understanding
A .then handler calls save(), which returns a promise that rejects, but the handler does not return that promise. What does the .catch at the end of the chain see?
- The rejection, because .then handlers automatically wait for any promise created inside them
- A TypeError, because the handler returned undefined where a promise was expected
- Nothing: the chain fulfils and the rejection is reported separately as an unhandled rejection
- The rejection, but only after every other handler in the chain has finished running
Show answer
A .then handler links to a promise only if you return it; returning undefined fulfils the next promise immediately, so the chain succeeds while save()'s rejection sits unobserved until the host reports it. Option 0 is tempting because await and return do make the chain adopt an inner promise, but that adoption happens only for the value you actually return or await, never for a fire-and-forget call.