Asynchronous Functions
Learning objectives
- You know what synchronous and asynchronous functions are and how they work.
- You have a basic idea of the execution order of asynchronous functions.
- You can create synchronous and asynchronous functions.
The difference between synchronous and asynchronous functions is that when a synchronous function is called from program code, the execution of the code from where the function is called waits until the synchronous function has finished execution. For asynchronous functions, the execution of code from where the asynchronous function is called does not need to wait and can continue execution.
JavaScript has both synchronous and asynchronous functions.
Defining asynchronous functions
Asynchronous functions are defined using the async
keyword that is placed before the parameters in the function definition. The following is an example of an asynchronous function that waits for the amount of seconds given as a parameter (don't worry about the function itself, we'll use it just for an example) and then logs the message to the console.
const waitAndPrint = async(message, seconds) => {
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
console.log(message);
}
In the following example, we call the asynchronous function three times.
const waitAndPrint = async (message, seconds) => {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
console.log(message);
};
waitAndPrint("First call!", 3);
waitAndPrint("Second call!", 2);
waitAndPrint("Third call!", 1);
When we run the program, we see the following output. The line with Third call!
takes approximately one second to appear, the line with Second call!
takes approximately two seconds to appear, and the line with First call!
takes approximately three seconds to appear.
deno run app.js
Third call!
Second call!
First call!
In practice, all of the functions are executed simultaneously, which leads to a situation where the total time that it takes to execute the program is approximately three seconds.
Waiting for an asynchronous function
Now, if we would want to wait in our program until the asynchronous function has completed the execution, we'd have to use the await
keyword when calling the function. In the following example, we wait for the first waitAndPrint
function call to finish before we continue with the execution of the program.
const waitAndPrint = async (message, seconds) => {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
console.log(message);
};
await waitAndPrint("First call!", 3);
waitAndPrint("Second call!", 2);
waitAndPrint("Third call!", 1);
Now, the output of the program is be as follows.
deno run app.js
First call!
Third call!
Second call!
Note that contrary to the previous example, it would take approximately 3 seconds for the First call!
to appear. After this, it would take approximately 1 second for the Third call!
to appear (a total of 4 seconds), and approximately 2 seconds for the Second call!
to appear (a total of 5 seconds).
Similarly, we could wait for each call in turn to finish before moving to the next call.
const waitAndPrint = async (message, seconds) => {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
console.log(message);
};
await waitAndPrint("First call!", 3);
await waitAndPrint("Second call!", 2);
await waitAndPrint("Third call!", 1);
Now, the output of the program would be as follows.
deno run app.js
First call!
Second call!
Third call!
In the above example, it would take approximately 3 seconds for the First call!
to appear, after which it would take approximately 2 seconds for the Second call!
to appear (a total of 5 seconds). After this, it would take approximately 1 second for the Third call!
to appear (a total of 6 seconds).
Await within a function
When we use await
within a function, we need to define the function in which await
is used as asynchronous. The following example shows a program where await
is used within a function that is not asynchronous -- the example does not work.
const waitAndPrint = async (message, seconds) => {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
console.log(message);
};
const example = () => {
await waitAndPrint("First call!", 3);
await waitAndPrint("Second call!", 2);
await waitAndPrint("Third call!", 1);
};
example();
When we run the program, we see the following output.
deno run app.js
error: Uncaught SyntaxError: Unexpected reserved word
await waitAndPrint('First call!', 3);
~~~~~
at <anonymous> (file:///path-to-file/app.js:7:3)
Changing the function to an asynchronous function solves the problem.
const waitAndPrint = async (message, seconds) => {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
console.log(message);
};
const example = async () => {
await waitAndPrint("First call!", 3);
await waitAndPrint("Second call!", 2);
await waitAndPrint("Third call!", 1);
};
example();
deno run app.js
First call!
Second call!
Third call!
Function execution order
When working with synchronous functions, determining the execution order of a set of functions was easy. Whenever a function was called, it was placed on the top of the call stack, and whatever function was at the top of the call stack was executed.
When working with asynchronous functions, whenever an asynchronous function is called, it is also placed on top of the call stack. However, instead of waiting for the function to be completed, a promise of the completion of the function is returned and the responsibility for the execution of the asynchronous function is given to a corresponding Web Platform API, which can be used to e.g. execute code in a separate thread.
Whenever an asynchronous function has been executed (e.g. by a Web API), the associated (completed) promise is placed into a queue, which is processed by the JavaScript engine. If the queue holds completed promises, they are eventually brought back to the call stack and processed; oldest completed promises are given priority over new ones. Note that the when and how asynchronous functions are executed partially depend on the used JavaScript engine (V8 for Deno).
In general, if the await
keyword is used, the execution of the program waits until the promise has been fulfilled before continuing with the execution. On the other hand, if asynchronous functions are called without the await
keyword, the program execution does not wait for the function to finish; in such a case, one cannot always determine the order in which the function calls will be completed.
As an example, the output of the program below depends on the duration of the (simulated) heavy calculation.
const simulatedHeavyCalculation = async () => {
await new Promise((resolve) => setTimeout(resolve, Math.random() * 100));
};
const logMessage = async (message) => {
await simulatedHeavyCalculation();
console.log(message);
};
logMessage("Hello world!");
logMessage("Hello world again!");
deno run app.js
Hello world again!
Hello world!
Promises and callbacks
The keywords async
and await
are an updated take on working with Promises, which are used to represent the eventual execution of asynchronous functions. Before the introduction of async
and await
, one wrote asynchronous functions with promises and the keyword then
, which was passed a function that was to be completed after the promise was fulfilled. As an example, the above application could be implemented without async
and await
as follows.
const simulatedHeavyCalculation = () => {
return new Promise((resolve) => setTimeout(resolve, Math.random() * 100));
};
const logMessage = (message) => {
simulatedHeavyCalculation().then(() => {
console.log(message);
});
};
logMessage("Hello world!");
logMessage("Hello world again!");
In the above example, the logMessage
function calls the simulatedHeavyCalculation
function, which returns a promise. Promises have a method then
, which is given a function that is called once the promise is fulfilled.
Before promises, the same effect was realized by giving functions as parameters to the code that was to be completed. In the example below, the function simulatedHeavyCalculation
is given a callback function as a parameter, which is to be called once the execution of the function is completed.
The setTimeout
function that we have used for demonstration purposes effectively works so that it is given a function -- the first parameter -- that is called after a specific amount of milliseconds -- the second parameter -- has elapsed.
const simulatedHeavyCalculation = (callback) => {
setTimeout(callback, Math.random() * 100);
};
const logMessage = (message) => {
simulatedHeavyCalculation(() => {
console.log(message);
});
};
logMessage("Hello world!");
logMessage("Hello world again!");
In practice, when working with more complex code, this approach often led to a so-called callback hell, where understanding (and debugging) the flow of the code was not trivial. In these materials, we mostly stick with more up to date conventions for writing asynchronous code, in this case using async
and await
.