Skip to content
aviral gupta

// I1.1 · ~32 min · Intermediate

The event loop and promises: predicting async JavaScript

After this lesson you can predict the order in which synchronous code, promise callbacks, await continuations and timers run, catch async errors where they really happen, and pick Promise.all or Promise.allSettled on purpose.

Lesson 1 of 6 in I1 Asynchronous JavaScript

Start of the module

You will be able to

  • Order console output for code that mixes synchronous calls, microtasks and setTimeout
  • Handle a rejected promise with try/catch by awaiting it in the right place
  • Explain when Promise.all rejects and what Promise.allSettled returns instead
  1. Warm-up · Activity 1 of 7

    Warm-up: plain synchronous code, no async yet. What does this print?

    function a() {
      console.log('a start');
      b();
      console.log('a end');
    }
    function b() {
      console.log('b');
    }
    a();
  2. Predict · Activity 2 of 7

    Predict before you read on. What does this print, in order?

    console.log('A');
    setTimeout(() => console.log('B'), 0);
    Promise.resolve().then(() => console.log('C'));
    console.log('D');
  3. Practice · Activity 3 of 7

    Put the lines this program prints in the order they appear.

    async function run() {
      console.log('run: start');
      await null;
      console.log('run: after await');
    }
    
    setTimeout(() => console.log('timeout'), 0);
    run();
    queueMicrotask(() => console.log('microtask'));
    console.log('script end');
    1. 1.timeout
    2. 2.script end
    3. 3.run: after await
    4. 4.microtask
    5. 5.run: start
  4. Practice · Activity 4 of 7

    Spot the bug. This is a CommonJS script run with Node.js. Why does “caught:” never print, and the process exits with an “Error: boom” after printing “done”?

    async function load() {
      throw new Error('boom');
    }
    
    try {
      load();
    } catch (err) {
      console.log('caught:', err.message);
    }
    console.log('done');
  5. Practice · Activity 5 of 7

    Your page shows a user’s name and their posts. If the posts API is down, you still want the name. Fill in the Promise method so this prints “ok: Ada” and then “failed: posts API down”.

    // ES module, so top-level await is allowed
    const fetchUser = () => Promise.resolve('Ada');
    const fetchPosts = () => Promise.reject(new Error('posts API down'));
    
    const results = await Promise.____([fetchUser(), fetchPosts()]);
    for (const r of results) {
      if (r.status === 'fulfilled') console.log('ok:', r.value);
      else console.log('failed:', r.reason.message);
    }
    await Promise.([fetchUser(), fetchPosts()])
  6. Brain teaser · Activity 6 of 7

    Brain teaser. load() wraps the call in try/catch and returns a fallback on error. What does this print?

    async function getData() {
      throw new Error('offline');
    }
    
    async function load() {
      try {
        return getData();
      } catch (err) {
        return 'fallback';
      }
    }
    
    load().then(
      (value) => console.log('value:', value),
      (err) => console.log('error:', err.message)
    );
  7. Apply · Activity 7 of 7

    Mini-task. Write loadDashboard() in Node.js. The user profile is required: if it fails, throw an error with a clear message. Three widgets (news, weather, stocks) are optional: show whichever succeed and log the ones that fail. Everything should start at once, not one after another. Fake the network with a delay(ms, value, fail) helper built on setTimeout, make one widget fail, and run it.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Every kind of job in one program

The letters show the order in which the lines print. The synchronous lines run first, A to C, including the start of work() up to its await. Then the microtask queue is drained: the then() callback, the rest of work(), and everything the top-level awaits wait for, because a promise that settles without a timer settles in microtasks. The timer’s task runs last, although it was scheduled second.

main.js

console.log('A sync');
setTimeout(() => console.log('H timer (a task)'), 0);
Promise.resolve().then(() => console.log('D then (a microtask)'));

async function work() {
  console.log('B work starts (still sync)');
  await null;
  console.log('E work after await (a microtask)');
}
work();
console.log('C sync, end of the first job');

// Errors: await inside try, and allSettled to keep every outcome.
const fail = (message) => Promise.reject(new Error(message));
async function load() {
  try {
    return await fail('offline');
  } catch (err) {
    return `fallback (${err.message})`;
  }
}
console.log('F load:', await load());
const results = await Promise.allSettled([Promise.resolve('news'), fail('weather down')]);
console.log('G allSettled:', results.map((r) => (r.status === 'fulfilled' ? r.value : r.reason.message)));

Run it with

node main.js

Output

A sync
B work starts (still sync)
C sync, end of the first job
D then (a microtask)
E work after await (a microtask)
F load: fallback (offline)
G allSettled: [ 'news', 'weather down' ]
H timer (a task)
  • A 0 ms timer is not “now”: its callback is a task, and every pending microtask runs before the next task.
  • load() catches the rejection because it awaits inside the try: return await, not return.
  • allSettled kept both outcomes in input order; Promise.all would have rejected with “weather down”.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Exercises

Exercise 1 of 2

Catch the rejection where it happens

safeLoad(load) calls the async function load and should fulfil with its value, or with "fallback" if load rejects. The starter returns load() inside the try without awaiting it, so a rejection slips past the catch. Fix it with one word.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: safeLoad rejects with offline. When does load()’s promise reject: inside the try, or after safeLoad has left it?

  2. Hint 2

    The catch only sees a rejection that is thrown while the try is still running. Which keyword turns a rejection into a throw?

  3. Hint 3

    return await load();

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export async function safeLoad(load) {
  try {
    return await load();
  } catch {
    return "fallback";
  }
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export async function safeLoad(load) {
  try {
    return load();
  } catch {
    return "fallback";
  }
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {safeLoad} from './main.js';

test('A load that works gives its value', async () => {
  const got = await safeLoad(async () => 'data');
  assert.equal(got, 'data', `safeLoad gave ${got}`);
});

test('A load that rejects gives fallback', async () => {
  const got = await safeLoad(async () => {
    throw new Error('offline');
  }).catch((error) => `rejected with ${error.message}`);
  assert.equal(got, 'fallback', `safeLoad gave: ${got}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

Report every outcome

summarize(promises) should wait for every promise and fulfil with {ok, failed}: ok holds the values of those that fulfilled, failed the messages of those that rejected, both in input order. The starter uses Promise.all, so one failure rejects everything. Use the combinator that never rejects.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: summarize rejects with weather down. Which combinator waits for every input and never rejects?

  2. Hint 2

    Promise.allSettled gives one object per input: {status: "fulfilled", value} or {status: "rejected", reason}.

  3. Hint 3

    Loop over the results: push result.value to ok, or result.reason.message to failed.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export async function summarize(promises) {
  const results = await Promise.allSettled(promises);
  const ok = [];
  const failed = [];
  for (const result of results) {
    if (result.status === "fulfilled") ok.push(result.value);
    else failed.push(result.reason.message);
  }
  return {ok, failed};
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

export async function summarize(promises) {
  const values = await Promise.all(promises);
  return {ok: values, failed: []};
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {summarize} from './main.js';

const fail = (message) => Promise.reject(new Error(message));

test('Everything works: every value in ok', async () => {
  const got = await summarize([Promise.resolve('news'), Promise.resolve('stocks')]);
  assert.deepEqual(got, {ok: ['news', 'stocks'], failed: []}, `summarize gave ${JSON.stringify(got)}`);
});

test('Failures are reported, and the rest is kept', async () => {
  const got = await summarize([Promise.resolve('news'), fail('weather down'), fail('stocks down')]).catch((error) => `rejected with ${error.message}`);
  assert.deepEqual(got, {ok: ['news'], failed: ['weather down', 'stocks down']}, `summarize gave ${JSON.stringify(got)}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Expecting a 0 ms timer to run before the next line

let data;
setTimeout(() => {
  data = {items: [1, 2]};
}, 0);
console.log(data.items.length);

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'items')

Why, and the fix

A timer callback is a task: it runs only after the current script has finished, even with a 0 ms delay. When the last line runs, data is still undefined. Use the data inside the callback, or wrap the timer in a promise and await it before reading data.

try/catch around an async call without await

async function load() {
  throw new Error("boom");
}
try {
  load();
} catch (err) {
  console.log("caught:", err.message);
}
console.log("done");

What Node.js prints

Error: boom

Why, and the fix

load() does not throw at the call: it returns a rejected promise, the try ends normally, “done” prints, and the rejection is never handled, so the program stops with it. Await it inside the try, in an async function or a module: try { await load(); } catch (err) { … }.

Promise.all with a failing input and no catch

const results = await Promise.all([Promise.resolve(1), Promise.reject(new Error("x failed"))]);
console.log(results);

What Node.js prints

Error: x failed

Why, and the fix

Promise.all rejects with the first rejection, and an await of a rejected promise throws. Nothing catches it here, so the program stops before console.log. Wrap the await in try/catch, or use Promise.allSettled when every outcome matters.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

One job at a time, run to completion

JavaScript runs one job at a time on the call stack. MDN’s execution model calls this run-to-completion: once a function starts, nothing else runs until the job finishes. Asynchronous work never interrupts your code. Its result arrives later as a callback, such as a then() handler or a setTimeout() function, queued as a new job.

Microtasks jump the queue

Jobs come in two kinds. Promise callbacks, queueMicrotask() callbacks and the code after an await are microtasks. setTimeout() callbacks are tasks. When the current job ends, the whole microtask queue is drained, including microtasks added along the way, before the next task runs. That is why a resolved promise’s then() handler beats setTimeout(…, 0), and why code after await never runs synchronously, even when you await a plain value like 42.

Errors travel with the promise

An async function always returns a promise; if it throws, that promise rejects. A try/catch only sees the rejection if you await the promise inside the try, and that includes return await. Promise.all rejects with the first rejection reason and does not cancel the other operations. Promise.allSettled never rejects: it fulfils with one {status, value} or {status, reason} object per input, in input order.

Sources

Last reviewed September 28, 2026