Week 10 - Asynchronous programming

Synchronous vs Asynchronous Code

Callbacks

Promises

Asynchronous File I/O

Event Loop

Fetch API

Async/Await and Promise.all()

AI Using GitHub Copilot in VS Code

Practice

Assignment

Back to core program

Promises

From the New Oxford American Dictionary:

prom·ise | ˈpräməs | noun a declaration or assurance that one will do a particular thing or that a particular thing will happen

In JavaScript, a promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Or, stated differently, it is an object that acts as a placeholder for a future value.

Promises are a structured way to handle async results instead of passing callbacks around. When you create a promise, you immediately get an object back. The async work finishes later, and the promise eventually gets either a value or an error.

A promise may be in one of 3 possible states:

A promise always starts as pending and will eventually become either fulfilled or rejected (and then it stays that way).

Creating a Promise

You create a promise by calling the Promise() constructor with new, passing as its sole parameter an executor function. Here is how you could create a pending promise:

const promise = new Promise((resolve, reject) => {
  // We never call resolve or reject, so this promise stays pending forever.
});

console.log(1, typeof promise);
console.log(2, promise);

// Output:
// 1 object
// 2 Promise { <pending> }

An executor function is a regular JavaScript function that takes two parameters:

<aside> ⚠️

In practice, you almost always call resolve or reject at some point. A forever-pending promise is usually a bug.

</aside>

Calling resolve():

const promise = new Promise((resolve, reject) => {
  // Immediately fulfill the promise with the value 42
  resolve(42);
});

console.log(1, typeof promise);
console.log(2, promise);

// Output:
// 1 object
// 2 Promise { 42 }

Calling reject():

const promise = new Promise((resolve, reject) => {
  reject(new Error('Something went wrong'));
});

console.log(1, typeof promise);
console.log(2, promise);

// Output:
// 1 object
// 2 Promise {
//   <rejected> Error: Something went wrong
//   ... (stack trace omitted)
//  }

<aside> 💡

You can technically pass any value to reject, but using Error objects is a best practice because they carry a message and a stack trace.

</aside>

Immutability

A promise, once settled, becomes immutable, i.e. its outcome can no longer be changed by further calls to resolve or reject. This is by design. By analogy, you might say that you can’t come back on your promise.

const promise = new Promise((resolve, reject) => {
  resolve(42);
  reject(new Error('Something went wrong')); // ignored, because the promise is already fulfilled
});

console.log(promise);

// Output:
// Promise { 42 }

The first call that transitions the promise from pending to fulfilled or rejected “wins”. All later calls to resolve or reject are ignored.

Using Promises

Up to now, we have talked about how to construct promises. But how do you extract the result of a promise once it becomes settled? For this purpose, a promise object provides two methods:

Here is an example:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Done!');
  }, 1000);
});

promise
  .then((value) => {
    console.log('Fulfilled with:', value);
  })
  .catch((error) => {
    console.error('Rejected with:', error);
  });

The function passed as an argument to the .then() method is called when the promise is fulfilled. The one that is passed to the .catch() method is called when the promise is rejected.

Here is how the (error-first) callback style compares to the promise style:

// Callback style
fs.readFile('file.txt', (error, data) => {
  if (error) {
    console.error(error);
  } else {
    console.log(data.toString());
  }
});

// Promise style
fs.promises.readFile('file.txt')
  .then((data) => {
    console.log(data.toString());
  })
  .catch((error) => {
    console.error(error);
  });

In the promise style, the .then() and .catch() methods are linked together to form a promise chain. This is possible because each .then() and .catch() method return a new promise object, complete with its own .then() and .catch() methods. We will look at promise chains next.

https://www.youtube.com/watch?v=670f71LTWpM

<aside> 💭

Watch this video up to the 8:00 time mark, which covers the material we discussed above. We will revisit this video in a later section for its latter part, when we introduce fetch() and async/await.

</aside>

Promise Chains

Sometimes, we have situations where one asynchronous operation depends on information obtained through preceding asynchronous operation. Here is an example:

// Step 1: Simulate finding a user in a database
function getUser(id) {
  return new Promise((resolve, reject) => {
    console.log("Searching for user...");
    setTimeout(() => {
      resolve({ id: id, name: "Alex" });
    }, 1500);
  });
}

// Step 2: Simulate getting posts based on the user's name
function getPosts(username) {
  return new Promise((resolve, reject) => {
    console.log(`Fetching posts for ${username}...`);
    setTimeout(() => {
      resolve(["Post 1", "Post 2", "Post 3"]);
    }, 1500);
  });
}

// --- The Promise Chain ---
getUser(1)
  .then((user) => {
    console.log("User found:", user.name);
    // Returning a promise here "links" the chain
    return getPosts(user.name); 
  })
  .then((posts) => {
    console.log("Posts retrieved:", posts);
  })
  .catch((error) => {
    console.error("An error occurred:", error);
  });
  
// --- Output ---
// Searching for user...
// ...
// User found: Alex
// Fetching posts for Alex...
// ...
// Posts retrieved: [ 'Post 1', 'Post 2', 'Post 3' ]

How the Promise Chain Works

  1. Dependency: The getPosts() function cannot be called until the call to getUser() succeeds because it requires the user.name result.

  2. Each .then() returns a new promise that resolves to whatever the function passed to the .then() method returns. If there is no explicit return the .then() method returns a promise that resolves to undefined.

  3. Inside the first .then() , we return the getPosts() promise. This second .then() will receive the result from getPost(). The return keyword is essential here. Without it, the first .then() will not return the promise returned by getPost(). Instead, it will return a promise that resolves to undefined and the console.log() in the second .then() would print “Posts retrieved: undefined”.

    <aside> ❗

    Common gotcha: Forgetting to return inside a .then when chaining.

    </aside>

  4. Error Handling: A single .catch() at the end will catch an error from either of the two operations, keeping your error handling centralized.

More on .then() and .catch()

Up until now, we have shown the .then() method as taking a single callback parameter. This callback will run if the promise object on which .then() is called is resolved. But .then() actually accepts a second parameter for a callback that will be called when the promise is rejected. So the chain from the getUser() function from the above example could be rewritten as follows:

getUser(1)
  .then((user) => {
    console.log('User found:', user.name);
    // Returning a promise here "links" the chain
    return getPosts(user.name);
  })
  .then(
    (posts) => {
      console.log('Posts retrieved:', posts);
    },
    (error) => {  // in place of chained .catch()
      console.error('An error occurred:', error);
    }
  );

Any error thrown or rejection returned in an earlier .then() will “travel down” until it meets the first handler that can deal with rejections (either a .catch() or a second argument in .then).

If the second parameter is not specified, or if it is called with the value null or undefined, the rejected promise will travel down the chain until it meets the first handler that can deal with rejections (either a .catch() or a second argument in .then()).

In fact, a .catch() is no more than syntactic sugar for:

.then(null, onRejected)

In this case, .then() passes the resolved value down the chain unchanged, and only calls the second callback when the promise is rejected.

In modern JavaScript you will almost always see .catch() used for error handling, rather than passing a second callback to .then(). The main reason is that .catch() collects errors from the whole chain above it, while .then(onFulfilled, onRejected) only handles errors coming from the promise it is directly attached to.

<aside> 💭

In this curriculum we will prefer .then(...).catch(...) for clarity and consistency. The two-argument form of .then() is good to know, but you will see it less often.

</aside>

Cleaning up, .finally()

Sometimes you need to perform an operation at the end of a promise chain that needs to execute, regardless of whether an error occurred or not. For instance, a file or database connection that need to be closed. For that purpose you can add a .finally() method at the end of the chain. In the example below we use a setInterval() timer to simulate the “thinking” time needed to answer this all-encompassing question. Regardless of whether the answer was found successfully, we need to cancel the setInterval() to stop the “thinking” process. This is what we do in the .finally() method.

<aside> ⚠️

The callback passed to .finally() does not receive the result or the error. It is only for cleanup. The value or error continues down the chain unchanged.

</aside>

function whatIsTheMeaningOfLife() {
  let count = 0;
  const intervalTimer = setInterval(() => {
    count += 1;
    // Rewrite the same line: not possible with console.log
    process.stdout.write('\\rThinking' + '.'.repeat(count));
  }, 1000);

  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.5) {
        resolve(42);
      } else {
        reject(new Error('Come back in 7.5 million years and ask me again!'));
      }
      process.stdout.write('\\r');
    }, Math.floor(Math.random() * 5000) + 3000);
  }).finally(() => {
    console.log();
    clearInterval(intervalTimer);
  });
}

console.log(
  'What is the answer to the Ultimate Question of Life, the Universe, and Everything?'
);

whatIsTheMeaningOfLife()
  .then((result) => {
    console.log(`The answer is ${result}`);
  })
  .catch((err) => {
    console.log(err.message);
  });

// --- Output ---
// What is the answer to the Ultimate Question of Life, the Universe, and Everything?
// Thinking.......
// Come back in 7.5 million years and ask me again!

<aside> ⌨️

Hands on: Try it for yourself from the Learning Resources repository: https://github.com/HackYourFuture/Learning-Resources/tree/main/core-program/week-10/promise-finally

</aside>


CC BY-NC-SA 4.0 Icons

*https://hackyourfuture.net/*

Found a mistake or have a suggestion? Let us know in the feedback form.