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:
- The about Node.js page from their website. It's a concise 3 pages or so.
- The discussion of blocking versus non-blocking page from their website. It's about 5 pages and covers some important concepts.
- The following is about the event-loop in JavaScript. It's pretty succinct and clear: Concurrency Model and the Event Loop.
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
- synchronous I/O
- asynchronous using callbacks
- asynchronous using promises
- 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:
(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:
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 executeshandleFulfilled
when/if the promise is fulfilled successfully andhandleRejected
otherwise. The second argument is optional.catch(func)
which executesfunc
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.