JavaScript Promises and async/await Explained: Error Handling, Execution Order, and Output Questions

Build a precise call-stack and queue model, use it to predict six output lines, and see why one missing await can move a rejection beyond try/catch.

KnowledgeGate Team

Exam prep & CS education

Updated 15 Aug 20266 min read

Many learners can write asynchronous JavaScript that runs, but freeze when asked what it prints and in which order. The syntax is not the real gap. You need a model for when synchronous code runs, when a promise continuation becomes eligible, and where try/catch can actually see a rejection.

That model has three moving parts: the call stack that runs your statements now, the microtask queue that promise continuations join, and the macrotask queue that timers join. Once their order is fixed in your head, an output question becomes bookkeeping rather than guesswork.

Promise states and the four building blocks

A Promise is in exactly one of three states. It starts pending, then settles as either fulfilled with a value or rejected with a reason. Settlement happens once. A fulfilled promise cannot later reject, and a rejected promise cannot later fulfil.

Most promise code uses four tools:

  • .then(onFulfilled) schedules work for a fulfilled value.

  • .catch(onRejected) handles a rejection from the earlier chain.

  • .finally(cleanup) runs after settlement, regardless of the outcome, and is useful for cleanup that does not replace the value.

  • async and await provide syntax over promises that makes sequential code easier to read.

An async function always returns a promise. Returning an ordinary value fulfils that promise with the value. Throwing rejects it.

At await p, JavaScript pauses that async function, not the whole program. If p fulfils, the await expression produces its value. If p rejects, await throws the rejection reason at that line. This is why a surrounding try/catch can catch it.

The event-loop model that decides output order

JavaScript executes one call stack. Ordinary statements and the synchronous part of an async function run on that stack immediately.

When the stack becomes empty, the runtime drains the microtask queue. Promise .then callbacks and the continuation after an await are microtasks. After all current microtasks finish, the event loop can take a macrotask such as a setTimeout callback. It then drains microtasks again before taking another macrotask.

The load-bearing rule is:

Synchronous code runs first. Then all queued microtasks run. Only then does the next macrotask run.

Therefore a resolved promise callback runs before setTimeout(fn, 0). A zero-millisecond delay means the timer becomes eligible as soon as scheduling permits, not that it interrupts the current stack or jumps ahead of microtasks.

Fully worked JavaScript output questions

Trace this exact program:

console.log('1: start');
setTimeout(() => console.log('2: timeout'), 0);
Promise.resolve().then(() => console.log('3: promise'));
(async () => {
  console.log('4: async start');
  await null;
  console.log('5: after await');
})();
console.log('6: end');

Start with the synchronous pass.

  1. The first console.log runs now and prints 1: start.

  2. setTimeout registers its callback as a future macrotask. It prints nothing yet.

  3. Promise.resolve() is already fulfilled, so .then(...) queues microtask M1. It also prints nothing yet.

  4. The async immediately invoked function starts synchronously. It prints 4: async start.

  5. await null treats null like an already fulfilled value, suspends the async function, and queues its continuation as microtask M2.

  6. Control returns to the outer script, which prints 6: end.

The call stack is now empty. The microtask queue contains [M1, M2] in that order. M1 runs and prints 3: promise. M2 runs next and prints 5: after await. Only after both microtasks finish can the timeout macrotask run, printing 2: timeout.

The final output is:

1: start
4: async start
6: end
3: promise
5: after await
2: timeout
Diagram tracing the six prints across Call Stack, Microtask Queue, and Macrotask Queue, with microtasks draining before the timeout.

Check the order by groups: three synchronous prints, then two microtask prints, then one macrotask print. Within each group, registration order decides the sequence in this example.

Second output question: a .then chain against repeated await

Each link in a .then chain costs one microtask tick, and so does each await. When two flows are in the air at once they interleave tick by tick, rather than one finishing before the other starts.

Promise.resolve()
  .then(() => console.log('A'))
  .then(() => console.log('B'));

(async () => {
  console.log('C');
  await Promise.resolve();
  console.log('D');
  await Promise.resolve();
  console.log('E');
})();

console.log('F');

The synchronous pass prints C and then F, and it leaves exactly two microtasks queued: the A callback, registered first, and the async function's continuation after its first await. B is not queued yet, because the promise its .then is attached to has not settled.

Tick one prints A and settles that promise, which queues B at the back of the queue. Tick two prints D and reaches the second await, queueing E behind B. Tick three prints B. Tick four prints E.

C
F
A
D
B
E

Count ticks rather than chains and the interleaving stops being surprising. Two awaits in one function cost two ticks, exactly as two chained .then callbacks do.

Promise error handling done right

Awaiting a rejected promise turns the rejection into a thrown value at the await point:

async function load() {
  try {
    const value = await getValue();
    return value;
  } catch (error) {
    console.error(error);
  }
}

In a promise chain, a final .catch(...) handles a rejection created earlier by the original promise or by a preceding callback:

getValue()
  .then(transform)
  .then(save)
  .catch(handleError);

The classic trap is returning a rejected promise without awaiting it:

async function ok() {
  try { return await Promise.reject(new Error('boom')); }
  catch (e) { console.log('caught', e.message); } // prints: caught boom
}

async function broken() {
  try { return Promise.reject(new Error('boom')); } // no await
  catch (e) { console.log('never runs'); } // rejection escapes
}

In ok, await makes the rejection throw while execution is still inside the try, so the catch runs and prints caught boom. In broken, the function returns the rejected promise. Nothing throws synchronously inside that try, so its catch cannot run. The caller must await or catch the promise returned by broken.

This is why return await can matter inside a local try/catch, even though it is often unnecessary in a simple async return.

Sequential awaits versus parallel promises

Awaiting inside a loop serialises work:

for (const url of urls) {
  results.push(await fetchOne(url));
}

That may be correct when each operation depends on the previous result. When three independent operations each take roughly 200 ms, the idealised sequential total is about 200 + 200 + 200 = 600 ms.

Start independent work together and await it once:

const results = await Promise.all(urls.map(fetchOne));

The idealised total is then about the slowest single operation, around 200 ms, rather than their sum. Real timings include overhead and variation, but the dependency structure is the important part.

Choose the combinator according to failure behaviour:

  • Promise.all is for results that must all succeed. It rejects as soon as one input rejects.

  • Promise.allSettled waits for every input and reports each fulfilled or rejected outcome.

  • Promise.race settles with the first input to settle, whether that first outcome is fulfilment or rejection.

Async JavaScript traps interviewers exploit

These mistakes all become predictable with the same model:

  • Forgetting await may log a promise object instead of its fulfilled value, and can move a rejection outside the intended try/catch.

  • Awaiting independent work inside a loop creates accidental serial execution.

  • Assuming setTimeout(fn, 0) beats a resolved promise ignores microtask priority.

  • Starting a promise branch without .catch or an awaiting caller can create an unhandled rejection.

  • Mixing .then and await on the same flow is legal, but often makes ownership and ordering harder to trace.

For more output-style practice, use JavaScript Interview Questions for Freshers 2026. Node.js Interview Questions for Freshers extends the same promise rules into server-side event-loop questions.

The short version and next step

Run synchronous lines first. When the stack empties, drain promise and await microtasks before taking the next timeout macrotask. A rejected promise becomes catchable at an await, so keep that await inside the intended try. Start independent work together and choose all, allSettled, or race by the result contract.

KnowledgeGate's MERN-stack question bank carries about 600 practice questions. Go deeper with Complete JavaScript, follow the broader MERN Stack + DSA, or browse the wider Coding & DSA catalogue.