Asynchrony
An asynchronous program is a program that has to stop computing while waiting for data to arrive or for some event to occur. JavaScript programs in a web browser are typically event-driven, meaning that they wait for the user to click or tap before they actually do anything.
Callback functions
At its most fundamental level, asynchronous programming in JavaScript is done with callbacks. A callback is a function that you write and then pass to some other function. That other function then invokes ("calls back") your function when some condition is met or some event occurs. For example, you can register a callback function with setTimeout()
function and specify under what asynchronous condition it should be invoked.
function callback() {
console.log("callback called");
}
setTimeout(callback, 2000);
The web browser generates an event when the user presses a key on the keyboard, moves the mouse, clicks a mouse button, or touches a touchscreen device. Event-driven programs register callback functions for an event, and the web browser invokes those functions when the specified event occurs. Such callback functions are called event handlers or event listeners.
// Ensure body covers entire viewport
document.body.style.height = "100vh";
document.body.addEventListener("click", () => alert("You clicked!"));
Network requests produce another common source of asynchronous events. You can register an event listener by assigning it directly to a property of an object that supports it.
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://jsonplaceholder.typicode.com/users/1");
xhr.send();
// register a callback function upon response arrival
xhr.onload = () => {
const user = JSON.parse(xhr.responseText);
console.log(user.id, user.name, user.email);
};
Promise
A promise is an object that represent the not-yet-available result of an asynchronous operation. The value of a promise is vague: you can only attach a callback function when the value is ready. Promises provide a better way to handle asynchronous operations using linear promise chain that avoid nesting callback functions.
Promises can be in one of the three states:
- Pending: The initial state, neither fulfilled, nor rejected.
- Fulfilled: The operation completed successfully, and the promise has a resulting value.
- Rejected: The operation failed.
A promise is settled when it is either fulfilled or rejected. Once settled, its state cannot change.
A promise provides a then()
method to handle the result of the promise once it is settled. It takes two arguments:
- A callback function for when the promise is fulfilled.
- A callback function for when the promise is rejected (optional).
The fetch()
function is a new way to handle HTTP requests: you pass a URL, and it returns a promise. This promise is fulfilled when the HTTP response begins to arrive, and the HTTP status and headers are available.
function c(response) {
const v = response.json();
return v;
}
const p1 = fetch("https://jsonplaceholder.typicode.com/users/2");
const p2 = p1.then(c);
When the promise p1
returned by fetch()
is fulfilled, it passes a response object to the callback function c
that you passed to its then()
method. The response object defines its own methods such as text()
and json()
that return another promise that will be fulfilled when the body of the response arrives.
When you pass a callback c
to the then()
method, then()
returns a promise p2
and arranges to asynchronously invoke c
at some later time. c
performs some computation and returns a value v
. When c
returns, p2
is resolved with value v
. When v
is not itself another promise, it is immediately fulfilled with value v
. But if v
is itself a promise, p2
is resolved but not yet fulfilled. At this point, p2
cannot settle until the promise of v
settles. If v
is fulfilled, then p2
will be fulfilled to the same value. If v
is rejected, then p2
will be rejected for the same reason. Therefore, resolved means the promise depends on another promise. We don't know yet whether p2
will be fulfilled or rejected, but our callback c
no longer has any control over that. The fate of p2
now depends entirely on what happens to promise of v
.
Handling asynchronous errors
Asynchronous errors cannot be handled with try/catch/finally
block, because the caller of asynchronous computation is not in the call stack. Instead, promise passes the exception to the second argument of its then()
method. This second argument is a callback function that will be invoked if the promise is rejected. The argument to this second callback function is a value (typically an error object) that represents the reason for the rejection.
fetch("https://jsonplaceholder.typicode.com/users/3").then(
(response) => console.log(response.status, response.url),
(error) => console.error("Error:", error.message)
);
However, a better way is to add a catch()
method to the promise chain to handle errors, which is a shorthand for calling then()
with its first argument null
and the specified error handler function as the second argument. In an asynchronous code, instead of exception "bubbling up the call stack", it "trickles down the promise chain" until it finds a catch()
invocation.
fetch("https://jsonplaceholder.typicode.com/users/4")
.then((response) => console.log(response.status, response.url))
.catch((error) => console.error("Error:", error.message));
Once an error has been passed to a catch()
callback, it stops propagating down the promise chain. A catch()
callback can be used anywhere in the promise chain to handle errors and recover from errors. If you return normally from the catch()
callback, that return value is used to resolve/fulfill the associated promise.
fetch("https://typicode.com/users/5")
.catch((error) => {
console.warn("Recovering from error for user 5");
return fetch("https://jsonplaceholder.typicode.com/users/6");
})
.then((response) => response.json())
.then((user) => console.log(user.id, user.name, user.email))
.catch((error) => console.error(error.message));
Promise objects also provide a finally()
method that you can append to your promise chain. The callback passed to finally()
is executed when the promise settles, regardless of whether it was fulfilled or rejected. The finally()
method returns a promise that resolves with the original value or reason from the promise it was called on. However, if the callback in finally()
throws an exception, the returned promise will be rejected with that exception.
fetch("https://jsonplaceholder.typicode.com/users/7")
.then((response) => {
// 404 error is a valid HTTP response
if (!response.ok) {
// the returned promise becomes fulfilled immediately
return null;
}
const contentType = response.headers.get("content-type") || "";
const jsonMimeType = /application\/json(;.*)?/;
if (!jsonMimeType.test(contentType)) {
// the returned promise becomes rejected
throw new TypeError(`Expected JSON, got "${contentType}"`);
}
// the returned promise is resolved and depends on the new promise
return response.json();
})
.then((user) => {
if (user) {
console.log(user.id, user.name, user.email);
} else {
console.log("Data not found");
}
})
.catch((error) => {
if (error instanceof TypeError) {
console.error("Something is wrong with our server!");
} else {
console.error("An error occurred:", error.message);
}
})
.finally(() => console.log("Finally block for user 7"));
Promises in parallel
The function Promise.all()
executes multiple asynchronous operations in parallel. It takes an array of promise objects as its input and returns a promise. The returned promise will be rejected if any of the input promises is rejected. This happens immediately upon the first rejection and can happen while other input promises are still pending. Otherwise, it will be fulfilled with an array of the fulfillment values of each of the input promises. If an element from input array is not a promise, it is treated as already fulfilled promise and is simply copied unchanged into the output array.
const todoUrls = [
"https://jsonplaceholder.typicode.com/todos/1",
"https://jsonplaceholder.typicode.com/",
"https://jsonplaceholder.typicode.com/todos/3",
];
const todoPromises = todoUrls.map((url) => fetch(url).then((r) => r.json()));
Promise.all(todoPromises)
.then((todos) =>
todos.forEach((todo) => console.log(`todo #${todo.id}: ${todo.title}`))
)
.catch((e) => console.error("Error while fetching todos!"));
Promise.allSettled()
works like Promise.all()
, except that it never rejects the returned promise, nor does it fulfill the returned promise until all the input promises are settled. Promise.allSettled()
returns an array of objects, each having a property status
that is set to either "fulfilled" or "rejected". If the status is "fulfilled", then the object will also have a value
property with fulfillment value. If the status
is "rejected", then the object will also have a reason
property that gives the error or rejection value for the corresponding promise.
const postUrls = [
"https://jsonplaceholder.typicode.com/posts/1",
"https://jsonplaceholder.typicode.com/",
"https://jsonplaceholder.typicode.com/posts/3",
];
const postPromises = postUrls.map((url) => fetch(url).then((r) => r.json()));
Promise.allSettled(postPromises)
.then((posts) => posts.forEach((result) => console.log(result)))
.catch((e) => console.error("Error while fetching posts!"));
If you only care about the value of the first promise to fulfill, use Promise.race()
instead of Promise.all()
. It returns a promise that is fulfilled or rejected when the first of the promises in the input array is fulfilled or rejected.
const albumUrls = [
"https://jsonplaceholder.typicode.com/albums/1",
"https://jsonplaceholder.typicode.com/albums/2",
"https://jsonplaceholder.typicode.com/albums/3",
];
const albumPromises = albumUrls.map((url) =>
fetch(url).then((r) => r.json())
);
Promise.race(albumPromises)
.then((album) => console.log(`First album: ${album.id} ${album.title}`))
.catch((e) => console.error("Error while fetching albums!"));
Making promises
The static method Promise.resolve()
takes a value as its single argument and returns a promise that will be immediately (but asynchronously) fulfilled to that value.
function getValue() {
return Promise.resolve(42);
}
getValue().then((num) => console.log(num));
The static method Promise.reject()
takes a single argument and returns a promise that will be asynchronously rejected with that value as the reason.
function mayFail() {
return Math.random() > 0.5
? Promise.resolve(49)
: Promise.reject("Unknown error while getting the number");
}
mayFail()
.then((num) => console.log("Success:", num))
.catch((reason) => console.error("Failed:", reason));
To create a new Promise
, you use the Promise()
constructor to create a new promise object by passing a function as its only argument. The function you pass should expect two parameters named resolve
and reject
.
function wait(duration) {
// Create and return a new Promise
return new Promise((resolve, reject) => {
if (duration < 0) {
reject(new Error("Duration cannot be negative"));
}
// setTimeout() will invoke resolve() with no arguments,
// so the Promise will fulfill with the undefined value
setTimeout(resolve, duration);
});
}
Sometimes errors can occur at random in a network environment, and it might be useful just to retry the asynchronous request.
mayFail()
.catch((e) => wait(500).then(mayFail()))
.then((num) => console.log("Success with retry:", num))
.catch((reason) => console.error("Failed with retry:", reason));
Promises in sequence
Running promises in sequence prevents overloading your network. However, if you don't know the content of an array in advance, you cannot simply write out a chain of promises. Instead, you need to build the promise chain dynamically.
function promiseSequence(inputs, promiseMaker) {
// Make a private copy of array to modify
inputs = [...inputs];
function handleNextInput(outputs) {
if (inputs.length === 0) {
// Fulfill last promise and all previous resolved promises
return outputs;
} else {
// Resolve the current promise with the next promise
let nextInput = inputs.shift();
return (
promiseMaker(nextInput)
// Then add current promise output to outputs array
.then((output) => outputs.concat(output))
// Then recurse, passing updated outputs array
.then(handleNextInput)
);
}
}
// Start with a new fulfilled promise,
// then resolve promises sequentially
return Promise.resolve([]).then(handleNextInput);
}
const commentUrls = [
"https://jsonplaceholder.typicode.com/comments/1",
"https://jsonplaceholder.typicode.com/comments/2",
"https://jsonplaceholder.typicode.com/comments/3",
];
function fetchJSON(url) {
return fetch(url).then((r) => r.json());
}
promiseSequence(commentUrls, fetchJSON)
.then((comments) =>
comments.reduce(
(str, comment) => str + `${comment.name} (${comment.email}), `,
""
)
)
.then((str) => console.log("Comments:", str))
.catch(console.error);
The async and await keywords
The async
and await
keywords take promise-based code and hide promises so that your asynchronous code looks like synchronous one. The await
keyword waits while a promise settles and turns it back into a return value or a thrown exception.
Because any code that uses await
is asynchronous, you can only use the await
keyword within functions that have been declared with the async
keyword. Such functions return a promise even if no promise-related code is present in the body of the function.
const url = "https://jsonplaceholder.typicode.com/comments/4";
async function getComment(url) {
const response = await fetch(url);
const comment = await response.json();
return comment;
}
getComment(url).then((comment) => console.log("Comment:", comment.id));
In order to get promises of a set of concurrently executing async
functions, use Promise.all()
.
async function getAllComments(urls) {
const promises = urls.map((url) => getComment(url));
const comments = await Promise.all(promises);
return comments;
}
const urls = [
"https://jsonplaceholder.typicode.com/comments/5",
"https://jsonplaceholder.typicode.com/comments/6",
"https://jsonplaceholder.typicode.com/comments/7",
];
getAllComments(urls).then((comments) => console.log(comments));
The for await loop
The for await
loop is used with promise-based iterators. The loop waits for the promise to fulfill, assigns the fulfillment value to the loop variable, and runs the body of the loop. Then it starts over, obtaining another promise from the iterator, waiting for that new promise to fulfill.
const photoUrls = [
"https://jsonplaceholder.typicode.com/photos/1",
"https://jsonplaceholder.typicode.com/photos/2",
"https://jsonplaceholder.typicode.com/photos/3",
];
function handlePhoto(photo) {
console.log("Photo:", photo.id);
}
async function getPhotos(urls) {
const promises = urls.map((url) => getJSON(url));
for await (const response of promises) {
handlePhoto(response);
}
}
getPhotos(photoUrls);
Asynchronous generators
The asynchronous generators combine the features of generators and regular async
functions, where the yielded value is automatically wrapped in a promise.
function getPhoto(id) {
const url = `https://jsonplaceholder.typicode.com/photos/${id}`;
return fetch(url).then((response) => response.json());
}
async function* photoGenerator(from = 1, to = 5000) {
for (let i = from; i < to; i++) {
// yield promise
yield await getPhoto(i);
}
}
async function printPhotos() {
for await (const photo of photoGenerator(10, 20)) {
// wait for promise to be fulfilled
console.log("Photo:", photo.id, photo.url);
}
}
printPhotos();
Asynchronous iterators
The asynchronous iterators implement a method with the name [Symbol.asyncIterator]()
, and its next()
method returns a promise that resolves to an iterator result object, in which both value
and done
properties are asynchronous, such that the choice when iteration ends is made asynchronously. The benefit of asynchronous iterators is that they allow you to represent streams of asynchronous user events or data.
class AsyncQueue {
// An asynchronous iterable queue class. Add values with enqueue()
// and remove them with dequeue(). Values can be dequeued before
// enqueued. The class can be used with for await loop; it will not
// terminate until the close() method is called.
//
// In the context of events, a pending promise means we are waiting
// for an event to take place. When the event that we are waiting
// happens, we start processing it by resolving this pending
// promise. If the event happens, and we are still proccessing the
// previous one, we add the event to the queue for future
// proccessing. The wait() method helps to see how the iterator
// works by slowing down the resolving of promises.
constructor() {
// A queue for values
this.queue = [];
// A queue for pending promises
this.resolvers = [];
this.closed = false;
// A marker to know when to finish iteration
this.EOS = Symbol("End Of Stream");
}
wait(duration) {
// Helper function to slow things down
return new Promise((resolve) => {
setTimeout(resolve, duration);
});
}
enqueue(value) {
if (this.closed) {
throw new Error("AsyncQueue is closed");
}
if (this.resolvers.length > 0) {
// Resolve a pending promise if there is one in the pending
// promises queue
const resolve = this.resolvers.shift();
this.wait(1000).then(() => resolve(value)); // slowly
// resolve(value); // without delay
} else {
// If there is no pending promise, add value to the value queue
this.queue.push(value);
}
}
dequeue() {
if (this.queue.length > 0) {
// If there is a value in the value queue, resolve it
const value = this.queue.shift();
return this.wait(1000).then(() => value); // slowly
// return Promise.resolve(value); // without delay
} else if (this.closed) {
// If the value queue is empty and the queue is closed,
// finish iteration
return Promise.resolve(this.EOS);
} else {
// If the value queue is empty, add a resolve function
// to the pending promises queue
return new Promise((resolve) => this.resolvers.push(resolve));
}
}
close() {
while (this.resolvers.length > 0) {
// Finish iteration of any pending promises
this.resolvers.shift()(this.EOS);
}
this.closed = true;
}
[Symbol.asyncIterator]() {
return this;
}
next() {
// This method makes the class an asynchrnonous iterator
return this.dequeue().then((value) =>
value === this.EOS
? { value: undefined, done: true }
: { value: value, done: false }
);
}
}
async function handleKeys() {
const q = new AsyncQueue();
// When an event occurs, enqueue the event
document.addEventListener("keypress", (e) => q.enqueue(e));
// Close the queue after 10s
setTimeout(() => q.close(), 10_000);
for await (const event of q) {
console.log(event.key);
}
}
handleKeys();