The previous lesson ended with a precise diagnosis: callbacks work, but they do not return anything. Since the future result is not a value, it cannot be stored in a variable, passed to a function, combined or chained without nesting, and errors stop propagating by themselves the moment you cross the asynchronous boundary. The language's solution was to make that future result be a value after all: an object called a promise that represents "something that is not here yet, but will be". In this lesson you will learn to create them, consume them and chain them; you will see how the pyramid from 05-05 flattens into a list of steps; you will meet the four combinators that solve in one line what used to demand manual counters; and you will arrive at async/await, the syntax that makes asynchronous code read exactly like synchronous code, with its try/catch finally working.

Contents

  1. What a promise is: the three states
  2. Creating a promise with new Promise
  3. Promisifying readSimulatedBacklog
  4. Consuming: .then, .catch, .finally
  5. Chaining: how the pyramid flattens
  6. How errors propagate along the chain
  7. Promise.resolve and Promise.reject
  8. The four combinators
  9. async and await
  10. try/catch that finally works
  11. await in loops versus Promise.all
  12. for await...of and module-level await
  13. The traps of async/await
  14. Putting it together: loadBoard()
  15. The comparison table of the three styles
  16. Common Mistakes and Tips
  17. Exercises
  18. Conclusion

  1. What a promise is: the three states

A promise (Promise) is an object representing the result of an asynchronous operation. It exists from the instant the operation begins, long before there is any result, and that is why it can be stored, passed and combined like any other value.

At any moment it is in one of three states:

State Meaning How you get there
Pending The operation is still under way The initial state of every promise
Fulfilled It finished well and has a value resolve(value) was called
Rejected It finished badly and has a reason reject(error) was called, or an exception was thrown
stateDiagram-v2
    [*] --> Pending
    Pending --> Fulfilled: resolve(value)
    Pending --> Rejected: reject(error)
    Fulfilled --> [*]: .then(onFulfilled)
    Rejected --> [*]: .catch(onRejected)

Two properties define its entire behavior:

  • The transition is single and irreversible. A promise goes from pending to fulfilled or to rejected, once only, and there it stays. Once decided, it is said to be settled. This wipes out the double-call problem from 05-05 at a stroke: however many times the code calls resolve, only the first one counts.
  • The result is kept. If you subscribe to a promise that is already fulfilled, you receive the value all the same. There is no need to "arrive in time", unlike with an event.

A vocabulary distinction that avoids confusion: a rejected promise is a normal, expected outcome (the server returned a 503), whereas an unhandled error is a program failure. Both use Error objects, but conceptually they are different.

  1. Creating a promise with new Promise

The constructor takes a function —called the executor— with two parameters: resolve and reject.

'use strict';

const promise = new Promise((resolve, reject) => {
  // This body runs IMMEDIATELY, synchronously
  setTimeout(() => {
    const success = true;
    if (success) resolve('backlog loaded');     // → fulfilled, with this value
    else reject(new Error('503'));              // → rejected, with this reason
  }, 400);
});

console.log(promise);      // Promise { <pending> }   ← it already exists, with no result yet

Key points about the executor:

  • It runs immediately, at the moment the promise is created. What is asynchronous is when resolve/reject are called, not when the work starts.
  • resolve and reject are ordinary functions; their names are a convention, not a requirement.
  • Only the first call counts. Later ones are ignored silently.
  • reject should receive an Error, not a string. Only that way do you keep the message, the name and the trace from 03-05.
  • An exception thrown inside the executor rejects the promise automatically. It is the first sign that error propagation works again.
new Promise(() => {
  throw new Error('failure in the executor');
}).catch((e) => console.error('caught:', e.message));   // caught: failure in the executor

Practical rule: new Promise is only used to wrap something that does not speak promises yet —a setTimeout, an old callback API. If you already have a promise, do not wrap it in another one: it is such a common antipattern that it has a name, the explicit promise constructor.

  1. Promisifying readSimulatedBacklog

Turning a callback function into one that returns a promise is called promisifying, and it is the hinge between the two worlds.

// js/data/simulatedBacklog.js
import { Task } from '../model/task.js';
import { DataError } from '../model/errors.js';
import { backlogData } from './backlog.js';

/**
 * Simulates loading the backlog from a server and returns a promise.
 * In Module 7 this will be a real call with fetch (07-02).
 *
 * @param {Object}  [options]
 * @param {number}  [options.latency=400]
 * @param {boolean} [options.fail=false]
 * @returns {Promise<Task[]>}
 */
export function readBacklog({ latency = 400, fail = false } = {}) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (fail) {
        reject(new DataError('The Taller Nómada server is not responding (503).'));
        return;
      }
      try {
        resolve(backlogData.map((data) => new Task(data)));
      } catch (error) {
        reject(new DataError('The backlog received is not valid.', error));
      }
    }, latency);
  });
}

Compare it with the callback version from 05-05. The essential difference is on the first line of the body: there is a return. The function returns something, and that something is a first-class value:

const promise = readBacklog();               // it can be stored in a variable
const list = [readBacklog(), readBacklog()]; // put in an array
someFunction(readBacklog());                 // passed as an argument

None of those three lines was possible with callbacks. And there is also a generic recipe, useful when you are promisifying many error-first functions:

/** Turns an error-first function into one that returns promises. */
function promisify(callbackFunction) {
  return (...args) =>
    new Promise((resolve, reject) => {
      callbackFunction(...args, (error, result) => {
        if (error) reject(error);
        else resolve(result);
      });
    });
}

const readBacklogP = promisify(readSimulatedBacklog);

It is the same idea as the higher-order functions from 03-06: a function that takes a function and returns another function.

  1. Consuming: .then, .catch, .finally

A promise is consumed by registering what to do when it settles.

'use strict';

import { readBacklog } from './data/simulatedBacklog.js';
import { Board } from './model/board.js';
import { TODAY } from './util/dates.js';

console.log('⏳ Loading…');

readBacklog()
  .then((tasks) => {
    const board = new Board('Taller Nómada', tasks);
    console.log('✓', board.summary(TODAY));
    // { total: 6, open: 5, totalHours: 48, openHours: 45, overdue: 1, effort: 124 }
  })
  .catch((error) => {
    console.error('✗', error.message);
  })
  .finally(() => {
    console.log('Load finished (successfully or not)');
  });

console.log('The application keeps responding');
Method When its callback runs What it receives
.then(onFulfilled) The promise is fulfilled The value from resolve
.then(onFulfilled, onRejected) Both cases, each its own Value or reason
.catch(onRejected) The promise is rejected The reason from reject
.finally(onSettled) In either case Nothing: neither value nor error

.finally is the equivalent of the finally from 02-05 and serves the same purpose: the cleanup that has to happen whatever occurs —hiding a loading indicator, closing a connection, re-enabling a button. It receives no arguments precisely because it should not depend on the result, and it does not consume the value: it lets it pass through to the following .thens.

Although .then(onFulfilled, onRejected) exists, the recommended form is .then(...).catch(...), and there is a technical reason: with two arguments, the error handler does not catch failures in onFulfilled itself, because both are registered on the same promise.

// ✗ If onFulfilled throws, this handler does NOT find out
promise.then(onFulfilled, onRejected);

// ✓ The catch is further down the chain: it also catches whatever onFulfilled throws
promise.then(onFulfilled).catch(onRejected);

  1. Chaining: how the pyramid flattens

Here is the property that changes everything:

.then() returns a new promise, fulfilled with whatever its callback returns.

That lets you put one .then after another instead of nesting them. And there is an additional rule that completes the magic: if the callback returns a promise, the chain waits for that one to settle and continues with its value, instead of passing the promise itself along.

readBacklog()
  .then((tasks) => new Board('Taller Nómada', tasks))   // returns a normal value
  .then((board) => board.hoursByAssignee())              // receives the board
  .then((workload) => readTeamHours(Object.keys(workload)))   // returns a PROMISE → it is awaited
  .then((contracted) => console.log(contracted));        // receives its value, not the promise

With that tool, the pyramid from section 9 of 05-05 turns into a list. First we promisify the other two simulated modules:

// js/data/simulatedTeam.js
import { DataError } from '../model/errors.js';

const CONTRACTS = { 'Iván': 30, 'Marta': 20, 'Lucía': 35 };

export function readTeamHours(names, latency = 300) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const unknown = names.find((n) => !Object.hasOwn(CONTRACTS, n));
      if (unknown !== undefined) {
        reject(new DataError(`${unknown} is not on the Taller Nómada team.`));
        return;
      }
      resolve(Object.fromEntries(names.map((n) => [n, CONTRACTS[n]])));
    }, latency);
  });
}

export function saveReport(report, latency = 200) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (report.lines.length === 0) {
        reject(new DataError('An empty report is not saved.'));
        return;
      }
      resolve({ saved: true, id: `rep-${Date.now()}`, lines: report.lines.length });
    }, latency);
  });
}

And now Marta's weekly report, the same one from 05-05, without a single level of nesting:

import { readBacklog } from './data/simulatedBacklog.js';
import { readTeamHours, saveReport } from './data/simulatedTeam.js';
import { Board } from './model/board.js';
import { TODAY } from './util/dates.js';

let workload = null;                  // (see the note afterwards)

console.log('⏳ Generating the weekly report…');

readBacklog()
  .then((tasks) => {
    const board = new Board('Taller Nómada', tasks);
    workload = board.hoursByAssignee();
    return readTeamHours(Object.keys(workload));
  })
  .then((contracted) => {
    const lines = Object.keys(workload).map((name) => ({
      name,
      assigned: workload[name],
      contracted: contracted[name],
      overloaded: workload[name] > contracted[name]
    }));
    return saveReport({ date: TODAY, lines });
  })
  .then((receipt) => {
    console.log(`✓ Report ${receipt.id} saved with ${receipt.lines} lines`);
  })
  .catch((error) => {
    console.error(`✗ ${error.message}`);      // ONE single handler for the three steps
  });

Three improvements over the callback version, and none of them cosmetic:

  1. The indentation is flat. The steps read top to bottom, in the order they happen.
  2. There is a single .catch. If the load, the team lookup or the save fails, the error ends up on the same line. The three repeated if (error) { … return; } blocks are gone.
  3. The flow is explicit. Each return says what is passed to the next step.

One honest flaw remains: that workload variable outside the chain, needed because the third .then needs data computed in the first. It is exactly the same shared-state problem that appeared when "flattening with named functions" in 05-05, and promises on their own do not fully solve it. Hold on to that discomfort: async/await makes it disappear, because there the variables are simply local variables.

  1. How errors propagate along the chain

When a step fails, the chain skips all the following .thens until the first .catch. It is the behavior of the synchronous stack from 02-05, recovered.

'use strict';

readBacklog({ fail: true })
  .then((tasks) => { console.log('step 1'); return new Board('X', tasks); })   // ← skipped
  .then((board) => { console.log('step 2'); return board.summary(); })         // ← skipped
  .catch((error) => console.error('✗ caught:', error.message));

// ✗ caught: The Taller Nómada server is not responding (503).

An error inside a .then causes the same thing, even if it is not an asynchronous operation:

readBacklog()
  .then((tasks) => {
    return tasks[99].title;               // ✗ TypeError: task 99 does not exist
  })
  .then((title) => console.log(title))    // skipped
  .catch((error) => console.error('✗', error.name, error.message));
// ✗ TypeError Cannot read properties of undefined (reading 'title')

And —this is key— the chain can recover after a .catch, because .catch also returns a promise:

readBacklog({ fail: true })
  .catch((error) => {
    console.warn(`⚠ ${error.message}. Using local data.`);
    return createBacklog();                       // fallback value
  })
  .then((tasks) => {
    console.log(`Working with ${tasks.length} tasks`);   // Working with 6 tasks
  })
  .catch((error) => console.error('✗ unrecoverable:', error.message));

From that comes an important placement rule: a .catch catches what happens above it in the chain, not below it. If you put one in the middle and want it to protect the following steps too, you need another one at the end. The usual practice is a single .catch right at the end.

With custom errors, the pattern combines with the hierarchy from 05-02:

readBacklog()
  .then((tasks) => new Board('Taller Nómada', tasks))
  .then((board) => board.changeStatus(6, 'done'))   // ✗ R6: forbidden transition
  .catch((error) => {
    if (error instanceof ValidationError) console.error(`Rule broken: ${error.message}`);
    else if (error instanceof DataError) console.error(`Data: ${error.message}`);
    else throw error;                                // fail-fast (02-05)
  });

  1. Promise.resolve and Promise.reject

Two shortcuts for creating already-settled promises.

const alreadyFulfilled = Promise.resolve(42);
const alreadyRejected = Promise.reject(new Error('no data'));

alreadyFulfilled.then((v) => console.log(v));              // 42
alreadyRejected.catch((e) => console.error(e.message));    // no data

Their most valuable use is normalizing: making a function always return a promise, whether or not it actually has work to do.

let backlogCache = null;

/** Returns the backlog, from the server the first time and from the cache afterwards. */
function getBacklog() {
  if (backlogCache !== null) {
    return Promise.resolve(backlogCache);       // ← same "shape" as the long route
  }
  return readBacklog().then((tasks) => {
    backlogCache = tasks;
    return tasks;
  });
}

getBacklog().then((t) => console.log(t.length));   // 6 · after 400 ms
getBacklog().then((t) => console.log(t.length));   // 6 · almost instant

Without that Promise.resolve, the function would return an array sometimes and a promise other times, and whoever used it would have to check. It is the memoization from 03-06, now applied to asynchronous operations.

Promise.resolve also has a very handy property: if you pass it a promise, it returns it as is, without wrapping it again.

  1. The four combinators

Here is the answer to "problem 4" from 05-05: combining asynchronous operations. Four static methods, each with different semantics.

'use strict';

/** Open tasks of one person, with latency proportional to their workload. */
function readTasksOf(assignee, latency = 300) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const theirs = backlogData.filter((t) => t.assignee === assignee && t.status !== 'done');
      if (theirs.length === 0) {
        reject(new DataError(`${assignee} has no open tasks.`));
        return;
      }
      resolve({ assignee, tasks: theirs.length, hours: theirs.reduce((s, t) => s + t.estimatedHours, 0) });
    }, latency);
  });
}

Promise.all — waits for all of them to fulfil. If one fails, the whole thing fails immediately.

Promise.all([readTasksOf('Iván', 300), readTasksOf('Marta', 500), readTasksOf('Lucía', 200)])
  .then((results) => {
    for (const r of results) console.log(`${r.assignee}: ${r.tasks} tasks, ${r.hours} h`);
    console.log('Total open:', results.reduce((s, r) => s + r.hours, 0));
  })
  .catch((error) => console.error('✗', error.message));

// Iván: 3 tasks, 25 h
// Marta: 1 tasks, 6 h
// Lucía: 1 tasks, 14 h
// Total open: 45

Two things to internalize. The order of the results array is the input order, not the arrival order: even though Lucía answers first, her result stays in third position. And the three requests run at the same time: the total takes 500 ms —as long as the slowest— not 1000 ms.

Promise.allSettled — waits for all of them to settle, fulfilled or rejected, and never fails.

Promise.allSettled([readTasksOf('Iván'), readTasksOf('Nobody'), readTasksOf('Lucía')])
  .then((results) => {
    for (const r of results) {
      if (r.status === 'fulfilled') console.log(`✓ ${r.value.assignee}: ${r.value.hours} h`);
      else console.warn(`⚠ ${r.reason.message}`);
    }
  });

// ✓ Iván: 25 h
// ⚠ Nobody has no open tasks.
// ✓ Lucía: 14 h

Each element is an object { status: 'fulfilled', value } or { status: 'rejected', reason }. It is what you want when partial failure is acceptable: you would rather have a report about two people than no report at all.

Promise.race — settles with the first one to finish, fulfilled or rejected. Its canonical use is imposing a maximum time:

function withTimeout(promise, ms) {
  const clock = new Promise((_, reject) =>
    setTimeout(() => reject(new DataError(`Timed out after ${ms} ms.`)), ms));
  return Promise.race([promise, clock]);
}

withTimeout(readBacklog({ latency: 3000 }), 1000)
  .then((tasks) => console.log(tasks.length))
  .catch((error) => console.error('✗', error.message));
// ✗ Timed out after 1000 ms.

An honest warning: the internal setTimeout does not cancel the slow operation, which carries on running even though its result is ignored. Real cancellation needs AbortController, which arrives in 07-03.

Promise.any — fulfils with the first one to fulfil, ignoring rejections. It only fails if all of them fail, with an AggregateError.

Promise.any([readBacklog({ latency: 800 }), readBacklog({ latency: 300, fail: true }), readBacklog({ latency: 500 })])
  .then((tasks) => console.log(`✓ Received ${tasks.length} tasks from the first server that answered`))
  .catch((error) => console.error('✗ All failed:', error.errors.length));
// ✓ Received 6 tasks from the first server that answered   (the 500 ms one)

The decision table:

Combinator Fulfils when… Rejects when… Use it for
Promise.all All fulfil Any fails (instantly) You need all the data to continue
Promise.allSettled All settle Never Partial failure is tolerable; you want the complete report
Promise.race The first fulfils The first fails Timeouts, "whoever answers first"
Promise.any The first one to fulfil All fail Several equivalent sources; any one will do

All four accept any iterable and return a promise. Mentally compare these four lines with the twenty lines of counters and flags from section 10 of 05-05: that is what it means for the future result to be a value.

  1. async and await

Promises fixed the flow, but the code is still full of .then((x) => …). Since ES2017 there has been a syntax that removes even that.

async in front of a function makes it always return a promise.

'use strict';

async function greet() {
  return 'hello';
}

console.log(greet());                      // Promise { 'hello' }   ← not the string
greet().then((v) => console.log(v));       // 'hello'
What the async function does The promise it returns
return value Fulfils with value
return aPromise Fulfils (or rejects) with whatever that promise gives
throw error Rejects with that error
Returns nothing Fulfils with undefined

await in front of a promise waits for its result and returns it as if it were an ordinary value. It can only be used inside an async function (or at the top level of a module, section 12).

async function load() {
  console.log('⏳ Loading…');
  const tasks = await readBacklog();        // "waits" without blocking the thread
  console.log(`✓ ${tasks.length} tasks`);
  return tasks;
}

That this await blocks nothing is the essential point and the hardest to believe at first. What it does is suspend this function at that point and hand control back to the program; when the promise settles, the function resumes exactly where it left off. The thread, meanwhile, is free for everything else. The exact mechanism that allows it is the subject of 05-07.

The same chain from section 5, rewritten:

async function generateWeeklyReport() {
  const tasks = await readBacklog();
  const board = new Board('Taller Nómada', tasks);
  const workload = board.hoursByAssignee();                  // ← an ordinary local variable

  const contracted = await readTeamHours(Object.keys(workload));

  const lines = Object.keys(workload).map((name) => ({
    name,
    assigned: workload[name],
    contracted: contracted[name],
    overloaded: workload[name] > contracted[name]
  }));

  const receipt = await saveReport({ date: TODAY, lines });
  console.log(`✓ Report ${receipt.id} saved with ${receipt.lines} lines`);
  return lines;
}

Compare it with the original pyramid from 05-05. It is the same program: three chained asynchronous operations, each depending on the previous one. And it reads exactly as it would if everything were synchronous. The workload variable has gone back to being a local variable, with no need to pull it outside. There are no callbacks, no .then, no indentation.

A syntax note: async works with any function form, including the arrows from 03-02 and the class methods from 05-02.

const load = async () => { … };                    // arrow
class Board { async reload() { … } }               // method
[1, 2].map(async (n) => { … });                    // callback (careful: it returns promises)

  1. try/catch that finally works

In 02-05 you learned try/catch, and in 05-05 you discovered it was useless for asynchronous code. With await, it is useful again.

'use strict';

async function loadWithWarning() {
  try {
    const tasks = await readBacklog({ fail: true });
    return new Board('Taller Nómada', tasks);
  } catch (error) {
    if (error instanceof DataError) {
      console.warn(`⚠ ${error.message} Using the local backlog.`);
      return new Board('Taller Nómada (local)', createBacklog());
    }
    throw error;                              // what I cannot handle, let it bubble up
  } finally {
    console.log('Load attempt finished');
  }
}

const board = await loadWithWarning();
console.log(board.summary(TODAY));
// ⚠ The Taller Nómada server is not responding (503). Using the local backlog.
// Load attempt finished
// { total: 6, open: 5, totalHours: 48, openHours: 45, overdue: 1, effort: 124 }

The reason it now works is precise: await turns a rejection into an exception thrown on that very line. Since the throw happens inside the try, on the same stack, the catch catches it just as it would catch a TypeError. All the machinery from 02-05 is available again: finally, instanceof to tell types apart, rethrowing what you cannot handle, the ValidationError/DataError hierarchy from 05-02.

And there is a symmetry worth keeping in mind:

In the code Effect on the returned promise
throw error inside an async function The promise rejects
await rejectedPromise An exception is thrown on that line

They are the same door in both directions, and that is why errors cross the boundary between async functions and promise chains without any trouble.

You can also mix styles when it suits. A useful pattern is catching just one specific await without wrapping half the function:

const tasks = await readBacklog().catch(() => createBacklog());   // fallback if it fails

  1. await in loops versus Promise.all

This is the most common performance mistake in all of async/await, and it deserves a section of its own.

'use strict';

// ✗ SLOW: every iteration waits for the previous one
async function loadAllSequential() {
  const start = Date.now();
  const results = [];
  for (const name of ['Iván', 'Marta', 'Lucía']) {
    results.push(await readTasksOf(name, 300));         // 300 + 300 + 300
  }
  console.log(`Sequential: ${Date.now() - start} ms`);  // ≈ 900 ms
  return results;
}

// ✓ FAST: all three start at once
async function loadAllParallel() {
  const start = Date.now();
  const promises = ['Iván', 'Marta', 'Lucía'].map((n) => readTasksOf(n, 300));
  const results = await Promise.all(promises);          // max(300, 300, 300)
  console.log(`Parallel: ${Date.now() - start} ms`);    // ≈ 300 ms
  return results;
}

Nine hundred milliseconds against three hundred, with three elements. With thirty assignees it would be nine seconds against three hundred milliseconds. The cause is that await inside a loop stops the loop: the second request is not even sent until the first one arrives.

The key to the fix lies in a detail worth underlining: a promise starts working the moment it is created, not when it is awaited. The .map() creates the three promises in one go —and all three requests go out at once—; the await Promise.all(...) only waits for them all to finish.

flowchart LR
    subgraph Seq["Sequential · 900 ms"]
        A1["Iván<br/>0→300"] --> A2["Marta<br/>300→600"] --> A3["Lucía<br/>600→900"]
    end
    subgraph Par["Parallel · 300 ms"]
        B1["Iván  0→300"]
        B2["Marta 0→300"]
        B3["Lucía 0→300"]
    end

That said: sequential is not always wrong. It is the right choice when each step needs the previous one's result, or when you deliberately want to limit the load on the server.

Situation Correct approach
The operations are independent Promise.all with map
Each one needs the previous one's result for...of with await inside
Independent, but you tolerate partial failure Promise.allSettled
Independent, but you do not want to swamp the server Loop with await, or batches of N

And a warning about forEach, which is a classic trap:

// ✗ It does NOT wait: forEach ignores the promises its callback returns
names.forEach(async (n) => {
  const r = await readTasksOf(n);
  console.log(r.hours);
});
console.log('Finished?');     // comes out BEFORE the results

forEach (04-04) discards the callback's return value, so the three promises are left dangling and nobody waits for them. If you need to wait, use for...of with await (sequential) or map + Promise.all (parallel). Never forEach.

  1. for await...of and module-level await

When what you have is an array of promises and you want to process them as they arrive, there is a variant of the loop:

async function showAsTheyArrive() {
  const promises = ['Iván', 'Marta', 'Lucía'].map((n) => readTasksOf(n));
  for await (const result of promises) {
    console.log(`${result.assignee}: ${result.hours} h`);
  }
}

The three requests start at the same time (the map already created them), and the loop unwraps them in array order. Its real potential is walking asynchronous data sources of unknown length —pages of results, files read in chunks— and for that you need asynchronous iterators, which are studied in 05-08. For now recognizing the syntax is enough.

The other addition is module-level await, which already appeared in passing in 05-04:

// js/data/config.js
export const config = await readSimulatedConfig();          // ✓ only in an ES module

Every module that imports this one will wait for that await to finish before running. It is convenient for essential configuration, but it delays the startup of the whole application, so it should be used sparingly and only in the data layer.

  1. The traps of async/await

Four mistakes you will make at some point. It is worth recognizing them by their symptom.

Trap 1: forgetting the await. The symptom is a Promise { <pending> } where you expected data.

async function bad() {
  const tasks = readBacklog();           // ✗ missing await
  console.log(tasks.length);             // undefined  (a promise has no .length)
  return tasks.filter((t) => t.isOpen);  // ✗ TypeError: tasks.filter is not a function
}

Trap 2: forgetting the return. The async function fulfils with undefined and nobody notices.

async function alsoBad() {
  readBacklog().then((t) => t.length);   // ✗ the promise is ignored
}                                         // returns Promise { undefined }

async function good() {
  return (await readBacklog()).length;   // ✓
}

An especially insidious case is forgetting the await inside a try:

async function escapingError() {
  try {
    return readBacklog({ fail: true });     // ✗ no await: the rejection happens OUTSIDE the try
  } catch (error) {
    console.error('I never get here');
  }
}

Without await, the function returns the promise before it rejects, and the catch sees nothing. The rule is return await when the return is inside a try; outside a try, return promise is equivalent and slightly more efficient.

Trap 3: unhandled rejections. A promise that rejects with no .catch and no try/catch produces an UnhandledPromiseRejection. In the browser it shows up as a console error; in modern Node, it terminates the process.

readBacklog({ fail: true });                        // ✗ nobody is listening
readBacklog({ fail: true }).catch(console.error);   // ✓

Especially treacherous are "orphan" promises fired without waiting (fire and forget). If you genuinely do not care about the result, put a .catch on it anyway, even if it only logs the failure.

Trap 4: async in a constructor. It does not exist: a constructor cannot be async because it has to return the object, not a promise. The correct pattern is a static factory of the kind you learned in 05-02:

class Board {
  static async load(name) {
    const tasks = await readBacklog();
    return new Board(name, tasks);
  }
}

const board = await Board.load('Taller Nómada');

  1. Putting it together: loadBoard()

Let us bring everything together in the function that will be the startup of Nómada Tasks from now on: load the backlog, validate it, build the board and produce the summary, with a local fallback if the server fails and a maximum waiting time.

// js/data/load.js
import { Task } from '../model/task.js';
import { Board } from '../model/board.js';
import { DataError, ValidationError } from '../model/errors.js';
import { readBacklog } from './simulatedBacklog.js';
import { createBacklog } from './backlog.js';
import { TODAY } from '../util/dates.js';

/** Rejects if the promise does not settle within the given time. */
function withTimeout(promise, ms) {
  const clock = new Promise((_, reject) =>
    setTimeout(() => reject(new DataError(`Timed out after ${ms} ms.`)), ms));
  return Promise.race([promise, clock]);
}

/** Checks the invariants of the backlog received. Throws if something does not add up. */
function validateBacklog(tasks) {
  if (!Array.isArray(tasks) || tasks.length === 0) {
    throw new DataError('The backlog is empty or is not an array.');
  }
  const ids = new Set(tasks.map((t) => t.id));
  if (ids.size !== tasks.length) {
    throw new ValidationError('There are duplicate ids in the backlog.', 'id', null);   // R1
  }
  const impostor = tasks.find((t) => !(t instanceof Task));
  if (impostor !== undefined) {
    throw new DataError('The backlog contains elements that are not tasks.');
  }
  return tasks;
}

/**
 * Loads, validates and summarizes the Taller Nómada backlog.
 * @param {Object} [options]
 * @param {number} [options.timeout=2000]
 * @param {boolean}[options.allowFallback=true]
 * @returns {Promise<{board: Board, summary: Object, source: string}>}
 */
export async function loadBoard({ timeout = 2000, allowFallback = true } = {}) {
  let tasks;
  let source = 'server';

  try {
    tasks = validateBacklog(await withTimeout(readBacklog(), timeout));
  } catch (error) {
    if (!allowFallback) throw error;
    console.warn(`⚠ ${error.message} Using the local data.`);
    tasks = validateBacklog(createBacklog());
    source = 'local';
  }

  const board = new Board('Taller Nómada', tasks);
  return { board, summary: board.summary(TODAY), source };
}

And the entry point, which comes out remarkably short:

// js/app.js
import { loadBoard } from './data/load.js';
import { readTeamHours } from './data/simulatedTeam.js';
import { TODAY, readableDate } from './util/dates.js';

async function start() {
  console.log(`— Taller Nómada · ${readableDate(TODAY)} —`);
  console.log('⏳ Loading…');

  const { board, summary, source } = await loadBoard();

  for (const task of board.tasks) console.log(task.describe(TODAY));

  console.log(`\nData source: ${source}`);
  console.log(`${summary.total} tasks, ${summary.open} open`);
  console.log(`Hours: ${summary.openHours} open out of ${summary.totalHours} total`);
  console.log(`Overdue: ${summary.overdue} · Weighted effort: ${summary.effort}`);

  const workload = board.hoursByAssignee();
  const contracted = await readTeamHours(Object.keys(workload));
  console.log('\nWorkload by assignee:');
  for (const [person, hours] of Object.entries(workload)) {
    console.log(`  ${person.padEnd(8)} ${String(hours).padStart(3)} h / ${contracted[person]} h contracted`);
  }
}

start().catch((error) => console.error('✗ Fatal error:', error.message));
— Taller Nómada · 20 September 2026 —
⏳ Loading…
▸ [1] Redesign the multipurpose room · Iván · 12 h
○ [2] Signage for the screen-printing workshop · Marta · 6 h
○ [3] Update the bookings website · Lucía · 14 h
✓ [4] Screen-printing ink inventory · Marta · 3 h
▸ [5] Bookbinding guide for residents · Iván · 8 h
○ [6] Carpentry workshop quote · Iván · 5 h ⚠ OVERDUE

Data source: server
6 tasks, 5 open
Hours: 45 open out of 48 total
Overdue: 1 · Weighted effort: 124

Workload by assignee:
  Iván      25 h / 30 h contracted
  Marta      6 h / 20 h contracted
  Lucía     14 h / 35 h contracted

Notice the .catch on the last line: start() is an async function, it returns a promise, and if anything escapes all the internal handlers that .catch is the safety net preventing an unhandled rejection. Every async function invoked from the top level needs that closing.

  1. The comparison table of the three styles

Aspect Callbacks Promises (.then) async/await
Does the operation return a value? No Yes, a promise Yes, a promise
Shape of chained code Nested pyramid Flat chain Linear, like synchronous code
Error handling if (error) at every level One .catch for the whole chain Ordinary try/catch
Does try/catch work? No Only inside each callback Yes
Automatic propagation No Yes, along the chain Yes, as exceptions
Parallel operations Manual counters Promise.all and company await Promise.all
Double delivery of the result Possible Impossible (irreversible state) Impossible
Inversion of control Yes: you hand over your function No: you receive an object No
Variables between steps Shared outside Sometimes outside the chain Ordinary locals
Readability with 5 steps Very poor Acceptable Good

The operational conclusion: write async/await by default, use .then/.catch when it fits better —a standalone transformation, a one-line fallback .catch—, reach for the combinators for anything parallel, and reserve new Promise for wrapping old callback APIs. And do not forget that the three styles are the same mechanism: async/await is syntax over promises, and a promise settles thanks to callbacks registered internally.

Common Mistakes and Tips

  • Forgetting await. The symptom is Promise { <pending> }, undefined on a property, or a TypeError saying a method is not a function. It is the first hypothesis when something asynchronous gives strange results.
  • Forgetting await inside a try. The rejection escapes the block and the catch never finds out. Use return await when the return is inside a try.
  • await inside a loop over independent operations. It multiplies the total time by the number of elements. Use map + Promise.all.
  • forEach with an async callback. It waits for nothing. Use for...of or map + Promise.all.
  • Promises with no .catch. An unhandled rejection is an error in the browser and can bring down the process in Node. Every chain ends in .catch, and so does every async function called from the top level.
  • Wrapping a promise in new Promise. If you already have a promise, chain it. new Promise is only for wrapping what is not one yet.
  • reject('text') with a string. You lose the name and the trace. Always reject with an Error or one of the subclasses from 05-02.
  • Promise.all when you tolerate partial failure. A single rejection tears down the whole set. If you prefer partial results, allSettled.
  • Believing that Promise.race cancels. It cancels nothing: the losing operation keeps running. Real cancellation is AbortController (07-03).
  • async constructors. They do not exist. Use a static factory (static async load()).
  • Tip: name variables holding promises so that it shows (backlogPromise, pending). A variable called tasks that is actually a promise is an endless source of slip-ups.

Exercises

Exercise 1 — From callbacks to async/await. Rewrite the withRetries function from exercise 3 of 05-05 as withRetries(operation, attempts, waitMs) where operation returns a promise. It must retry until the attempts run out, wait between them and throw the last error if they all fail. Count the lines and compare with the callback version.

Exercise 2 — Parallel report with failure tolerance. Write workloadReport(names) that looks up several people's hours in parallel, includes in the report those who answer and lists separately those who fail, without one failure tearing down the whole thing. Try it with ['Iván', 'Marta', 'Nobody', 'Lucía'] and check that the 45 open hours still add up across the three real ones.

Exercise 3 — Predict the output. Without running it, say what it prints and in what order, and explain each line. (Note: only the result is asked for here; the exact reason for the ordering between synchronous and asynchronous code is the subject of 05-07.)

async function step(name, ms, fail = false) {
  await new Promise((r) => setTimeout(r, ms));
  if (fail) throw new Error(`${name} failed`);
  console.log(`end ${name}`);
  return name;
}

async function main() {
  console.log('A');
  const p1 = step('one', 200);
  const p2 = step('two', 100, true);
  console.log('B');
  try {
    const r = await Promise.all([p1, p2]);
    console.log('C', r);
  } catch (e) {
    console.log('D', e.message);
  }
  const r2 = await Promise.allSettled([step('three', 50), step('four', 50, true)]);
  console.log('E', r2.map((x) => x.status));
}

main();
console.log('F');

Solutions

Exercise 1

'use strict';

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * Runs an operation that returns a promise, retrying if it fails.
 * @param {Function} operation  () => Promise
 * @param {number}   attempts
 * @param {number}   waitMs
 */
async function withRetries(operation, attempts, waitMs = 200) {
  let lastError;
  for (let i = 1; i <= attempts; i++) {
    try {
      return await operation();
    } catch (error) {
      lastError = error;
      if (i < attempts) {
        console.log(`  ↻ attempt ${i} failed: ${error.message}. ${attempts - i} left.`);
        await wait(waitMs);
      }
    }
  }
  throw new DataError(`All ${attempts} attempts failed. Last error: ${lastError.message}`, lastError);
}

// Test
let times = 0;
const unstableServer = () => {
  times += 1;
  const attemptNumber = times;
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (attemptNumber < 3) reject(new Error(`503 on attempt ${attemptNumber}`));
      else resolve({ backlog: 6, hours: 48 });
    }, 100);
  });
};

const data = await withRetries(unstableServer, 4);
console.log('✓ Received:', data);
//   ↻ attempt 1 failed: 503 on attempt 1. 3 left.
//   ↻ attempt 2 failed: 503 on attempt 2. 2 left.
// ✓ Received: { backlog: 6, hours: 48 }

Twelve lines of function against the twenty of the callback version, and the qualitative comparison is more eloquent than the line count: here all the logic is logic. An ordinary for counts the attempts, an ordinary try/catch catches the failure, an ordinary await waits between retries. The inner function that existed only so it could repeat itself is gone, the guard returns after every callback call are gone, and the double-call fragility is gone: a promise settles once, by construction. Notice too the return await operation() inside the try: here the await is essential, because without it the rejection would escape the block and there would be no retry (trap 2 from section 13).

Exercise 2

'use strict';

/**
 * Looks up several people's workload in parallel, tolerating failures.
 * @param {string[]} names
 * @returns {Promise<{lines: Array, failures: Array, totalHours: number}>}
 */
async function workloadReport(names) {
  const results = await Promise.allSettled(names.map((n) => readTasksOf(n)));

  const lines = [];
  const failures = [];

  for (const [i, r] of results.entries()) {
    if (r.status === 'fulfilled') lines.push(r.value);
    else failures.push({ name: names[i], reason: r.reason.message });
  }

  return {
    lines,
    failures,
    totalHours: lines.reduce((s, l) => s + l.hours, 0)
  };
}

const report = await workloadReport(['Iván', 'Marta', 'Nobody', 'Lucía']);

for (const l of report.lines) console.log(`✓ ${l.assignee.padEnd(8)} ${l.tasks} tasks · ${l.hours} h`);
for (const f of report.failures) console.warn(`⚠ ${f.name}: ${f.reason}`);
console.log(`Total open hours: ${report.totalHours}`);

// ✓ Iván     3 tasks · 25 h
// ✓ Marta    1 tasks · 6 h
// ✓ Lucía    1 tasks · 14 h
// ⚠ Nobody: Nobody has no open tasks.
// Total open hours: 45

The canonical 45 h add up: 25 for Iván, 6 for Marta and 14 for Lucía. Three decisions deserve comment. Choosing allSettled instead of all is the heart of the exercise: with all, the failure of 'Nobody' would have torn down the entire report and you would not have seen a single line. The results.entries() is necessary because allSettled does not say which input each result corresponds to; the only guarantee is that the order is preserved, and that is why the index is enough to recover the name. And all four lookups are fired at once in the map, so the report takes as long as the slowest, not the sum of the four.

Exercise 3

A
B
F
D two failed
E [ 'fulfilled', 'rejected' ]

Line by line:

  • A and B are synchronous inside main. Between them p1 and p2 are created: step is async, and its body runs up to the first await, so both timers start immediately and in parallel. Neither prints anything yet.
  • F comes out after B because main() suspends at its first await and hands control back to the top level, which still had that console.log pending.
  • D two failed: at 100 ms p2 rejects. Promise.all rejects instantly with the first failure, without waiting for p1. That is why C is not printed.
  • end two never appears (it threw before reaching the console.log), but end one would appear at 200 ms: p1 carries on even though Promise.all has already ignored it —remember that nothing is cancelled. Its end one slips in between D and E.
  • E [ 'fulfilled', 'rejected' ]: allSettled waits for both and never rejects; 'three' prints its end three and 'four' fails without printing.

With end one included, the complete output is: A, B, F, D two failed, end one, end three, E [...]. The lesson to take away is twofold: Promise.all fails fast but cancels nothing, and an async function yields control at its first await, which is exactly the mechanism dissected in the next lesson.

Conclusion

You have solved the four problems the previous lesson left behind, and all of them with the same idea: making the future result be a value. A promise is an object with three states —pending, fulfilled, rejected—, with a single, irreversible transition that makes double delivery impossible, and with the result kept for whoever subscribes later. You know how to create one with new Promise((resolve, reject) => …) —remembering that the executor runs immediately, that only the first call counts and that you must reject with an Error— and you know how to promisify a callback API, which is the hinge between the two worlds and the operation that turned readSimulatedBacklog into readBacklog.

You have consumption down. .then for the value, .catch for the failure, .finally for the cleanup; and above all chaining, because each .then returns a new promise and, if its callback returns another promise, the chain waits for it. That flattened the weekly report's pyramid into a list of steps with a single .catch at the end, and restored the automatic error propagation that had been lost crossing the asynchronous boundary: a failure at any link jumps to the first handler, and an intermediate .catch can even recover the chain by returning a fallback value. With Promise.resolve you normalize functions that sometimes do work and sometimes answer from a cache, and with the four combinators you solve in one line what used to demand manual counters: all when you need everything, allSettled when you tolerate partial failure, race for timeouts and any for several equivalent sources.

And you have arrived at async/await, which is syntax over everything above. An async function always returns a promise —fulfilled by the return, rejected by the throw— and await suspends that function without blocking the thread until the promise settles, handing over the value as if it were an ordinary one. With that, try/catch works again, because await turns a rejection into an exception thrown on that very line, and with it come back finally, the instanceof for telling ValidationError from DataError, and the fail-fast of rethrowing what you cannot handle. You know the classic performance mistake —await inside a loop over independent operations, 900 ms instead of 300— and its fix with map + Promise.all, resting on the detail that a promise starts working when it is created, not when it is awaited; you know that forEach with an async callback waits for nothing; and you recognize for await...of and module-level await. The four traps —forgetting the await, forgetting the return inside a try, leaving rejections unhandled, attempting an async constructor— are identified by their symptom. And loadBoard() brings it all together: a load with a maximum time, validation of the backlog's invariants, a fall back to local data if the server fails, and the same canonical summary as always —48 h, 45 open, 1 overdue, effort 124— in a function that reads top to bottom.

One question remains, one we have been deliberately dodging. You know what await does, but not why the output order is what it is: why 'F' comes out before the result of an already-fulfilled promise, why a setTimeout(f, 0) runs after a .then registered later, and why a heavy loop freezes the interface even with an await in the mix. All of that is decided by machinery with a name of its own —the call stack, the environment APIs, the macrotask queue, the microtask queue and the loop that coordinates them— and it is the subject of The Event Loop and the Microtask Queue.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved