skip to content

In a JavaScript async function, how does wrapping an `await` in try/catch let you handle a rejected promise, and what exactly does `await` do when the promise it is waiting on rejects?

level: juniorimportance: must knowfreq 78%

answer

  1. rejection becomes a normal exception
  2. only at the await point
  3. catch binding is the raw reason
  4. async functions never throw synchronously

basics

~20 s

await rethrows a rejected promise's reason as an ordinary exception at the await point, so a normal try/catch around that await catches it. The catch parameter is bound to whatever value the promise rejected with, which need not be an Error.

solid answer

~50 s

`await` has two outcomes. If the awaited promise fulfils, the expression evaluates to the fulfilment value; if it rejects, `await` throws the rejection reason right there, exactly as if you had written `throw reason` on that line. Because the failure becomes a real exception at a known point in the source, ordinary `try`/`catch`/`finally` works again — that is the main ergonomic win of async/await over `.then` chains, where the continuation runs on a later, empty stack that no surrounding `try` can guard. Two details I would call out: the catch binding holds the raw rejection reason, so it can be a string, `undefined`, or a response object rather than an `Error`, and only the awaits lexically inside the `try` are protected. An async callee that throws synchronously before its first await is no different — that throw is captured and turned into a rejection, so the caller still sees it at the `await`.

code

javascript · 16 lines
javascript
function fetchUser(id) {
  return new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`no user ${id}`)), 10));
}

async function load(id) {
  try {
    const user = await fetchUser(id);
    return user.name;
  } catch (err) {
    console.log('caught:', err.message); // caught: no user 7
    return null;
  }
}

load(7).then(v => console.log('resolved with', v)); // resolved with null

go deeper

for a junior

Be ready to say plainly that await turns a rejected promise into a thrown error at that line, so a normal try/catch around the await handles it. Show the shape in code without hesitating.

for a middle

Explain the two resumption paths of await and that the catch binding is the raw rejection reason of any type. Point out that a throw before the callee's first await still reaches you as a rejection.

for a senior

Demonstrate judgment about handler scope: which awaits a try actually protects, why swallowing an error silently converts failure into a fulfilled promise, and how you normalise unknown rejection reasons at a module boundary.

for a principal

Own the error-channel contract across a codebase: whether async APIs may reject with non-Error values, where reasons get normalised and wrapped, and how that choice affects logging, alerting, and every caller's handler.

## The problem `await` solves Before async/await, asynchronous failures were structurally uncatchable by `try`/`catch`. A callback or a `.then` continuation runs on a later turn of the event loop, on a fresh call stack. By then the `try` block that started the work has already exited, so there is no live handler frame for an exception to unwind into. Wrapping `fetchUser().then(handle)` in a `try` catches only errors thrown while *starting* the work, never the failure the work reports later. ## What `await` actually does `await expr` suspends the async function until `expr` settles, then resumes it in one of exactly two ways: - The promise **fulfils** → the `await` expression evaluates to the fulfilment value and execution continues on the next line. - The promise **rejects** → the `await` expression **throws** the rejection reason at that point, as if `throw reason` appeared on that line. That second rule is the whole mechanism. Rejection is converted back into an ordinary exception at a specific, visible point in the source, so the language's ordinary exception machinery applies again. ```js async function load(id) { try { const user = await fetchUser(id); // rejection throws here return user.name; } catch (err) { console.error('load failed', err); return null; } } ``` The function returns a promise either way: it fulfils with `user.name` on the happy path, and fulfils with `null` on the failure path, because the `catch` handled the error and returned a value. Handling an error inside an async function turns a rejection into a fulfilment unless you rethrow. ## The catch binding holds a reason, not necessarily an Error `throw` accepts any value in JavaScript, and so does `Promise.reject`. The catch parameter is bound to whatever the promise rejected with. A handler that immediately reads `err.message` silently produces `undefined` for a string rejection and throws a `TypeError` for a `null` one. Robust handlers narrow first: ```js catch (err) { const message = err instanceof Error ? err.message : String(err); logger.warn(message); } ``` This matters more with async code than with synchronous code, because rejection reasons often cross a library boundary you do not control. ## Only the awaits inside the block are protected A `try` block guards the awaits that are lexically inside it, and nothing else. If a promise is created inside the `try` but awaited after it, the rejection surfaces at the later `await`, outside the handler. If a promise is created and never awaited, nothing ever throws into that block at all, because the call that produced it returned normally. The practical rule is that the `try` should bracket the `await`, not merely the call that starts the work. ## A synchronous throw inside the callee still arrives as a rejection An async function never throws synchronously to its caller. If `fetchUser` validates its argument and throws on its very first line, before any `await`, the thrown value does not propagate up the caller's stack — it is captured and used to reject the promise the call returned. From the caller's side, an argument-validation bug and a network failure surface identically, at the `await`: ```js async function fetchUser(id) { if (!id) throw new TypeError('id required'); // becomes a rejection return api.get(`/users/${id}`); } ``` That uniformity is deliberate: callers of an async function need exactly one error channel, the returned promise, rather than two. ## Where the catch clause actually runs When an async function suspends at `await`, its remaining body is registered as a continuation on the awaited promise and resumed as a promise job. So the `catch` clause executes on a later turn, on a call stack that no longer contains the caller that invoked the async function. Two consequences are worth naming in an interview. First, the resumed code still sees the same variables — resumption restores the function's environment, so closures and locals are intact. Second, the caught error's stack trace was captured where the error was *created*, which may be deep inside a library; the frames that were live where you wrote the `await` are only visible if the engine records async stack traces, which V8 does for awaited frames but not for every promise shape. ## try/catch versus a `.catch` handler Both catch the same failures. `try`/`catch` protects a region of code and reads sequentially, which is why it dominates in async functions; `.catch` attaches to a single promise and is the only option outside an async function. What you should not conclude is that `try`/`catch` gained new powers — it did not. `await` did the work by moving the failure back onto the throwing path.

  • If the catch block handles the error and returns a value, what does the async function's returned promise do?
    It fulfils with that value. Catching an error inside an async function consumes it, so the promise the function returned resolves normally — callers see success, not failure. If the caller must know the operation failed, the catch has to rethrow (or return a rejected promise) rather than return a fallback.
  • Why can't a try/catch placed around a plain `.then` chain catch the asynchronous failure?
    Because only the synchronous part runs inside the `try`: creating the promise and registering the callback. The callback runs later on a fresh stack, after the `try` block has exited, so there is no live handler for it to unwind into. `await` fixes this by throwing the reason inside the still-live function body when it resumes.
  • What happens if the rejection reason is not an Error object?
    The catch binding is that value verbatim — a string, a number, `undefined`, an object. Nothing normalises it. So `err.message` is `undefined` for a string rejection and reading properties of a `null` rejection throws a TypeError inside your handler. Guard with `err instanceof Error` before assuming Error shape.

saying these in an interview costs you the question

  • Says try/catch works because async/await makes code synchronous
  • Assumes the caught value is always an Error instance
  • Thinks an async function can throw synchronously to its caller
  • Believes try/catch around a .then chain catches the rejection
  • Claims await blocks the thread while waiting

context