Today, in the seventh part of the Node.js tutorial, we will talk about asynchronous programming, consider issues such as the use of callbacks, promises, and async / await constructs, and discuss work with events.

[We advise to read] Other parts of the cyclePart 1:
General Information and Getting StartedPart 2:
JavaScript, V8, some design tricksPart 3:
Hosting, REPL, work with the console, modulesPart 4:
npm, package.json and package-lock.json filesPart 5:
npm and npxPart 6:
event loop, call stack, timersPart 7:
Asynchronous ProgrammingPart 8:
Node.js Guide, part 8: HTTP and WebSocket protocolsPart 9:
Node.js Guide, part 9: working with the file systemPart 10:
Node.js Guide, part 10: standard modules, streams, databases, NODE_ENVFull PDF Node.js Manual Asynchrony in programming languages
By itself, JavaScript is a synchronous single-threaded programming language. This means that in the code you cannot create new threads that run in parallel. However, computers, by their nature, are asynchronous. That is, some actions can be performed independently of the main program flow. In modern computers, each program is allocated a certain amount of processor time, when this time expires, the system gives resources to another program, also for a while. Such switchings are performed cyclically, this is done so quickly that a person simply cannot notice this, as a result we think that our computers run many programs at the same time. But this is an illusion (if not to talk about multiprocessor machines).
')
In the depths of programs, interrupts are used - signals transmitted by the processor and allowing the system to attract attention. We will not go into details, the most important thing - remember that asynchronous behavior, when program execution is suspended until the moment when it needs processor resources, is completely normal. At the time when the program does not load the system with work, the computer can solve other problems. For example, with this approach, when a program waits for a response to a network request made to it, it does not block the processor until the response is received.
As a rule, programming languages ​​are asynchronous, some of them give the programmer the ability to control asynchronous mechanisms using either built-in language tools or specialized libraries. We are talking about languages ​​such as C, Java, C #, PHP, Go, Ruby, Swift, Python. Some of them allow programming in asynchronous style using threads, starting new processes.
Javascript asynchrony
As already mentioned, JavaScript is a single-threaded synchronous language. Lines of code written in JS are executed in the order in which they appear in the text, one after another. For example, here is a completely ordinary JS program that demonstrates this behavior:
const a = 1 const b = 2 const c = a * b console.log(c) doSomething()
But JavaScript was created for use in browsers. His main task, at the very beginning, was to organize the processing of events related to the user's activities. For example, these are events such as
onClick
,
onMouseOver
,
onChange
,
onSubmit
, and so on. How to solve similar problems within the framework of the synchronous programming model?
The answer lies in the environment in which JavaScript works. Namely, the browser can effectively solve such tasks, giving the programmer the appropriate API.
The Node.js environment has the means to perform non-blocking I / O operations, such as working with files, organizing data exchange over a network, and so on.
Callbacks
If we talk about browser-based JavaScript, then it can be noted that it is impossible to know in advance when the user clicks on a certain button. In order to ensure that the system responds to such an event, a handler is created for it.
The event handler accepts a function that will be called when an event occurs. It looks like this:
document.getElementById('button').addEventListener('click', () => { // })
Such functions are also called callback functions or callbacks.
A callback is a normal function that is passed, as a value, to another function. It will be caused only in the event that an event occurs. JavaScript implements the concept of first-class functions. Such functions can be assigned to variables and transferred to other functions (called higher order functions).
In client-side JavaScript-development, there is a common approach when all client code wraps a listener in the
load
event of the
window
object, which the callback passed to it after the page is ready for work:
window.addEventListener('load', () => { // // })
Callbacks are used everywhere, and not just for handling DOM events. For example, we have already met with their use in timers:
setTimeout(() => {
Callbacks are also used in XHR queries. In this case, it looks like the function assignment to the corresponding property. A similar function will be called when a specific event occurs. In the following example, such an event is a change in the request status:
const xhr = new XMLHttpRequest() xhr.onreadystatechange = () => { if (xhr.readyState === 4) { xhr.status === 200 ? console.log(xhr.responseText) : console.error('error') } } xhr.open('GET', 'https://yoursite.com') xhr.send()
Error handling in callbacks
Let's talk about how to handle errors in callbacks. There is one common strategy for handling such errors, which is also used in Node.js. It is that the first parameter of any callback function is to make the object an error. If there are no errors, the value
null
will be written to this parameter. Otherwise, there will be an error object containing its description and additional information about it. Here's what it looks like:
fs.readFile('/file.json', (err, data) => { if (err !== null) { // console.log(err) return } // , console.log(data) })
Callback problem
Callbacks are convenient to use in simple situations. However, each callback is an additional level of code nesting. If several nested callbacks are used, this quickly leads to a significant complication of the code structure:
window.addEventListener('load', () => { document.getElementById('button').addEventListener('click', () => { setTimeout(() => { items.forEach(item => { //, - }) }, 2000) }) })
In this example, only 4 levels of code are shown, but in practice you can encounter a large number of levels, usually referred to as “callback hell”. You can cope with this problem using other language constructs.
Promises and async / await
Starting with the ES6 standard in JavaScript, new features appear that make it easier to write asynchronous code, allowing you to do without callbacks. These are the promises that appeared in ES6, and the async / await construction that appeared in ES8.
Promises
Promises (promise objects) are one of the ways to work with asynchronous software constructs in JavaScript, which, in general, reduces the use of callbacks.
Meet the promises
Promises are usually defined as proxies for some values ​​that are expected to appear in the future. Promises are also called “promises” or “promised results.” Although this concept has been around for many years, promises have been standardized and added to the language only in ES2015. In ES2017, an async / await design appeared, which is based on promises, and which can be considered as their convenient replacement. Therefore, even if you do not plan to use conventional promises, understanding how they work is important for the effective use of the async / await construction.
How do promises
After calling the promise, it enters the pending state. This means that the function that caused the promis continues to be performed, while some calculations are performed in the promis, after which the promis reports this. If the operation that the promise performs completes successfully, the promise is set to the state “fulfilled”. Such a promise is said to be successfully resolved. If the operation completes with an error, the promise is rejected.
Let's talk about working with promises.
Creating promises
The API for working with promises is given by the corresponding constructor, which is called by a command like
new Promise()
. This is how promises are created:
let done = true const isItDoneYet = new Promise( (resolve, reject) => { if (done) { const workDone = 'Here is the thing I built' resolve(workDone) } else { const why = 'Still working on something else' reject(why) } } )
Promis checks the global constant
done
, and if its value is
true
, it is successfully resolved. Otherwise, the promis is rejected. Using the
resolve
and
reject
parameters, which are functions, we can return values ​​from the promise. In this case, we return a string, but an object can be used here.
Work with promises
Above, we created a promise, now consider working with it. It looks like this:
const isItDoneYet = new Promise( //... ) const checkIfItsDone = () => { isItDoneYet .then((ok) => { console.log(ok) }) .catch((err) => { console.error(err) }) } checkIfItsDone()
Calling
checkIfItsDone()
will lead to the implementation of the isItDoneYet
isItDoneYet()
and to the organization of waiting for its resolution. If the promise resolves successfully, the callback passed to the
.then()
method will work. If an error occurs, that is, the promise will be rejected, it can be processed in the function passed to the
.catch()
method.
Combination of promises in chains
Promise methods return promises, which allows them to be chained. A good example of this behavior is the browser
API Fetch , which is a level of abstraction over
XMLHttpRequest
. There is a fairly popular npm package for Node.js that implements the Fetch API, which we will look at later. This API can be used to load certain network resources and, thanks to the possibility of combining promises in chains, to organize the subsequent processing of downloaded data. In fact, when accessing the API Fetch, performed by calling the function
fetch()
, a promise is created.
Consider the following example of combining promises in chains:
const fetch = require('node-fetch') const status = (response) => { if (response.status >= 200 && response.status < 300) { return Promise.resolve(response) } return Promise.reject(new Error(response.statusText)) } const json = (response) => response.json() fetch('https://jsonplaceholder.typicode.com/todos') .then(status) .then(json) .then((data) => { console.log('Request succeeded with JSON response', data) }) .catch((error) => { console.log('Request failed', error) })
Here we use the
node-fetch npm package and the
jsonplaceholder.typicode.com resource as a source of JSON data.
In this example, the
fetch()
function is used to load a TODO list item using a chain of promises. After doing
fetch()
, an
answer is returned that has many properties, among which we are interested in the following:
status
is a numeric value representing the HTTP status code.statusText
is a textual description of the HTTP status code, which is represented by the string OK
in the event that the request was successful.
The
response
object has a
json()
method that returns a promise, which, if resolved, returns the processed contents of the request body, in JSON format.
Given the above, we describe what happens in this code. The first promis in the chain is represented by the
status()
function that we declared, which checks the response status, and if it indicates that the request failed (that is, the HTTP status code is not between 200 and 299), rejects the promise. This operation leads to the fact that other
.then()
expressions in the promise chain are not executed and we immediately get into the
.catch()
method, displaying the text
Request failed
, along with an error message.
If the HTTP status code suits us, the declared
json()
function is called. Since the previous promise, when successfully resolved, returns a
response
object, we use it as an input value for the second promise.
In this case, we are returning the processed JSON data, so the third promise receives it, after which they, preceded by a message stating that the necessary data could be obtained as a result of the request, are output to the console.
Error processing
In the previous example, we had a
.catch()
method attached to a chain of promises. If something in the promise chain goes wrong and an error occurs, or if one of the promises is rejected, control is passed to the nearest
.catch()
expression. Here is the situation when an error occurs in the promise:
new Promise((resolve, reject) => { throw new Error('Error') }) .catch((err) => { console.error(err) })
Here is an example of triggering
.catch()
after rejecting the promise:
new Promise((resolve, reject) => { reject('Error') }) .catch((err) => { console.error(err) })
Cascade error handling
What if an error occurs in the
.catch()
expression? To handle this error, you can include another
.catch()
expression in the promis chain (and then you can attach as many more
.catch()
expressions to the chain as you need):
new Promise((resolve, reject) => { throw new Error('Error') }) .catch((err) => { throw new Error('Error') }) .catch((err) => { console.error(err) })
Now consider several useful methods used to manage promises.
Promise.all ()
If you need to perform an action after resolving several promises, you can do this with the help of the
Promise.all()
command. Consider an example:
const f1 = fetch('https://jsonplaceholder.typicode.com/todos/1') const f2 = fetch('https://jsonplaceholder.typicode.com/todos/2') Promise.all([f1, f2]).then((res) => { console.log('Array of results', res) }) .catch((err) => { console.error(err) })
In ES2015, the destructive assignment syntax has appeared, with its use you can create constructions of the following form:
Promise.all([f1, f2]).then(([res1, res2]) => { console.log('Results', res1, res2) })
Here we, as an example, considered API Fetch, but
Promise.all()
, of course, allows you to work with any promises.
Promise.race ()
The
Promise.race()
command allows you to perform a specified action after one of the promises passed to it is allowed. The corresponding callback containing the results of this first promise is called only once. Consider an example:
const first = new Promise((resolve, reject) => { setTimeout(resolve, 500, 'first') }) const second = new Promise((resolve, reject) => { setTimeout(resolve, 100, 'second') }) Promise.race([first, second]).then((result) => { console.log(result)
Error Uncaught TypeError, which occurs when working with promises
If, while working with promises, you encounter an
Uncaught TypeError: undefined is not a promise
, check that the
new Promise()
construct is used when creating promises and not just
Promise()
.
As Async / await design
The async / await design is a modern approach to asynchronous programming, simplifying it. Asynchronous functions can be represented as a combination of promises and generators, and, in general, this design is an abstraction over promises.
The async / await design allows you to reduce the amount of template code that you have to write when working with promises. When promises appeared in the ES2015 standard, they were aimed at solving the problem of creating asynchronous code. They coped with this task, but after two years separating the output of the ES2015 and ES2017 standards, it became clear that they could not be considered the final solution to the problem.
One of the problems that promises solved was the famous “hell callbacks”, but they, solving this problem, created their own problems of a similar nature.
Promises were simple constructions around which one could build something with a simpler syntax. As a result, when the time came, the async / await construction appeared. Its use allows you to write code that looks like synchronous, but is asynchronous, in particular, does not block the main thread.
How the async / await construction works
The asynchronous function returns a promis, as, for example, in the following example:
const doSomethingAsync = () => { return new Promise((resolve) => { setTimeout(() => resolve('I did something'), 3000) }) }
When you need to call a similar function, you need to put the
await
keyword before the command to call it. This will cause the code calling it to wait for permission or rejection of the corresponding promise. It should be noted that the function in which the
await
keyword is used must be declared using the
async
:
const doSomething = async () => { console.log(await doSomethingAsync()) }
Combine the above two code snippets and examine its behavior:
const doSomethingAsync = () => { return new Promise((resolve) => { setTimeout(() => resolve('I did something'), 3000) }) } const doSomething = async () => { console.log(await doSomethingAsync()) } console.log('Before') doSomething() console.log('After')
This code will output the following:
Before After I did something
The text
I did something
will fall into the console with a delay of 3 seconds.
About promises and asynchronous functions
If you declare a certain function using the
async
, it will mean that such a function will return a promise even if it is not explicitly done. That is why, for example, the following example is a working code:
const aFunction = async () => { return 'test' } aFunction().then(console.log)
This construction is similar to this:
const aFunction = async () => { return Promise.resolve('test') } aFunction().then(console.log)
Async / await strengths
Analyzing the above examples, you can see that the code that uses async / await is simpler than the code that uses the combination of promises in chains, or code based on callback functions. Here we, of course, considered very simple examples. You can fully experience the above benefits by working with much more complex code. Here, for example, how to load and parse JSON data using promises:
const getFirstUserData = () => { return fetch('/users.json') // .then(response => response.json()) // JSON .then(users => users[0]) // .then(user => fetch(`/users/${user.name}`)) // .then(userResponse => userResponse.json()) // JSON } getFirstUserData()
Here is the solution to the same problem using async / await:
const getFirstUserData = async () => { const response = await fetch('/users.json') // const users = await response.json() // JSON const user = users[0] // const userResponse = await fetch(`/users/${user.name}`) // const userData = await userResponse.json() // JSON return userData } getFirstUserData()
Using sequences from asynchronous functions
Asynchronous functions can easily be combined into constructions resembling a chain of promises. The results of this combination, however, are much better readability:
const promiseToDoSomething = () => { return new Promise(resolve => { setTimeout(() => resolve('I did something'), 10000) }) } const watchOverSomeoneDoingSomething = async () => { const something = await promiseToDoSomething() return something + ' and I watched' } const watchOverSomeoneWatchingSomeoneDoingSomething = async () => { const something = await watchOverSomeoneDoingSomething() return something + ' and I watched as well' } watchOverSomeoneWatchingSomeoneDoingSomething().then((res) => { console.log(res) })
This code will display the following text:
I did something and I watched and I watched as well
Simplified Debugging
Promises are difficult to debug, since using them you cannot effectively use the usual tools of the debugger (like “walk-by-step”, step-over). The code written using async / await can be debugged using the same methods as regular synchronous code.
Generating events in Node.js
If you worked with JavaScript in the browser, then you know that events play a huge role in the processing of user interactions with pages. We are talking about handling events caused by clicks and mouse movements, keystrokes on the keyboard, and so on. In Node.js, you can work with events that the programmer creates on his own. Here you can create your own event system using the
events module. In particular, this module offers us the
EventEmitter
class, the capabilities of which can be used to organize work with events. Before using this mechanism, you need to connect it:
const EventEmitter = require('events').EventEmitter
When working with it,
on()
and
emit()
methods are available to us, among others. The
emit
method
emit
used to
emit
events. The
on
method is used to set up callbacks, event handlers that are called when a particular event is called.
For example, let's create a
start
event. When it happens, we will output something to the console:
eventEmitter = new EventEmitter(); eventEmitter.on('start', () => { console.log('started') })
In order to trigger this event, use the following construction:
eventEmitter.emit('start')
As a result of the execution of this command, an event handler is called and the
started
line gets into the console.
You can pass arguments to the event handler, representing them as additional arguments to the
emit()
method:
eventEmitter.on('start', (number) => { console.log(`started ${number}`) }) eventEmitter.emit('start', 23)
Similarly, they act in cases when the handler needs to pass several arguments:
eventEmitter.on('start', (start, end) => { console.log(`started from ${start} to ${end}`) }) eventEmitter.emit('start', 1, 100)
Objects of the
EventEmitter
class have several other useful methods:
once()
- allows to register an event handler that can be called only once.removeListener()
- allows you to remove the handler passed to it from the handler array of the event passed to it.removeAllListeners()
- allows you to remove all handlers of the event passed to it.
Results
Today we talked about asynchronous programming in JavaScript, in particular, we discussed callbacks, promises, and the async / await construct. We also touched upon the issue of working with events described by the developer using the
events
module. Our next topic will be the mechanisms for networking the Node.js platform.
Dear readers! Do you use the async / await construct when programming for Node.js?