This blog post is part of the series “Learning web development” – which teaches people who have never programmed how to create web apps with JavaScript.
To download the projects, go to the GitHub repository learning-web-dev-code
and follow the instructions there.
I’m interested in feedback! If there is something you don’t understand, please write a comment at the end of this page.
In this chapter, we learn how to handle tasks that take a long time to complete – think downloading a file. The mechanisms for doing that, Promises and async functions are an important foundation of JavaScript and enable us to do a variety of interesting things.
This chapter tackles some challenging topics. You may not immediately understand everything. However, that is normal:
A queue is a data structure to which we add values, which we then retrieve from it later. It works like a queue of people in front of a ticket booth: The first value we retrieve is the first value that was added. The second value we retrieve is the second value that was added. Etc. That’s why a queue is also called a FIFO data structure: First In First Out. JavaScript arrays can be used as queues, via the following two methods:
array.push(v)
adds a value v
to the end of the array.array.shift()
retrieves and removes the first element of the array.This is again similar to queues of humans: People join the queue at the end and leave it once they are first in line.
The following code demonstrates how we can use an array as a queue:
const queue = [];
queue.push('a');
queue.push('b');
assert.deepEqual(
queue, ['a', 'b']
);
assert.equal(
queue.shift(), 'a'
);
assert.deepEqual(
queue, ['b']
);
queue.push('c');
assert.deepEqual(
queue, ['b', 'c']
);
.push()
values in this order: 'a', 'b', 'c'
..shift()
receives the values in the same order.If we invoke .shift()
on an empty array, it returns undefined
:
> [].shift()
undefined
Modern operating systems are capable of multitasking: More than one task (think piece of code) can run at the same time. In contrast, most JavaScript code is single-tasked:
while (true) {
const task = taskQueue.shift(); // (A)
task();
}
If the task queue is empty, the .shift()
in line A waits until there is a task.
In other words: The task queue constantly fills the main thread with code to run.
Why the “event” in the name “event loop”? While a browser runs JavaScript in a single thread, it runs other functionality in other threads: User input such as mouse clicks are received in a different thread (that runs concurrently to the main thread). Each user input is added as task to the queue. That task invokes the appropriate event listeners. The next two subsections describe other ways of adding tasks to the queue.
setTimeout()
The following function adds a task
to the event queue, after a delay
specified in milliseconds (1000 milliseconds are one second).
setTimeout(task, delay);
Note that the adding of a task is managed outside the main thread and relatively precise. However, the actual execution may be delayed – depending on how full the queue is when the task is added.
This is what using setTimeout()
looks like:
setTimeout(
() => {
console.log('One second later');
},
1000
);
Why would we want to use setTimeout()
? A long task can literally freeze the browser and stop it from accepting any user input. setTimeout()
enables the current task to take a break, allow the main thread to process user input and continue after that. We’ll explore how that works in more detail soon.
setInterval()
setInterval()
works similarly to setTimeout()
. However, where the latter only adds a task once, the former adds the tasks many times (until it is stopped):
const id = setInterval(task, delay);
clearInterval(id);
setInterval()
returns an id
– which we can pass to clearInterval()
in order to stop task
from being run again. delay
determines the wait (in milliseconds) before each invocation of the task.
We’ll use setInterval()
in the next project.
Date
We’ll need some date time functionality for the next two projects. Date
is a class for objects that represent date time values. Note that class Date
has many limitations. There is already a replacement called Temporal
but it is not yet supported by many JavaScript platforms.
Consider the following interaction:
> new Date().toISOString()
'2161-10-11T09:42:21.117Z'
We created a Date
object for the current moment and converted it to a string that conforms to the ISO standard notation for date time values.
The method Date.now()
returns the number of milliseconds that have passed since zero o’clock on January 1, 1970, UTC. Therefore, it helps us with determining how many milliseconds have passed:
const sleep = (milliseconds) => {
let start = Date.now();
while ((Date.now() - start) < milliseconds);
};
Note that this is not a good way to wait! We’ll use it soon to demonstrate how a task can completely block the browser.
string.slice()
The string method .slice()
works similarly to the array method .slice()
:
> '2161-10-11T09:42:21.117Z'.slice(11, 19)
'09:42:21'
We extracted a fragment of the string that .slice()
was invoked on: It starts at index 11 and ends before index 19.
log-time.js
log-time.js
is a shell command that logs the current time to the terminal:
setInterval(
() => {
const d = new Date();
console.log(
d.toISOString().slice(11, 19)
);
},
// Call function once every second
1000 // milliseconds
);
block-browser.html
The main thread of a browser does not use run JavaScript, it also handles updating the content displayed on screen. Therefore, a long-running task can completely block the browser because no other functionality can run in the main thread. This project shows what that looks like. It has the following user interface:
<p>
<a id="block" href="">Block browser for 5 seconds</a>
<p>
<button>This is a button</button>
<div id="statusMessage"></div>
The idea is that we click “Block” and a long-running loop is executed via JavaScript. During that loop, we can’t click the button because the main thread is blocked.
setStatusMessage('Blocking...');
setTimeout(
() => {
sleep(5000); // blocks the browser
setStatusMessage('Done');
},
1
);
We have already seen how sleep()
is implemented. setStatusMessage()
is implemented like this:
const setStatusMessage = (msg) => {
document.querySelector('#statusMessage').innerText = msg;
};
Now that we know how problematic long-running tasks are: What do we do if something takes a long time? We somehow have to pause the current task and come back to it later, so that other tasks get a chance to run.
In such cases, the following approach is used:
const promise = downloadText(someUrl);
promise.then(
(str) => {
// Process `str`
}
);
downloadText()
takes a long time to deliver its result. Therefore, it can’t immediately return it (at least not without blocking the browser). Instead, it returns a Promise – a placeholder for the actual result (which has yet to be delivered). With that Promise, we use method .then()
to register an event listener that is invoked once the result is ready.
A function that delivers results via Promises is called asynchronous. Normal functions are called synchronous – we immediately get results.
In this section, we briefly explore the production side of Promises. After that, we explore tools for consuming Promises.
These are the three states of a Promise:
A Promise that isn’t pending anymore is called settled – it’s either fulfilled or rejected.
This is how we can create a Promise:
new Promise(
(resolve, reject) => {
// Invoke resolve() and/or reject()
}
);
The callback of new Promise()
contains the code associated with the Promise. It can call its parameter resolve()
in order to fulfill the new Promise or reject()
in order to reject it.
If we call the following function, it returns a Promise that is only resolved once the provided HTML element was clicked:
const waitForClick = (elem) => {
return new Promise(
(resolve, reject) => {
elem.addEventListener(
'click',
(event) => {
event.preventDefault();
resolve(undefined); // (A)
},
{ once: true } // (B)
);
}
);
};
The returned Promise is pending until it is fulfilled in line A. This function does not really have a result, so we fulfill it with undefined
. Omitting the argument would have had the same effect.
In line B, we use the third parameter of .addEventListener()
to tell it that our event listener should only be invoked once.
It may be helpful to compare how synchronous and asynchronous functions deliver results and errors. setTimeout()
is used as a stand-in for any operation that takes some time to complete.
In case of success, they deliver a result:
const successSync = () => {
return 123;
};
const successAsync = () => {
return new Promise(
(resolve, reject) => {
setTimeout(
() => resolve(123)
);
}
);
};
In case of failure, they report an error:
const failureSync = () => {
throw new Error();
};
const failureAsync = () => {
return new Promise(
(resolve, reject) => {
setTimeout(
() => reject(new Error())
);
}
);
};
await
: handling results delivered via Promises We can work directly with Promises and receive fulfillment and rejection values via callbacks. But there is an alternative that is more convenient: async
functions. This is an example:
const asyncFunc = async () => {
console.log('Before');
const result = await functionThatReturnsAPromise();
console.log('After');
};
When we call asyncFunc()
, it logs 'Before'
and then returns. Why? Because the execution of the function was paused via await
. As soon as the Promise that we await is settled, execution continues:
result
and the function logs 'After'
.'After'
).Pausing via await
is also called suspending the function that surrounds it. Continuing the execution of the function is also called resuming it.
Promise.resolve()
and Promise.reject()
The following two methods are not used very often, but they let us experiment with await
in a JavaScript console:
Promise.resolve(v)
creates a Promise that is already fulfilled with the value v
.Promise.reject(e)
creates a Promise that is already rejected with the value e
.Let’s use these methods in a console:
> await Promise.resolve('Success!')
'Success!'
> await Promise.reject(new Error('Failure'))
Error: Failure
The first await
produces a value. The second await
throws an exception.
wait-for-click.html
We have already seen the code of waitForClick()
. Project wait-for-click.html
uses await
to handle the Promise it returns. It has the following user interface:
<p>
<a href="">Click here!</a>
</p>
<p id="status"></p>
This is the JavaScript behind the user interface:
await waitForClick(document.querySelector('a'));
document.querySelector('#status')
.innerText = 'Clicked';
Previously, we needed an event listener to wait for a click. In this case, we only need to await
a Promise.
An async function always returns a Promise:
> const asyncFunc = async () => {};
> asyncFunc() instanceof Promise
true
The result Promise of an async function is returned the first time its execution pauses (await
) or stops permanently (return
, throw
). It is settled in two ways:
return v
fulfills the result Promise with the value v
. Not returning anything is equivalent to returning undefined
.throw e
rejects the result Promise with the value e
.It’s interesting how an async function translates between the worlds of sync code and async code:
await
converts a Promise (something asynchronous) to a value or an exception (something synchronous).return
fulfills its result Promise and a (synchronous) throw
rejects its result Promise.await
? Let’s use the following code to explore what happens if we invoke an asynchronous function without await
:
const logAfterOneSecond = () => {
return new Promise(
(resolve) => { // (A)
console.log('Waiting for one second...');
setTimeout(
() => {
console.log('Logged');
resolve(undefined); // (B)
},
1000
);
}
);
};
console.log('Before');
await logAfterOneSecond(); // (C)
console.log('After');
In line A, we omit the second parameter reject
because we don’t need it.
This is what the output looks like with the await
in line C: We wait until the Promise returned by logAfterOneSecond()
is settled in line B.
Before
Waiting for one second...
Logged
After
If we remove the await
in line C, the output looks like this:
Before
Waiting for one second...
After
Logged
So what happens?
await
runs in a new task).Without await
, part 1 runs before 'After'
. But then we don’t wait for part 2 and log 'After'
. Then part 2 happens.
An asynchronous function f()
has an interesting viral nature: We can’t really invoke f()
without await
. That means that wherever we invoke f()
from has to be asynchronous, too. And so on!
fs
functions We have already used the following two functions from node:fs
to read and write text files:
fs.readFileSync()
fs.writeFileSync()
However, there are also asynchronous versions of these functions (note the different module name in the first line):
import * as fs from 'node:fs/promises';
const str = await fs.readFile(filePath, 'utf-8');
await fs.writeFile(filePath, str);
Why would we want to use these versions? They are less convenient to use but they don’t block the main thread while they are doing their work. Such blocking doesn’t matter in shell command, but it does matter if we implement a web server with Node.js (which we’ll do in a future chapter). There, we want the main thread to be unblocked and ready to handle more incoming requests.
fetch()
fetch()
is a function that lets us download data from the web. We use it like this:
const response = await fetch(url);
const text = await response.text();
fetch()
asynchronously returns a response object for the file at url
..text()
of the response object asynchronously returns the content of the file as a string.In principle, we can also combine the two steps but that looks kind of ugly to my eyes:
const text = await ((await fetch(url)).text());
response
has more methods – e.g., response.json()
which parses the JSON in a file for us, saving us an extra step we’d have to do after .text()
.
log-url-text.js
The shell command log-url-text.js
looks like this:
const url = process.argv[2];
const response = await fetch(url);
const text = await response.text();
console.log(text);
We invoke it as follows:
node log-url-text.js https://example.com
random-quote-browser/
Project random-quote-browser
is the browser version of project random-quote-nodejs
. We now have the following HTML user interface for the quote:
<p>
<button disabled>Show random quote</button>
</p>
<div id="quote"></div>
<div id="author"></div>
This function loads the quotes from a JSON file that sits next to the HTML file:
const loadQuotes = async () => {
const quotesUrl = new URL('quotes.json', import.meta.url); // (A)
const quotesResponse = await fetch(quotesUrl);
const quotes = await quotesResponse.json();
return quotes;
};
In line A, we once again construct a URL object for a file that is a sibling of the current file. We then use fetch()
to download the sibling file.
This is the remaining JavaScript code of the project:
const quotes = await loadQuotes();
showQuoteButton.addEventListener(
'click',
() => {
const randomIndex = getRandomInteger(quotes.length);
const randomQuote = quotes[randomIndex];
quoteElem.innerText = randomQuote.quote;
authorElem.innerText = '— ' + randomQuote.author;
}
);
showQuoteButton.disabled = false; // (π)
Note that the <button>
is initially disabled. That ensures that the user doesn’t click it before everything is ready. Once it is, we enable the button (line π). The main reason we are doing that is that downloading the quotes (line 1) make take some time.
item-store.js
: Rewrite the code so that it uses module 'node:fs/promises'
.
await
to invoke fs
methods).main()
must become async too. And you must await
its invocation in the last line. That’s the virality we have talked about previously.flash-cards
: Replace data.js
with a JSON file that is downloaded via fetch()
.
log-time.js
: Create a web version of this shell command.