You finished the previous lesson with a purchase pyramid that worked, but whose cost was obvious: a context object to carry the state by hand, two error exit points, compensation nested inside another callback and no way to launch in parallel what was independent. The diagnosis was clear: the language was not helping. try/catch did not work, return was useless, finally did not exist.

Promises give all of that back. A promise is an object representing a value that is not available yet, and one that the language understands: it can be chained, composed, awaited with await and caught with try/catch. On top of them, async/await adds a syntax layer that makes asynchronous code read exactly like synchronous code, without being it.

In this lesson you are going to understand what a promise really is and its three states, convert with util.promisify the callback functions you wrote yesterday, rewrite the Escena Viva purchase flow until it is flat and readable, and master the distinction that separates the beginner from the professional: when to wait in series and when to launch in parallel. You will finish with the patterns you will use every day — retries, timeouts and a sleep function — and knowing why an unhandled rejection takes your process down in modern Node.

Contents

  1. What a promise is and its three states
  2. Consuming promises: .then, .catch and .finally
  3. Flat chaining versus the pyramid
  4. Creating promises: new Promise and util.promisify
  5. async and await: the three rules
  6. The purchase flow rewritten, side by side
  7. Sequential versus concurrent: the for with await mistake
  8. Promise.all, allSettled, race and any
  9. Error propagation and unhandled rejections
  10. Top-level await
  11. Useful patterns: sleep, retries and timeouts
  12. Summary table: callbacks, promises and async/await

  1. What a promise is and its three states

A promise (Promise) is an object representing the future result of an asynchronous operation. It is not the value: it is a receipt you can store, pass to other functions and inspect, and that at some point will turn into a value or an error.

A promise is always in one of three states:

stateDiagram-v2
    [*] --> Pending: the promise is created
    Pending --> Fulfilled: resolve(value)
    Pending --> Rejected: reject(error)
    Fulfilled --> [*]: .then(value)
    Rejected --> [*]: .catch(error)

    note right of Pending
        The operation is in progress.
        There is no value yet.
    end note
    note right of Fulfilled
        fulfilled: there is a value.
        FINAL state.
    end note
    note right of Rejected
        rejected: there is an error.
        FINAL state.
    end note
State Keyword Meaning
Pending pending The operation has not finished yet
Fulfilled fulfilled It ended well and there is a value
Rejected rejected It ended badly and there is a reason (usually an Error)

And three properties that define its behavior and that you must burn into memory:

  1. A promise changes state exactly once. From pending it moves to fulfilled or to rejected, and there it stays forever. It is said to be settled. This solves by decree the problem of "the callback was called twice": it is impossible.
  2. The result is immutable. Once fulfilled with a value, that value does not change.
  3. You can subscribe whenever you like, even late. If you subscribe to an already fulfilled promise, your function runs anyway (on the microtask queue). With a callback, if you arrive late, you missed it.

You can see it in the REPL, which you already know from the The Node.js REPL lesson:

> const p = new Promise((resolve) => setTimeout(() => resolve('ready'), 2000));
> p
Promise { <pending> }          // Before the 2 seconds

// ... you wait two seconds ...

> p
Promise { 'ready' }            // Settled now, with its value visible

> Promise.reject(new Error('failure'))
Promise { <rejected> Error: failure ... }

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

A promise is consumed with three methods:

// src/lab/consume-promise.js
const fs = require('node:fs/promises');   // The promise-based version of fs

fs.readFile('data/events.json', 'utf8')
  .then((content) => {
    // Runs if the promise is FULFILLED. It receives the value.
    const catalog = JSON.parse(content);
    console.log(`Loaded ${catalog.length} events`);
  })
  .catch((error) => {
    // Runs if the promise is REJECTED at any earlier point.
    console.error(`Could not load the catalog: ${error.message}`);
  })
  .finally(() => {
    // ALWAYS runs, whether it went well or badly. No arguments.
    console.error('[catalog] load attempt finished');
  });

Note node:fs/promises: Node offers promise-based versions of its core modules. fs/promises, dns/promises, timers/promises and stream/promises exist precisely so you never have to convert anything by hand.

Method When it runs What it receives What it returns
.then(fn) On fulfillment The value A new promise
.catch(fn) On rejection The rejection reason A new promise
.finally(fn) Always, on settling Nothing A promise with the same result

That right-hand column is the key to everything that follows: each method returns a new promise, and that is what makes chaining possible.

Two things about .finally that are often forgotten:

  • It receives no arguments, because it does not know (and does not care) whether there was success or an error. It is for cleanup: closing a file, removing a loading indicator, releasing a resource.
  • It does not alter the result. If the promise was rejected, it is still rejected after the finally.

  1. Flat chaining versus the pyramid

Here is the first big win. Remember the shape of the pyramid:

// CALLBACKS: each step inside the previous one.
findEvent(eventId, (error, event) => {
  if (error) return console.error(error.message);
  checkCapacity(sessionId, quantity, (error, session) => {
    if (error) return console.error(error.message);
    createOrder(userId, session, quantity, (error, order) => {
      if (error) return console.error(error.message);
      console.log(order.id);
    });
  });
});

And now with promises:

// PROMISES: each step at the SAME level, with a single catch at the end.
findEvent(eventId)
  .then((event) => checkCapacity(sessionId, quantity))
  .then((session) => createOrder(userId, session, quantity))
  .then((order) => console.log(order.id))
  .catch((error) => console.error(error.message));

The rule that makes this possible is simple and powerful:

If you return a promise inside a .then, the chain waits for it to settle before continuing.

And the second rule, just as important:

A rejection anywhere in the chain skips every following .then until it finds a .catch.

flowchart LR
    A["findEvent"] -->|"fulfilled"| B[".then<br/>checkCapacity"]
    B -->|"fulfilled"| C[".then<br/>createOrder"]
    C -->|"fulfilled"| D[".then<br/>display"]
    D --> E[".catch"]
    A -.->|"rejected"| E
    B -.->|"rejected"| E
    C -.->|"rejected"| E
    D -.->|"rejected"| E

A single error-handling point for the whole chain. Compare it with the five if (error) return of the previous lesson.

The classic mistake: forgetting the return

// WRONG: the chain does NOT wait for createOrder.
findEvent(eventId)
  .then((event) => {
    createOrder(userId, event, 2);   // Missing return
  })
  .then((order) => {
    console.log(order.id);   // TypeError: order is undefined
  });

// RIGHT
findEvent(eventId)
  .then((event) => {
    return createOrder(userId, event, 2);
  })
  .then((order) => {
    console.log(order.id);
  });

// RIGHT, in short form: an arrow without braces returns implicitly.
findEvent(eventId)
  .then((event) => createOrder(userId, event, 2))
  .then((order) => console.log(order.id));

This is to promises what the return after the error was to callbacks: the number one mistake. The short arrow form without braces avoids it at the root, and that is why it is preferred.

  1. Creating promises: new Promise and util.promisify

4.1 new Promise

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

// src/utils/sleep.js
// Returns a promise that fulfills after the given number of milliseconds.

function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

module.exports = { sleep };
console.log('Before');
sleep(1000).then(() => console.log('One second later'));

The executor runs immediately and synchronously when the promise is constructed. What is asynchronous is the setTimeout inside it, not the constructor.

A version with rejection, applied to Escena Viva:

// Simulates the charge through the payment gateway.
function chargeOrder(order) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (order.totalCents > 20000) {
        const error = new Error(
          `Payment declined: ${(order.totalCents / 100).toFixed(2)} EUR exceeds the limit`
        );
        error.code = 'PAYMENT_DECLINED';
        reject(error);
        return;
      }
      resolve({ ...order, status: 'paid' });
    }, 40);
  });
}

Rules of the constructor:

  • resolve and reject do not interrupt the function. Just as with callbacks, put a return after them.
  • Always reject with an Error object. reject('something failed') is legal and is bad practice: you lose the stack trace.
  • An exception thrown inside the executor becomes a rejection automatically. That one the promise does catch.

An important antipattern: the "constructor antipattern". If you already have a promise, do not wrap it in another one.

// WRONG
function load() {
  return new Promise((resolve, reject) => {
    fs.readFile('a.json', 'utf8').then(resolve).catch(reject);
  });
}
// RIGHT
function load() {
  return fs.readFile('a.json', 'utf8');
}

new Promise is only for wrapping something that is not a promise yet: a callback, an event, a timer.

4.2 util.promisify: converting yesterday's callbacks

Here is the tool that converts all of the previous lesson's work. util.promisify takes a function that follows the error-first convention and returns another one that returns a promise.

// src/lab/promisify.js
const { promisify } = require('node:util');

// The functions from the previous lesson, with error-first callbacks.
// function findEvent(id, callback) { ... }
// function findSession(sessionId, callback) { ... }
// function reserveTickets(sessionId, quantity, callback) { ... }

const findEventP = promisify(findEvent);
const findSessionP = promisify(findSession);
const reserveTicketsP = promisify(reserveTickets);

// And now they are used as promises.
findEventP('evt-002')
  .then((event) => console.log(`Found: ${event.title}`))
  .catch((error) => console.error(`[${error.code}] ${error.message}`));

How it works inside, so that it is not magic:

// A simplified version of what promisify does.
function promisifyManual(callbackFunction) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      callbackFunction(...args, (error, result) => {
        if (error) {
          reject(error);
          return;
        }
        resolve(result);
      });
    });
  };
}

Requirements for promisify to work:

Requirement If it is not met
The callback is the last parameter It does not work
The callback has the (error, result) signature The resolved value will be wrong
The callback is called exactly once Extra calls are ignored (the promise is already settled)

Notice that last row: the promise protects you from the "call the callback twice" bug. It is one of those quiet advantages you come to appreciate a lot.

If the function returns several values (it is not standard error-first), there is util.promisify.custom to define the conversion by hand. It is uncommon; when you need it, the node:util documentation covers it.

  1. async and await: the three rules

async/await is not a new mechanism: it is syntactic sugar over promises. Underneath there are exactly the same promises and the same microtask queue. What changes is how you write it.

Rule 1: an async function always returns a promise

async function getTitle() {
  return 'Concierto de Otono';   // We return a string...
}

console.log(getTitle());               // Promise { 'Concierto de Otono' }
getTitle().then((t) => console.log(t)); // Concierto de Otono

Even if you return a number, undefined or nothing, the result is a promise. And if you throw an exception inside, the promise rejects instead of propagating the exception:

async function fail() {
  throw new Error('something went wrong');
}

fail().catch((error) => console.error(error.message));   // something went wrong

Rule 2: await only pauses the function that contains it

This is the most misinterpreted rule. await does not block the process or the thread. It pauses only the async function it appears in; the rest of the program carries on as normal.

// src/lab/await-does-not-block.js
const { sleep } = require('../utils/sleep.js');

async function slowTask() {
  console.log('  [slow] starting');
  await sleep(300);
  console.log('  [slow] finished');
}

// A heartbeat that shows the process is still alive.
const heartbeat = setInterval(() => console.log('heartbeat'), 100);

slowTask().then(() => clearInterval(heartbeat));

console.log('This line runs BEFORE slowTask finishes');
  [slow] starting
This line runs BEFORE slowTask finishes
heartbeat
heartbeat
  [slow] finished

During the 300 ms of waiting the event loop kept spinning and firing the timer. Compare it with the synchronous block from the architecture lesson, where the heartbeat disappeared entirely. await yields control; a while loop holds on to it.

And await is only valid inside an async function (or at the top level of an ES module, section 10):

// SyntaxError: await is only valid in async functions
function wrong() {
  const event = await findEventP('evt-001');
}

Rule 3: try/catch works again

This is the reason async/await exists.

// src/lab/try-catch-works.js

async function showEvent(id) {
  try {
    const event = await findEventP(id);
    console.log(`Found: ${event.title}`);
  } catch (error) {
    // It IS caught. await turns the rejection into a normal exception.
    console.error(`[${error.code}] ${error.message}`);
  } finally {
    // And finally works too: cleanup goes here.
    console.error(`[query] finished for ${id}`);
  }
}

showEvent('evt-999');
[EVENT_NOT_FOUND] Event not found: evt-999
[query] finished for evt-999

Getting try/catch/finally back is not just syntactic comfort: it means that the language's error model applies to asynchronous code again. Errors propagate upwards through the async call stack, they are caught where it makes sense to catch them, and finally guarantees cleanup. All the manual bookkeeping of the previous lesson disappears.

  1. The purchase flow rewritten, side by side

Time to collect the prize. This is the same flow from exercise 3 of the previous lesson, now with async/await.

Before, with callbacks (reduced to its structure):

function buyTickets(userId, eventId, sessionId, quantity, onDone) {
  const context = { userId, eventId, sessionId, quantity, capacityReserved: false };

  findEvent(eventId, onEventFound);

  function onEventFound(error, event) {
    if (error) return fail('find-event', error);
    context.event = event;
    reserveTickets(sessionId, quantity, onReserved);
  }
  function onReserved(error, reservation) { /* ... */ }
  function onSessionFound(error, result) { /* ... */ }
  function onOrderCreated(error, order) { /* ... */ }
  function onCharged(error, paidOrder) { /* ... */ }
  function onIssued(error, tickets) { /* ... */ }
  function fail(step, error) { /* ... */ }
  function compensateAndFail(step, error) {
    releaseCapacity(sessionId, quantity, (releaseError) => { /* ... */ });
  }
}

After, with async/await:

// src/lab/purchase-async.js
// The same purchase flow, with promises. 30 lines instead of 70.

async function buyTickets(userId, eventId, sessionId, quantity) {
  const event = await findEventP(eventId);

  // From here on there is reserved capacity that may have to be given back.
  const reservation = await reserveTicketsP(sessionId, quantity);

  try {
    const { session } = await findSessionP(sessionId);
    const order = await createOrderP(userId, session, quantity);
    const paidOrder = await chargeOrder(order);
    const tickets = await issueTicketsP(paidOrder);

    return { event, session, order: paidOrder, tickets };
  } catch (error) {
    // Compensation: we release the capacity we had reserved.
    const available = await releaseCapacityP(sessionId, quantity);
    console.error(`[compensation] capacity released in ${sessionId}, ${available} available`);

    // And we rethrow: our caller decides what to do.
    throw error;
  }
}

And how it is used:

async function main() {
  try {
    const purchase = await buyTickets('att-001', 'evt-002', 'ses-002-2', 3);

    console.log(`Order ${purchase.order.id} - ${purchase.order.status}`);
    console.log(`  Event : ${purchase.event.title}`);
    console.log(`  Amount: ${(purchase.order.totalCents / 100).toFixed(2)} EUR`);
    for (const ticket of purchase.tickets) {
      console.log(`  ${ticket.code}  ${ticket.status}`);
    }
  } catch (error) {
    console.error(`[${error.code || 'ERROR'}] ${error.message}`);
    process.exitCode = 1;
  }
}

main();

Compare point by point:

Aspect Callbacks async/await
Lines in the flow ~70 ~30
Indentation levels 2 (with the flattening technique) 1
context object for the state Required Unnecessary: they are ordinary local variables
Error-handling points 2 (fail and compensateAndFail) 1 (catch)
Compensation A nested callback with its own error 2 lines inside the catch
Returning the result Impossible; you have to pass a callback An ordinary return
Reading order Jumping between functions Top to bottom

And notice the most elegant detail: event, reservation, session, order and tickets are ordinary local variables, visible throughout the function body. All that dragging of context through five callbacks has vanished, and not because we got cleverer: because the language now understands waiting.

  1. Sequential versus concurrent: the for with await mistake

async/await is so comfortable that it invites a very frequent and very expensive performance mistake. Look at it:

// src/lab/sequential-vs-parallel.js
// WRONG: the three events are independent, but they load in series.

async function loadEventsInSeries(ids) {
  const events = [];
  for (const id of ids) {
    const event = await findEventP(id);   // Waits for the previous one to finish
    events.push(event);
  }
  return events;
}

const start = Date.now();
loadEventsInSeries(['evt-001', 'evt-002', 'evt-003']).then((events) => {
  console.log(`In series: ${events.length} events in ${Date.now() - start} ms`);
});
In series: 3 events in 124 ms

With 40 ms of latency per query, three queries in series cost 120 ms. But the three events do not depend on each other: there is no reason to wait for the first one to arrive before asking for the second.

// RIGHT: the three requests go out at once.
async function loadEventsInParallel(ids) {
  // map returns an array of PROMISES: the three operations have already started.
  const promises = ids.map((id) => findEventP(id));

  // Promise.all waits for ALL of them to fulfill.
  return Promise.all(promises);
}

const start2 = Date.now();
loadEventsInParallel(['evt-001', 'evt-002', 'evt-003']).then((events) => {
  console.log(`In parallel: ${events.length} events in ${Date.now() - start2} ms`);
});
In parallel: 3 events in 42 ms

Three times faster, and that is with three events. With thirty, the difference would be 1.2 seconds versus 40 milliseconds.

gantt
    dateFormat SSS
    axisFormat %L ms
    title Three 40 ms queries
    section In series (await in a loop)
    evt-001 :a1, 000, 40ms
    evt-002 :a2, after a1, 40ms
    evt-003 :a3, after a2, 40ms
    section In parallel (Promise.all)
    evt-001 :b1, 000, 40ms
    evt-002 :b2, 000, 40ms
    evt-003 :b3, 000, 40ms

The key to understanding it: a promise starts working the moment it is created, not when you await it. The map creates all three promises at once, so all three operations start together; Promise.all only takes care of waiting.

When to use which

Situation What to use
Step B needs the result of step A Sequential await. There is no alternative
The steps are independent of each other Promise.all
They are independent but the target cannot take the load (a rate-limited API) Bounded parallelism: batches of N
You want the first one that answers Promise.race or Promise.any

In the purchase flow from section 6, the awaits do have to be sequential: you cannot issue tickets for an order you have not charged. Loading the whole catalog or querying three different organizers, on the other hand, is parallel work.

Bounded parallelism

Firing 3,000 requests at once with Promise.all is not "faster": it is a way to bring down the database or get blocked by the external API. The correct pattern is to process in batches:

// src/utils/in-batches.js
// Runs a task over many items, at most N at a time.

async function inBatches(items, batchSize, task) {
  const results = [];

  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    // Within a batch, in parallel. Between batches, in series.
    const batchResults = await Promise.all(batch.map(task));
    results.push(...batchResults);
  }

  return results;
}

module.exports = { inBatches };
// Query 3000 sessions, 20 at a time.
const occupancies = await inBatches(sessionIds, 20, (id) => queryOccupancy(id));

  1. Promise.all, allSettled, race and any

The four promise combinators. Choosing the wrong one is a common source of subtle bugs.

Method Fulfills when… Rejects when… Returns
Promise.all All of them fulfill Any of them rejects (the first) An array of values, in input order
Promise.allSettled All of them settle (it never rejects) Never An array of { status, value } or { status, reason }
Promise.race The first to settle fulfills The first to settle rejects The value or error of the first
Promise.any The first one to fulfill All of them reject The value of the first one that worked

Promise.all: all or nothing

const [almendra, boveda, ribera] = await Promise.all([
  findEventP('evt-001'),
  findEventP('evt-002'),
  findEventP('evt-003')
]);

Destructuring works because the order of the result is the input order, not the completion order.

Its behavior on failure is what you have to understand well:

try {
  const events = await Promise.all([
    findEventP('evt-001'),
    findEventP('evt-999'),   // Does not exist: it rejects
    findEventP('evt-003')
  ]);
} catch (error) {
  console.error(error.message);   // Event not found: evt-999
  // And we know nothing about evt-001 and evt-003, although they probably went fine.
}

Promise.all is "all or nothing": on the first rejection, the combined promise rejects and you lose the other results. What is more, the other operations are not cancelled: they carry on running, they just no longer interest anyone.

Use it when you need every result in order to continue. If one is missing, the work makes no sense.

Promise.allSettled: I want to know everything

// src/lab/resilient-report.js
// A report that must not fall over because one event fails.

const results = await Promise.allSettled([
  findEventP('evt-001'),
  findEventP('evt-999'),
  findEventP('evt-003')
]);

const found = [];
const failed = [];

for (const result of results) {
  if (result.status === 'fulfilled') {
    found.push(result.value.title);
  } else {
    failed.push(result.reason.message);
  }
}

console.log(`Found (${found.length}): ${found.join(', ')}`);
console.error(`Failed (${failed.length}): ${failed.join(' | ')}`);
Found (2): Concierto de Otono, Festival de Jazz de Primavera
Failed (1): Event not found: evt-999

allSettled never rejects. It is the right choice for reports, synchronizations and any batch process where a partial failure must not invalidate the rest. In Escena Viva: sending 500 confirmation emails with Promise.all means one bounced email cancels the report on the other 499; with allSettled, you know exactly which ones went out and which did not.

Promise.race: first past the post, for better or worse

// Timeout: either the gateway answers, or it rejects after 3 seconds.
const paidOrder = await Promise.race([
  chargeOrder(order),
  rejectAfter(3000, 'The payment gateway is not responding')
]);

Its main use is exactly that: imposing a timeout. We develop it in section 11.

Beware one trap: race settles with the first promise to settle, even if that is a rejection. If you want "the first one that works", ignoring failures, you need any.

Promise.any: the first one that works

// Query the price from three providers; the first good answer will do.
try {
  const exchangeRate = await Promise.any([
    queryProviderA(),
    queryProviderB(),
    queryProviderC()
  ]);
  console.log(`Exchange rate obtained: ${exchangeRate}`);
} catch (error) {
  // AggregateError: it contains ALL the errors in error.errors
  console.error(`No provider responded (${error.errors.length} failures)`);
  for (const failure of error.errors) {
    console.error(`  - ${failure.message}`);
  }
}

Promise.any only rejects if all of them fail, and it does so with an AggregateError containing the full array of errors in .errors. It is the redundancy pattern: several replicas or several providers for the same thing.

Quick decision guide

I want to… I use
Load the data I need to respond, and if one is missing I cannot respond Promise.all
Process a batch where partial failures are acceptable and must be reported Promise.allSettled
Put a timeout on an operation Promise.race
Query several redundant sources and keep the first one that works Promise.any

  1. Error propagation and unhandled rejections

9.1 Errors travel up the async stack

async function level3() {
  throw new Error('failure at the deepest level');
}

async function level2() {
  await level3();          // The rejection propagates upwards
  console.log('This does not run');
}

async function level1() {
  try {
    await level2();
  } catch (error) {
    console.error(`Caught in level1: ${error.message}`);
  }
}

level1();   // Caught in level1: failure at the deepest level

Exactly the same as with synchronous code. It is the property that makes async/await so comfortable: you catch errors where you have the context to decide what to do, not at every intermediate step.

9.2 An unhandled rejection takes the process down

If a promise rejects and nobody has registered a .catch nor awaited it inside a try, you get an unhandled rejection.

// src/lab/unhandled-rejection.js

async function charge() {
  throw new Error('The gateway returned a 500 error');
}

charge();   // Called, result ignored. NOBODY catches the rejection.

console.log('The script carries on...');
The script carries on...

node:internal/process/promises:288
            triggerUncaughtException(err, true /* fromPromise */);
            ^
Error: The gateway returned a 500 error
    ...
[the process dies with code 1]

Since Node.js 15, an unhandled rejection terminates the process. Before that it only printed a warning, and many applications lived with dozens of silent rejections hiding real bugs. The change was deliberate: an unhandled rejection is a programming error, exactly like an uncaught exception.

The three oversights that cause it:

// 1. Calling an async function without await and without .catch
processOrder(order);                     // WRONG
await processOrder(order);               // RIGHT
processOrder(order).catch(logError);     // RIGHT, if you don't want to wait

// 2. Forgetting the catch in a chain
findEventP(id).then((e) => console.log(e.title));            // WRONG
findEventP(id).then((e) => console.log(e.title)).catch(log); // RIGHT

// 3. The main function left unprotected
async function main() { /* ... */ }
main();                                        // WRONG
main().catch((error) => {                      // RIGHT
  console.error(`Fatal failure: ${error.message}`);
  process.exitCode = 1;
});

As a safety net for logging — not as error handling — you can listen for the process event:

// src/utils/safety-net.js
// Logs any unhandled rejection before the process dies.

process.on('unhandledRejection', (reason, promise) => {
  console.error('UNHANDLED REJECTION. This is a programming error.');
  console.error(reason instanceof Error ? reason.stack : reason);
  process.exitCode = 1;
});

This is for finding out about the problem and logging it in production, not for carrying on as if nothing happened. We will formalize it in Module 11.

  1. Top-level await

In a CommonJS module — the one we are using and the one we will study in CommonJS Modules and require() — await can only appear inside an async function. That is why we have had to wrap everything in a main() function.

In an ES module, await works directly at the top level of the file:

// src/lab/load.mjs   (note the .mjs extension)
import { readFile } from 'node:fs/promises';

// await directly, without wrapping it in any function.
const content = await readFile('data/events.json', 'utf8');
const catalog = JSON.parse(content);

console.log(`Loaded ${catalog.length} events`);

It is one of the exclusive advantages of ES modules, and it is especially handy for scripts, for configuration initialization and for the REPL (which, as you saw in Module 1, also supports it).

We will look at it in detail — along with how to enable it, its rules and its interoperability with CommonJS — in the ES Modules and Interoperability lesson.

  1. Useful patterns: sleep, retries and timeouts

Three utilities you will write once and use forever.

11.1 sleep(ms)

// src/utils/sleep.js
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

module.exports = { sleep };

Node has shipped a native version since version 15, in timers/promises:

const { setTimeout: sleep } = require('node:timers/promises');

await sleep(1000);
console.log('One second later');

Use the native one when you can: it also accepts a cancellation signal (AbortSignal).

11.2 Retries with growing backoff

Network operations fail transiently: a latency spike, a service restart, a momentary rate limit. Retrying is often the right answer.

// src/utils/retry.js
// Retries an asynchronous operation with exponential backoff.

const { setTimeout: sleep } = require('node:timers/promises');

async function retry(operation, options = {}) {
  const {
    attempts = 3,
    initialDelayMs = 200,
    factor = 2,
    isRetryable = () => true
  } = options;

  let lastError;

  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      return await operation(attempt);
    } catch (error) {
      lastError = error;

      // A business error is not retried: retrying an insufficient
      // capacity is not going to fix it.
      if (!isRetryable(error) || attempt === attempts) {
        throw error;
      }

      // Exponential backoff: 200, 400, 800 ms...
      const delay = initialDelayMs * factor ** (attempt - 1);
      console.error(
        `[retry] attempt ${attempt}/${attempts} failed (${error.message}). ` +
        `Retrying in ${delay} ms.`
      );
      await sleep(delay);
    }
  }

  throw lastError;
}

module.exports = { retry };
// Usage in Escena Viva: charging while retrying only network failures.
const RETRYABLE_CODES = new Set(['ETIMEDOUT', 'ECONNRESET', 'GATEWAY_UNAVAILABLE']);

const paidOrder = await retry(
  () => chargeOrder(order),
  {
    attempts: 4,
    initialDelayMs: 250,
    isRetryable: (error) => RETRYABLE_CODES.has(error.code)
  }
);

Notice isRetryable. It is the most important part of the pattern, and the one almost everyone leaves out: retrying a PAYMENT_DECLINED four times is not merely useless, it can double-charge. Only what is transient gets retried.

In production you also add jitter: a small random variation in the wait, so that a thousand clients that failed at the same moment do not all retry in the same millisecond.

11.3 Timeouts with Promise.race

An asynchronous operation that never answers is worse than one that fails: it keeps resources tied up indefinitely.

// src/utils/with-timeout.js
// Wraps a promise with a maximum waiting time.

function withTimeout(promise, limitMs, message = 'Waiting time exhausted') {
  let timer;

  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => {
      const error = new Error(`${message} (${limitMs} ms)`);
      error.code = 'TIMEOUT';
      reject(error);
    }, limitMs);
  });

  // The first one to settle wins.
  return Promise.race([promise, timeout]).finally(() => {
    // We clear the timer so it does not keep the process alive.
    clearTimeout(timer);
  });
}

module.exports = { withTimeout };
try {
  const paidOrder = await withTimeout(
    chargeOrder(order),
    3000,
    'The payment gateway is not responding'
  );
  console.log(`Charged: ${paidOrder.id}`);
} catch (error) {
  if (error.code === 'TIMEOUT') {
    console.error('Retry later or notify the user.');
  }
  throw error;
}

That .finally(() => clearTimeout(timer)) is no minor detail: without it, the timer keeps the process alive until it expires, even if the operation finished in 20 ms. It is exactly the reference-counting mechanism you saw in the architecture lesson.

And an honest warning: a timeout does not cancel the underlying operation. The HTTP request is still in flight; you simply stop waiting for it. To cancel for real you need AbortController, which modern Node APIs (fetch, fs/promises, timers/promises) do accept.

  1. Summary table: callbacks, promises and async/await

Criterion Callbacks Promises (.then) async/await
Syntax Nested functions A chain of methods Linear, like synchronous code
Readability of long flows Poor (the pyramid) Good Excellent
Error handling if (error) at every step One .catch per chain Native try/catch/finally
finally / cleanup Manual .finally() Native finally
Returning a value Impossible Yes (a promise) Yes, with return
Parallel execution Manual and error-prone Promise.all Promise.all + await
Can it be called twice Yes, a frequent bug Impossible Impossible
Variables between steps Nested closures or a context Closures or chaining Ordinary local variables
Debugging and traces Poor traces Better Complete async traces
Multiple results over time Yes No (it settles once) No
Performance cost The lowest Microtask queue Same as promises
Node support Always Since Node 0.12 Since Node 7.6
When to use it Events, streams, old APIs Occasional composition, Promise.all Everything else

The practical conclusion for the rest of the course:

Write async/await by default. Use .then when you compose promises without needing to wait for them (Promise.all, a .catch on a call you are not awaiting). Use callbacks when the API forces you to or when the result repeats over time — that is, when you are dealing with an event.

Common Mistakes and Tips

Mistake 1: forgetting the return inside a .then. The chain does not wait and the next step receives undefined. Use arrows without braces to avoid it.

Mistake 2: await inside a for when the tasks are independent. You multiply the time by the number of items. Promise.all with map.

Mistake 3: believing await blocks the process. It only pauses the function that contains it. The event loop keeps spinning.

Mistake 4: calling an async function without await and without .catch. An unhandled rejection, and since Node 15 that kills the process.

Mistake 5: Promise.all when the right choice is allSettled. One partial failure cancels the whole batch and you lose the good results.

Mistake 6: forEach with async functions.

// It waits for NOTHING: forEach ignores the promises the callback returns.
ids.forEach(async (id) => { await process(id); });
console.log('Finished');   // A lie: nothing has finished

// Correct:
await Promise.all(ids.map((id) => process(id)));

Mistake 7: wrapping a promise in new Promise. The constructor antipattern. If it is already a promise, return it as it is.

Mistake 8: reject('text') instead of reject(new Error('text')). You lose the stack trace and break the convention the whole ecosystem expects.

Mistake 9: retrying errors that are not transient. Retrying a declined payment four times can end in duplicate charges.

Tip 1: always protect your main function. main().catch(...) on the last line of the file, no exceptions.

Tip 2: ask yourself at every await: "does this need the previous result?" If the answer is no, there is a Promise.all opportunity.

Tip 3: use node:fs/promises and node:timers/promises directly. Do not promisify what Node already gives you ready made.

Tip 4: keep your errors carrying a code. The previous lesson's convention still holds, and with promises it is even more useful, because a single catch receives errors from very different steps and needs to tell them apart.

Exercises

Exercise 1: converting the data layer and measuring the difference

Write src/lab/catalog-promises.js that:

  1. Takes the findEvent, findSession and reserveTickets functions from the previous lesson and converts them with util.promisify.
  2. Implements loadCatalogSeries(ids) with await in a for loop.
  3. Implements loadCatalogParallel(ids) with Promise.all.
  4. Runs both with the three events, measures the times with Date.now() and shows a comparison table with console.table including each one's time and the speedup factor.
  5. Adds a third variant loadCatalogResilient(ids) with Promise.allSettled that works even if you pass it evt-999, reporting the failures on stderr.

Answer in writing: what speedup do you get with 3 events? And with 10? Why does the factor not grow indefinitely in a real case?

Exercise 2: the purchase with a timeout and retries

Starting from the buyTickets flow in section 6, write src/lab/purchase-robust.js adding:

  1. A 2-second timeout on the charge, using withTimeout.
  2. Retries of the charge: up to 3 attempts with exponential backoff from 200 ms, only for errors with code TIMEOUT, ETIMEDOUT or GATEWAY_UNAVAILABLE.
  3. A simulated gateway chargeOrder(order) that:
    • Rejects with PAYMENT_DECLINED if the total exceeds 20000 cents (not retryable).
    • Fails with GATEWAY_UNAVAILABLE the first two times it is called and works on the third (retryable).
  4. Correct compensation with try/catch: if anything fails after reserving the capacity, it is released.
  5. Logging on stderr for each attempt, and the final result on stdout.

Check both paths: one that ends up working after two retries and one that is declined without any retry.

Exercise 3: the occupancy dashboard, in parallel and in batches

Write src/lab/occupancy-dashboard.js that generates the Escena Viva occupancy dashboard:

  1. A queryOccupancy(sessionId) function that returns a promise with { sessionId, venue, percentage, revenueCents } after a latency of 30 ms.
  2. generateDashboard(sessionIds, batchSize) that uses the inBatches helper from section 7 to query every session with at most batchSize in flight simultaneously.
  3. Aggregation by venue: total sessions, average occupancy and revenue in euros.
  4. Measurement of the event loop lag during generation, with monitorEventLoopDelay from the previous lesson, to show that the promise-based version does not block even while processing 300 sessions.
  5. A time comparison with batch sizes 1, 10, 50 and 300.

Answer: why is the batch of 300 the fastest here and yet still not the best choice against a real database?

Solutions

Solution 1

// src/lab/catalog-promises.js
// Compares sequential, parallel and resilient loading of the catalog.

const { promisify } = require('node:util');

// findEvent comes from the previous lesson (error-first callback).
const findEventP = promisify(findEvent);

// 2. In series: each request waits for the previous one.
async function loadCatalogSeries(ids) {
  const events = [];
  for (const id of ids) {
    events.push(await findEventP(id));
  }
  return events;
}

// 3. In parallel: every request starts at once.
async function loadCatalogParallel(ids) {
  return Promise.all(ids.map((id) => findEventP(id)));
}

// 5. Resilient: partial failures do not invalidate the rest.
async function loadCatalogResilient(ids) {
  const results = await Promise.allSettled(ids.map((id) => findEventP(id)));

  const events = [];
  const failures = [];

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      events.push(result.value);
    } else {
      failures.push({ id: ids[index], reason: result.reason.message });
    }
  });

  for (const failure of failures) {
    console.error(`[catalog] could not load ${failure.id}: ${failure.reason}`);
  }

  return { events, failures };
}

// --- Measurement ---
async function measure(label, fn, ids) {
  const start = Date.now();
  const result = await fn(ids);
  const ms = Date.now() - start;
  const count = Array.isArray(result) ? result.length : result.events.length;
  return { label, events: count, ms };
}

async function main() {
  const ids = ['evt-001', 'evt-002', 'evt-003'];

  const series = await measure('series', loadCatalogSeries, ids);
  const parallel = await measure('parallel', loadCatalogParallel, ids);

  console.table([
    series,
    parallel,
    { label: 'speedup', events: '-', ms: `x${(series.ms / parallel.ms).toFixed(1)}` }
  ]);

  console.error('');
  console.error('--- Resilient variant with a non-existent id ---');
  const resilient = await loadCatalogResilient([...ids, 'evt-999']);
  console.log(
    `Resilient: ${resilient.events.length} loaded, ${resilient.failures.length} failed`
  );
}

main().catch((error) => {
  console.error(`Fatal failure: ${error.message}`);
  process.exitCode = 1;
});
┌─────────┬────────────┬────────┬────────┐
│ (index) │ label      │ events │ ms     │
├─────────┼────────────┼────────┼────────┤
│ 0       │ 'series'   │ 3      │ 124    │
│ 1       │ 'parallel' │ 3      │ 42     │
│ 2       │ 'speedup'  │ '-'    │ 'x3.0' │
└─────────┴────────────┴────────┴────────┘

--- Resilient variant with a non-existent id ---
[catalog] could not load evt-999: Event not found: evt-999
Resilient: 3 loaded, 1 failed

Answers:

  • With 3 events: a factor of ~3. With 10 events: a factor of ~10 (400 ms versus 40 ms). In this lab the improvement is linear because setTimeout consumes no shared resource.
  • In a real case the factor does not grow indefinitely for three reasons: the target has a limit (a database with 20 connections does not serve 500 simultaneous queries any faster than 20 at a time), the network has finite bandwidth, and Node itself has the 4-thread thread pool for the operations that go through it. Past a certain point, more concurrency only adds queueing. Hence the bounded parallelism pattern with batches.

Solution 2

// src/lab/purchase-robust.js
// Purchase flow with a timeout, selective retries and compensation.

const { setTimeout: sleep } = require('node:timers/promises');

const RETRYABLE_CODES = new Set([
  'TIMEOUT', 'ETIMEDOUT', 'GATEWAY_UNAVAILABLE'
]);

// --- Utilities ---

function withTimeout(promise, limitMs, message = 'Waiting time exhausted') {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => {
      const error = new Error(`${message} (${limitMs} ms)`);
      error.code = 'TIMEOUT';
      reject(error);
    }, limitMs);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

async function retry(operation, { attempts = 3, initialDelayMs = 200, factor = 2 } = {}) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      return await operation(attempt);
    } catch (error) {
      const retryable = RETRYABLE_CODES.has(error.code);

      if (!retryable) {
        console.error(`[retry] "${error.code}" is not retryable. Giving up.`);
        throw error;
      }
      if (attempt === attempts) {
        console.error(`[retry] all ${attempts} attempts exhausted.`);
        throw error;
      }

      const delay = initialDelayMs * factor ** (attempt - 1);
      console.error(
        `[retry] attempt ${attempt}/${attempts} failed (${error.message}). ` +
        `Retrying in ${delay} ms.`
      );
      await sleep(delay);
    }
  }
}

// --- Simulated gateway ---

let gatewayCalls = 0;

function chargeOrder(order) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // Business error: NOT retryable.
      if (order.totalCents > 20000) {
        const error = new Error(
          `Payment declined: ${(order.totalCents / 100).toFixed(2)} EUR exceeds the limit`
        );
        error.code = 'PAYMENT_DECLINED';
        reject(error);
        return;
      }

      // Transient error: the first two calls fail.
      gatewayCalls++;
      if (gatewayCalls <= 2) {
        const error = new Error('The gateway is unavailable');
        error.code = 'GATEWAY_UNAVAILABLE';
        reject(error);
        return;
      }

      resolve({ ...order, status: 'paid' });
    }, 40);
  });
}

// --- Purchase flow ---

async function buyTickets(userId, eventId, sessionId, quantity) {
  const event = await findEventP(eventId);
  await reserveTicketsP(sessionId, quantity);

  try {
    const { session } = await findSessionP(sessionId);
    const order = await createOrderP(userId, session, quantity);

    // Retries on the outside, timeout on the inside: each attempt
    // gets its own 2 seconds.
    const paidOrder = await retry(
      () => withTimeout(chargeOrder(order), 2000, 'The gateway is not responding'),
      { attempts: 3, initialDelayMs: 200 }
    );

    const tickets = await issueTicketsP(paidOrder);
    return { event, session, order: paidOrder, tickets };
  } catch (error) {
    const available = await releaseCapacityP(sessionId, quantity);
    console.error(`[compensation] capacity released in ${sessionId}, ${available} available`);
    throw error;
  }
}

// --- Tests ---

async function main() {
  // Path 1: 3 x 18.00 = 54.00 EUR. Fails twice and works on the third try.
  console.error('--- Purchase that succeeds after two retries ---');
  const purchase = await buyTickets('att-001', 'evt-002', 'ses-002-2', 3);
  console.log(`Order ${purchase.order.id} - ${purchase.order.status}`);
  console.log(`  Amount: ${(purchase.order.totalCents / 100).toFixed(2)} EUR`);
  console.log(`  Tickets: ${purchase.tickets.map((t) => t.code).join(', ')}`);

  // Path 2: 5 x 42.00 = 210.00 EUR. Declined, not retryable.
  console.error('');
  console.error('--- Purchase declined without retries ---');
  try {
    await buyTickets('att-002', 'evt-003', 'ses-003-2', 5);
  } catch (error) {
    console.error(`Result: [${error.code}] ${error.message}`);
  }
}

main().catch((error) => {
  console.error(`Fatal failure: ${error.message}`);
  process.exitCode = 1;
});
--- Purchase that succeeds after two retries ---
[retry] attempt 1/3 failed (The gateway is unavailable). Retrying in 200 ms.
[retry] attempt 2/3 failed (The gateway is unavailable). Retrying in 400 ms.
Order ord-001 - paid
  Amount: 54.00 EUR
  Tickets: EV-2026-000001, EV-2026-000002, EV-2026-000003

--- Purchase declined without retries ---
[retry] "PAYMENT_DECLINED" is not retryable. Giving up.
[compensation] capacity released in ses-003-2, 180 available
Result: [PAYMENT_DECLINED] Payment declined: 210.00 EUR exceeds the limit

Two design decisions worth underlining:

  1. The timeout goes inside the retry, not outside. That way each attempt gets its own 2 seconds. The other way round, a global limit would cut the retry chain off halfway.
  2. PAYMENT_DECLINED is abandoned on the first attempt. That is the line separating a useful retry from a duplicate charge.

Solution 3

// src/lab/occupancy-dashboard.js
// Escena Viva occupancy dashboard with bounded parallelism and loop measurement.

const { monitorEventLoopDelay } = require('node:perf_hooks');

const VENUES = ['Teatro Almendra', 'Sala Boveda', 'Auditorio Ribera'];
const LATENCY_MS = 30;

// 1. Simulated query for one session.
function queryOccupancy(sessionId, index) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const capacity = 420;
      const sold = (index * 37) % capacity;
      resolve({
        sessionId,
        venue: VENUES[index % VENUES.length],
        percentage: Math.round((sold / capacity) * 100),
        revenueCents: sold * 2500
      });
    }, LATENCY_MS);
  });
}

// 2. Bounded parallelism.
async function inBatches(items, batchSize, task) {
  const results = [];
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    results.push(...await Promise.all(batch.map(task)));
  }
  return results;
}

async function generateDashboard(sessionIds, batchSize) {
  return inBatches(sessionIds, batchSize, (id, i) => queryOccupancy(id, i));
}

// 3. Aggregation by venue.
function aggregateByVenue(occupancies) {
  const byVenue = new Map();

  for (const o of occupancies) {
    const a = byVenue.get(o.venue) ?? { venue: o.venue, sessions: 0, percentageSum: 0, revenueCents: 0 };
    a.sessions++;
    a.percentageSum += o.percentage;
    a.revenueCents += o.revenueCents;
    byVenue.set(o.venue, a);
  }

  return [...byVenue.values()].map((a) => ({
    venue: a.venue,
    sessions: a.sessions,
    averageOccupancy: `${Math.round(a.percentageSum / a.sessions)}%`,
    revenueEuros: (a.revenueCents / 100).toFixed(2)
  }));
}

async function main() {
  const ids = Array.from({ length: 300 }, (_, i) => `ses-${String(i + 1).padStart(3, '0')}-1`);

  // 4. Measuring loop lag throughout the whole process.
  const histogram = monitorEventLoopDelay({ resolution: 5 });
  histogram.enable();

  // 5. Batch size comparison.
  const comparison = [];
  for (const batchSize of [1, 10, 50, 300]) {
    const start = Date.now();
    await generateDashboard(ids, batchSize);
    comparison.push({ batchSize, ms: Date.now() - start });
  }

  histogram.disable();

  const occupancies = await generateDashboard(ids, 50);

  console.log('OCCUPANCY DASHBOARD');
  console.table(aggregateByVenue(occupancies));

  console.log('');
  console.log('TIME BY BATCH SIZE');
  console.table(comparison);

  const toMs = (n) => (n / 1e6).toFixed(2);
  console.error('');
  console.error(`Loop lag: mean ${toMs(histogram.mean)} ms, p99 ${toMs(histogram.percentile(99))} ms`);
}

main().catch((error) => {
  console.error(`Fatal failure: ${error.message}`);
  process.exitCode = 1;
});
OCCUPANCY DASHBOARD
┌─────────┬────────────────────┬──────────┬──────────────────┬──────────────┐
│ (index) │ venue              │ sessions │ averageOccupancy │ revenueEuros │
├─────────┼────────────────────┼──────────┼──────────────────┼──────────────┤
│ 0       │ 'Teatro Almendra'  │ 100      │ '49%'            │ '515450.00'  │
│ 1       │ 'Sala Boveda'      │ 100      │ '50%'            │ '523900.00'  │
│ 2       │ 'Auditorio Ribera' │ 100      │ '50%'            │ '521100.00'  │
└─────────┴────────────────────┴──────────┴──────────────────┴──────────────┘

TIME BY BATCH SIZE
┌─────────┬───────────┬──────┐
│ (index) │ batchSize │ ms   │
├─────────┼───────────┼──────┤
│ 0       │ 1         │ 9412 │
│ 1       │ 10        │ 942  │
│ 2       │ 50        │ 192  │
│ 3       │ 300       │ 32   │
└─────────┴───────────┴──────┘

Loop lag: mean 5.31 ms, p99 11.08 ms

Analysis:

  • The batch of 300 is the fastest (32 ms versus 9.4 seconds) because the 300 waits elapse simultaneously: the total cost is that of a single 30 ms latency.
  • Loop lag stays at 5-11 ms even with 300 operations in flight. Compare it with the previous lesson's synchronous report, which pushed the p99 to 414 ms. This is the demonstration that well-used asynchrony scales without blocking.
  • And yet the batch of 300 would not be the right choice against a real system: a database with a pool of 20 connections does not run 300 simultaneous queries, it queues them; an external API with a limit of 100 requests per minute would return 429 errors; and 300 responses arriving at once multiply memory use. The batch size is chosen by what the target can take, not by what Node can take. A value between 10 and 50 is the usual sweet spot, and it is tuned by measuring.

Conclusion

You have got the language back. A promise is an object with three states — pending, fulfilled, rejected — that changes state exactly once and whose result is immutable; that single property eliminates by construction the "the callback was called twice" bug. You consume it with .then, .catch and .finally, and since each one returns a new promise, the steps chain flat with a single error-handling point instead of the previous lesson's pyramid.

You have learned to create them: new Promise(resolve, reject) to wrap what is not a promise yet — a timer, an event, a payment gateway — and, above all, util.promisify to convert in one stroke the error-first Escena Viva functions you wrote yesterday. And you have seen the antipattern to avoid: never wrap a promise inside another one.

On that basis, async/await with its three rules: an async function always returns a promise, await pauses only the function that contains it — you demonstrated it with a heartbeat that kept sounding during the wait — and try/catch/finally works again. The complete purchase flow went from 70 lines with a context object and two error routes to 30 lines with ordinary local variables, a single catch and a two-line compensation.

You have internalized the distinction that separates slow code from fast code: await in a for loop over independent tasks multiplies the time by the number of items, whereas Promise.all over a map reduces it to the cost of the slowest one. And you know how to choose among the four combinators: all when you need everything, allSettled when partial failures are tolerable and must be reported, race to impose a timeout and any for redundant sources. You also know that parallelism is bounded by what the target can take, not by what Node can take.

Finally, you have the robustness tools you will always use: sleep (or node:timers/promises), retries with exponential backoff and an isRetryable predicate that avoids retrying a declined payment, and timeouts with Promise.race, remembering to clear the timer in the finally. And you know that an unhandled rejection kills the process as of Node 15, so your main function always carries its .catch.

One case remains that neither promises nor async/await cover, and not by accident: a promise settles only once, but there are results that happen many times over time. A session selling out, a sale being recorded, capacity dropping below 10 %, a socket receiving data: that is not "a future result", it is a stream of occurrences. For that, Node has a mechanism of its own that has been in its DNA since day one. In the next lesson, Events and EventEmitter, you will build Escena Viva's SalesManager and make the entire system find out, with no coupling whatsoever, the instant a session runs out of tickets.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved