Promises and Node.js

So far in this course, we've been working on the front end (the browser). We will now be moving to the back end, using Node.js.

Node.js is JavaScript code, but running on the server, not in the browser.

However a major conceptual leap to using Node.js effectively is to understand the event loop and promises. So, although this reading nominally about Node.js, we'll mostly be discussing asynchronous functions, promises and the like. At the end, we'll get to some practical stuff about Node.js. But, we'll start with Node.js

Outside Readings

To prepare for class, I'd like you to read the following:

Concepts

The important concepts and features to note about Node.js are:

  • It uses an asynchronous, non-blocking I/O style.
  • The non-blocking I/O can be handled in our code by one of the following techniques (from oldest to newest):
    • callbacks, which we are now quite familiar with.
    • promises, which were introduced into JavaScript in 2015
    • async/await, which were introduced into JavaScript in 2017

These are all facets of the same idea. Let's explore some of those ideas.

I/O in the Browser

Consider the following simple code in your browser. You can even run the function if you'd like.

function computeCircleArea() {
    let radius = prompt('what radius?');
    let area = Math.PI * radius * radius;
    console.log(area);
}

You'll notice that when the "prompt" window goes up, the browser tab freezes. Everything is suspended, waiting for your slow, error-prone typing.

This is called synchronous I/O, because everything else is synchronized with the prompt function. Synchronous I/O is acceptable for certain applications, where we don't need anything else to happen at the same time, but in many, many, real-world applications, synchronous I/O is unacceptable. Suppose, for example, that instead of prompting the user, the code was making a network request to some server somewhere to get the desired radius. That network request might take several seconds, or even longer. The user would be concerned as to why their browser tab had frozen.

The alternative, of course, is asynchronous I/O, which we will see in three forms: callbacks promises async/await

For the following discussion, the examples will use file I/O in Node.js as an example. In this course, we will mostly be using network and database I/O rather than reading files from disk, but the principles are the same. (The examples are all in ~cs304node/apps/fs-examples/, and you're welcome to try them. I'll demo them all in class.)

Four ways to do I/O

We're going to discuss four ways to do I/O from the file system in Node.js

  1. synchronous I/O
  2. asynchronous using callbacks
  3. asynchronous using promises
  4. asynchronous using async/await

I've stored four lines of a poem in four different files. Here's the poem, with the lines numbered:

Three rings for the Elven-kings under the sky,
Seven for the Dwarf-lords in their halls of stone,
Nine for Mortal Men, doomed to die,
One for the Dark Lord on his dark throne

We'll read the contents of each file in a different way, corresponding to the four ways listed above.

Consider the following example code. Read over it to learn what you can, then we'll go through it piece by piece. The basic idea is that each function reads one line of of a poem from a file (each reading from a different file) and printing that one line. If we run them all consecutively, we'll get the poem printed.

fs = require('node:fs');
fsp = require('node:fs/promises');

function readMyFile1() {
    let data = fs.readFileSync('line1.text', {encoding: 'UTF8'});
    console.log('1', data);
}

function readMyFile2() {
    let cb = (err, data) => {
        if(err) {
            console.err('error reading file', err);
        } else {
            console.log('2', data);
        }
    };
    fs.readFile('line2.text', {encoding: 'UTF8'}, cb);
}

function readMyFile3() {
    let cont = (data) => {
        console.log('3', data);
    };
    let err = (err) => {
        console.err('error reading data', err);
    };
    let p = fsp.readFile('line3.text', {encoding: 'UTF8'});
    console.log('3', p);
    p.then(cont).catch(err);
}

async function readMyFile4() {
    let data = await fsp.readFile('line4.text', {encoding: 'UTF8'});
    console.log('4', data);
}

console.log('1. old-fashioned synchronous, blocking I/O');
readMyFile1();
console.log('2. async with callbacks');
readMyFile2();
console.log('3. async with promises');
readMyFile3();
console.log('4. async with async/await');
readMyFile4();

The first two lines of the file are how we load modules in node.js. This is similar to importing Python packages, if you're familiar with that. We'll talk more about modules later. The return value from require is a kind of JS dictionary containing functions that we can use.

The next function is very simple and is just like the computeCircleArea() function earlier:

function readMyFile1() {
    let data = fs.readFileSync('line1.text', {encoding: 'UTF8'});
    console.log('1', data);
}

This uses synchronous I/O, and when we run the function, it stops until the file is read, then the data is returned and we can print it. (We put in the '1' so that we know which console.log did the printing.) The "encoding" argument just says how to interpret the bytes in the file, so that we can get a JavaScript string as the value.

The next function uses asynchronous I/O via readFile, which therefore takes a callback function, here called cb. By convention in Node.js, the callback function takes two arguments: the first is an error argument, which is false when there's no error, otherwise it's a description of the error. The second is the data, if any. Here, our callback function just prints the error, if any, or the data.

function readMyFile2() {
    let cb = (err, data) => {
        if(err) {
            console.err('error reading file', err);
        } else {
            console.log('2', data);
        }
    };
    fs.readFile('line2.text', {encoding: 'UTF8'}, cb);
}

So, while the code is longer, it's still conceptually pretty simple. It does, however, mean we have to rearrange the code a bit. Anything we want to do after (as a consequence of) the file I/O has to be in the callback. In other words, any code that uses the data that was read from the file has to be in the callback function.

Let's dwell on that for a moment. Suppose we add a print statement after the readFile, like this:

function readMyFile2() {
    let cb = (err, data) => {
        if(err) {
            console.err('error reading file', err);
        } else {
            console.log('2', data);
        }
    };
    fs.readFile('line2.text', {encoding: 'UTF8'}, cb);
    console.log('after readFile');
}

The print statement would happen before the I/O completes and therefore would be printed before the line that we read from the file! This is very weird.

Event Loop

This is a good time to look back at the first few sections of the MDN article on the EventLoop

Here's the general picture:

The JavaScript Event Loop architecture has a stack, a heap and a message queue where pending operations live.

(Aside: the picture above uses some terminology which you may have learned or will learn in CS 240. Briefly, the heap is memory where objects are allocated from and indeed, any global values, including functions. The stack is memory where information for currently executing functions are stored, including their arguments and local variable values. The queue is not discussed in CS 240, because it's for Event-Loop architectures. It's memory where future computations, like promises, are put.

When the fs.readFile is executed, it's on the stack, and it puts the callback function, cb on the message queue. The cb function gets executed later, when the I/O completes. Meanwhile fs.readFile returns and the rest of readMyFile2() is executed, including the print statement, so the state of the computer is like this:

The callback function cb waits on the message queue for the I/O to complete.

Essentially, we have chopped up our function into two pieces, the callback function and the main function. The main function executes to completion, and the callback function executes at some later time.

Programming with callbacks works, but it can be difficult. If we want to do anything with the result of the I/O, we have to put it in the callback. So, our computation of the area of the circle would become (if there were a callback-style equivalent to prompt):

function computeCircleArea() {
    let radius = prompt('what radius?', (radius) => {
                let area = Math.PI * radius * radius;
                console.log(area);
                       });
 }

Which isn't too bad. If we wanted to read a line from the poem and print it, we could do it like this:

fs = require('node:fs');

function poem1() {
   fs.readFile('line1.text',
                {encoding: 'UTF8'},
                (err, data) => {
                    console.log('1', data);
                    });
}

Again; not too bad. But if we wanted to read and print all four lines of the poem, we'd have do to the following, which is a nightmare:

fs = require('node:fs');

function printPoem() {
    fs.readFile('line1.text',
                {encoding: 'UTF8'},
                (err, data) => {
                    console.log('1', data);
                    fs.readFile('line2.text',
                                {encoding: 'UTF8'},
                                (err, data) => {
                                    console.log('2', data);
                                    fs.readFile('line3.text',
                                                {encoding: 'UTF8'},
                                                (err, data) => {
                                                    console.log('3', data);
                                                    fs.readFile('line4.text',
                                                                {encoding: 'UTF8'},
                                                                (err, data) => {
                                                                    console.log('4', data);
                                                                });
                                                });
                                });
                });
}

printPoem();

Believe it or not, that code works! You can run it in poem-using-callbacks.js But this kind of code is described as "callback hell" or "pyramid of doom".

Two solutions have evolved to deal with callback hell, namely promises and async/await. Let's start with promises.

Promises

A promise is a representation of an unfinished bit of computation. You can think of it as like the elements in the message queue, waiting for the I/O to complete. A promise has several states, namely:

  • pending (not yet completed)
  • fulfilled
  • rejected

Using methods on the promise object, we can say "hey, when this promise completes, please execute this function." If this reminds you of attaching event handlers in the browser, it should; it's the same idea. The methods are:

  • .then(handleFulfilled, handleRejected) which executes handleFulfilled when/if the promise is fulfilled successfully and handleRejected otherwise. The second argument is optional
  • .catch(func) which executes func when/if the promise fails (is rejected).

These can be chained; we'll see that in a bit.

Let's look at a way to do file I/O using promises. That's our third function readMyFile3()

function readMyFile3() {
    let cont = (data) => {
        console.log('3', data);
    };
    let eh = (err) => {
        console.err('error reading data', err);
    };
    let p = fsp.readFile('line3.text', {encoding: 'UTF8'});
    console.log('3', p);
    p.then(cont).catch(eh);
}

The return value, p, from fsp.readFile() is a promise. When we print it out, the file I/O hasn't completed yet, so it will say Promise { <pending> }. We can use the .then method to say "when this promise resolves, execute cont. The cont function is the continuation of our computation. If an error occurs, we can attach an error handler using .catch(). The last line of the function demonstrates both.

Here's the output of just that one function:

3 Promise { <pending> }
3 Nine for Mortal Men, doomed to die,

Notice that the first print statement shows that p is a promise and it's pending (not yet completed). When it completes successfully, the cont arrow function is executed, printing the line of the poem.

Promises work very well and gives us a lot of expressive power. We'll return to it later in the course. It also helps avoid callback hell. Here's how to print the entire poem using just a chain of promises and then:

fsp = require('node:fs/promises');

function printPoem() {
    fsp.readFile('line1.text',
                 {encoding: 'UTF8'})
        .then( (data) => console.log('1', data))
        .then( () => fsp.readFile('line2.text',
                                 {encoding: 'UTF8'}))
        .then( (data) => console.log('2', data))
        .then( () => fsp.readFile('line3.text',
                                 {encoding: 'UTF8'}))
        .then( (data) => console.log('3', data))
        .then( () => fsp.readFile('line4.text',
                                 {encoding: 'UTF8'}))
        .then( (data) => console.log('4', data));
}

printPoem();

This is a great improvement, but still difficult to program in. We have to cut up our program into little event-handling chunks. It would be nice if there were a syntactic way to do that. That leads us to async/await, which was introduced just a few years after promises.

Async and Await

The await operator is used to wait for a Promise and get its fulfillment value. It can only be used inside an async function or at the top level of a module. You can read more about await

Here it is in action:

async function readMyFile4() {
    let data = await fsp.readFile('line4.text', {encoding: 'UTF8'});
    console.log('4', data);
}

The await operator is used when the expression is returning a promise. It tells the compiler to chop up the rest of the function into a callback attached (using then) to the fulfillment of the promise. The resulting function, however, is still not synchronous, so we have to mark it as such. Hence the outer async keyword that precedes the function keyword.

The resulting code feels much more like the synchronous code that we are used to, and it completely solves the callback hell problem. Here's how we can read and print all four lines of the poem using just await

fsp = require('node:fs/promises');

async function readPoem() {
    let line1 = await fsp.readFile('line1.text', {encoding: 'UTF8'});
    console.log('1', line1);
    let line2 = await fsp.readFile('line2.text', {encoding: 'UTF8'});
    console.log('2', line2);
    let line3 = await fsp.readFile('line3.text', {encoding: 'UTF8'});
    console.log('3', line3);
    let line4 = await fsp.readFile('line4.text', {encoding: 'UTF8'});
    console.log('4', line4);
}

console.log('using async/await');
readPoem();

Whew! That's so much easier.

For the rest of this course, we'll use async/await as appropriate, and only use promises and callbacks when necessary or when they give us an advantage.

When we start learning MongoDB, many of the database operations are asynchronous I/O, which means that you'll have to get in the habit of using await with them.

Async Contagion

It's important to remember that, even though using await makes coding more like synchronous coding, it's still asynchronous. If we have an asynchronous function that returns some value:

async function foo() {
   let y = await ...;
   return y;
};

the caller must use await when calling foo. And that means that the caller is now an asynchronous function:

async function bar() {
   let z = await foo();
   ...
}

In that sense, asynchrony is contagious.

Browser Examples

Before we start seeing examples of asynchronous functions using Node.js in the back end, let's see it in the browser, where we've been working so far in the course.

If our JS code in the browser wants to retrieve some data from a server, it can do so using jQuery. (There's another API using fetch as well, but let's stick to jQuery for now.)

I've implemented a server that will respond with a list of multiple-choice questions upon request. But, because it has to communicate across a network, this response is necessarily asynchronous. Here's how we can request the entire list using a callback:

$.get("https://cs.wellesley.edu/cs304node-questions/questions/", 
       (resp) => { console.log('resp', resp); });

Here's how we can retrieve the data using promises and then:

$.get("https://cs.wellesley.edu/cs304node-questions/questions/")
    .then( (resp) => { console.log('resp', resp); });

Here, we can barely see a difference between callbacks and promises, but we know from our earlier discussion that if we wanted to do a series of requests, each following on the previous (like lines in a poem), the first way leads to callback hell while the promises approach scales much better.

Finally, let's see it with await:

async function getQuestions() {
    let data = await $.get("https://cs.wellesley.edu/cs304node-questions/questions/");
    console.log(data);
} 

The Fetch API

Modern browsers also implement the fetch function, which returns a Promise object, just like jQuery's .get method. The fetch API is built into the browser, so you don't have to use jQuery:

p = fetch("https://cs.wellesley.edu/cs304node-questions/questions/");
console.log(p);
p.then((resp) => { console.log(resp); });

However, as you've seen, the return value from fetch is not the data we want, but a response object. Here, we can use the .json() method, which returns another promise that resolves to the data we want, chaining that second then onto the first one:

p = fetch("https://cs.wellesley.edu/cs304node-questions/questions/");
console.log(p);
p.then((resp) => resp.json())
 .then((data) => { console.log(data); });

Here's a slightly more succinct way of doing it:

fetch("https://cs.wellesley.edu/cs304node-questions/questions/")
    .then((resp) => resp.json())
    .then((data) => { console.log(data); });

And, with await:

async function getData() {
    let resp = await fetch("https://cs.wellesley.edu/cs304node-questions/questions/");
    let data = await resp.json();
    console.log('got', data.length, 'questions');
    console.log(data);
}

You might think that you can now return data from getData but async functions always return promises. So, even in the caller, you'd have to use await and so forth. Again, once we're in the promised land, we stay there.

Here's some reference material on the Fetch API. Notice the example and the comparison to jQuery.

Node.js

Like Python, Java and other languages you may be familiar with, running code on the server requires running a program that runs a file of your code, though you probably have never done that in your Wellesley classes. For Node.js, we can put a file of JavaScript code in a file called, say, myFile.js and run it like this:

node myFile.js

The node program runs our JavaScript code.

Node Shell

Like Python, we can just run node by itself (without a file of code) to get an interaction loop, where we can try code. Again, this is just like Python, but it's JavaScript, running on the server rather than in a browser. Run it with the node command, and exit by typing control-d.

$ node
> 3+4
7
> function foo(x,y) { return x+y; }
undefined
> foo(3,4)
7
> ^d
$

Later in the course, but very soon, we will be building web servers using node to execute JavaScript files on Tempest.

Asynchronous I/O in Other Languages

Lest you think that JavaScript and asynchronous I/O are just weird (which would be completely understandable), you should know that Python added async and await for asynchronous I/O in version 3.4, in 2013. See asyncio. The syntax is shockingly similar to what JavaScript has.

Node.js Resources

If you want to learn more, the following is a good start: Node Beginner, which I've cribbed some from.

Summary

  • Node.js uses an event loop architecture.
  • Node.js uses asynchronous I/O using promises
  • The JavaScript language has Promises to represent pending computations or I/O
  • We can attach a callback function to be executed once the Promise is settled by using the then method on a Promise.
  • The await keyword can be used to syntactically set up code to run when a promise is resolved.