Chapter 1. Asynchronous APIs
Introduction
A lot of the APIs covered in this book are asynchronous. When you call one of these functions or methods, you might not get the result back right away. Different APIs have different mechanisms to get the result back to you when it’s ready.
Events
Many browser APIs are event based. An event is something that happens asynchronously. Some examples of events are:
-
A button was clicked.
-
The mouse was moved.
-
A network request was completed.
-
An error occurred.
An event has a name, such as click
or mouseover
, and an object with data about the event that occurred. This might include information such as what element was clicked or an HTTP status code. When you listen for an event, you provide a callback function that receives the event object as an argument.
Objects that emit events implement the EventTarget
interface, which provides the addEventListener
and removeEventListener
methods. To listen for an event on an element or other object, you can call addEventListener
on it, passing the name
of the event and a handler function. The callback is called every time the event
is triggered until it is removed. A listener can be removed manually by calling
removeEventListener
, or in many cases listeners are automatically removed by the browser when objects are destroyed or removed from the DOM.
Promises
Many newer APIs use Promise
s. A Promise
is an object, returned from a function, that is a placeholder for the eventual result of the asynchronous action. Instead of listening for an event, you call then
on a Promise
object. You pass a callback function to then
that is eventually called with the result as its argument. To handle errors, you pass another callback function to the Promise
’s catch
method.
A Promise
is fulfilled when the operation completes successfully, and it is rejected when there’s an error. The fulfilled value is passed as an argument to the then
callback, or the rejected value is passed as an argument to the catch
callback.
There are a few key differences between events and Promise
s:
-
Event handlers are fired multiple times, whereas a
then
callback is executed only once. You can think of aPromise
as a one-time operation. -
If you call
then
on aPromise
, you’ll always get the result (if there is one). This is different from events where, if an event occurs before you add a listener, the event is lost. -
Promise
s have a built-in error-handling mechanism. With events, you typically need to listen for an error event to handle error conditions.
Working with Promises
Solution
Call then
on the Promise
object to handle the result in a callback function. To handle potential errors, add a call to catch
.
Imagine you have a function getUsers
that makes a network request to load a list of users. This function returns a Promise
that eventually resolves to the user list (see Example 1-1).
Example 1-1. Using a Promise
-based API
getUsers
()
.
then
(
// This function is called when the user list has been loaded.
userList
=>
{
console
.
log
(
'User List:'
);
userList
.
forEach
(
user
=>
{
console
.
log
(
user
.
name
);
});
}
).
catch
(
error
=>
{
console
.
error
(
'Failed to load the user list:'
,
error
);
});
Discussion
The Promise
returned from getUsers
is an object with a then
method. When the user list is loaded, the callback passed to then
is executed with the user list as its
argument.
This Promise
also has a catch
method for handling errors. If an error occurs while loading the user list, the callback passed to catch
is called with the error object. Only one of these callbacks is called, depending on the outcome.
Loading an Image with a Fallback
Solution
Create an Image
element programmatically, and listen for its load
and error
events. If the error
event triggers, replace it with the fallback image. Once either the requested image or the placeholder image loads, add it to the DOM when desired.
For a cleaner API, you can wrap this in a Promise
. The Promise
either resolves with an Image
to be added or rejects with an error if neither the image nor the fallback can be loaded (see Example 1-2).
Example 1-2. Loading an image with a fallback
/**
* Loads an image. If there's an error loading the image, uses a fallback
* image URL instead.
*
* @param url The image URL to load
* @param fallbackUrl The fallback image to load if there's an error
* @returns a Promise that resolves to an Image element to insert into the DOM
*/
function
loadImage
(
url
,
fallbackUrl
)
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
image
=
new
Image
();
// Attempt to load the image from the given URL
image
.
src
=
url
;
// The image triggers the 'load' event when it is successfully loaded.
image
.
addEventListener
(
'load'
,
()
=>
{
// The now-loaded image is used to resolve the Promise
resolve
(
image
);
});
// If an image failed to load, it triggers the 'error' event.
image
.
addEventListener
(
'error'
,
error
=>
{
// Reject the Promise in one of two scenarios:
// (1) There is no fallback URL.
// (2) The fallback URL is the one that failed.
if
(
!
fallbackUrl
||
image
.
src
===
fallbackUrl
)
{
reject
(
error
);
}
else
{
// If this is executed, it means the original image failed to load.
// Try to load the fallback.
image
.
src
=
fallbackUrl
;
}
});
});
}
Discussion
The loadImage
function takes a URL and a fallback URL and returns a Promise
. Then it creates a new Image
and sets its src
attribute to the given URL. The browser attempts to load the image.
There are three possible outcomes:
- Success case
-
If the image loads successfully, the
load
event is triggered. The event handler resolves thePromise
with theImage
, which can then be inserted into the DOM. - Fallback case
-
If the image fails to load, the
error
event is triggered. The error handler sets thesrc
attribute to the fallback URL, and the browser attempts to load the fallback image. If that is successful, theload
event fires and resolves thePromise
with the fallbackImage
. - Failure case
-
If neither the image nor the fallback image could be loaded, the error handler rejects the
Promise
with theerror
event.
The error
event is triggered every time there’s a load error. The handler first checks if it’s the fallback URL that failed. If so, this means that the original URL and fallback URL both failed to load. This is the failure case, so the Promise
is rejected.
If it’s not the fallback URL, this means the requested URL failed to load. Now it sets the fallback URL and tries to load that.
The order of checks here is important. Without that first check, if the fallback fails to load, the error handler would trigger an infinite loop of setting the (invalid) fallback URL, requesting it, and firing the error
event again.
Example 1-3 shows how to use this loadImage
function.
Example 1-3. Using the loadImage
function
loadImage
(
'https://example.com/profile.jpg'
,
'https://example.com/fallback.jpg'
)
.
then
(
image
=>
{
// container is an element in the DOM where the image will go
container
.
appendChild
(
image
);
}).
catch
(
error
=>
{
console
.
error
(
'Image load failed'
);
});
Chaining Promises
Solution
Use a chain of Promise
s to run the asynchronous tasks in sequence. Imagine a blog application with two APIs, both of which return Promise
s:
getUser(id)
-
Loads a user with the given user ID
getPosts(user)
-
Loads all the blog posts for a given user
If you want to load the posts for a user, you first need to load the user
object—you can’t call getPosts
until the user details are loaded. You can do this by chaining the two Promise
s together, as shown in Example 1-4.
Example 1-4. Using a Promise
chain
/**
* Loads the post titles for a given user ID.
* @param userId is the ID of the user whose posts you want to load
* @returns a Promise that resolves to an array of post titles
*/
function
getPostTitles
(
userId
)
{
return
getUser
(
userId
)
// Callback is called with the loaded user object
.
then
(
user
=>
{
console
.
log
(
`Getting posts for
${
user
.
name
}
`
);
// This Promise is also returned from .then
return
getPosts
(
user
);
})
// Calling then on the getPosts' Promise
.
then
(
posts
=>
{
// Returns another Promise that will resolve to an array of post titles
return
posts
.
map
(
post
=>
post
.
title
);
})
// Called if either getUser or getPosts are rejected
.
catch
(
error
=>
{
console
.
error
(
'Error loading data:'
,
error
);
});
}
Discussion
The value returned from a Promise
’s then
handler is wrapped in a new Promise
. This Promise
is returned from the then
method itself. This means the return value of then
is also a Promise
, so you can chain another then
onto it. This is how you create a chain of Promise
s.
getUser
returns a Promise
that resolves to the user
object. The then
handler calls getPosts
and returns the resulting Promise
, which is returned again from then
, so you can call then
once more to get the final result, the array of posts.
At the end of the chain is a call to catch
to handle any errors. This works like a try
/catch
block. If an error occurs at any point within the chain, the catch
handler is called with that error and the rest of the chain does not get executed.
Using the async and await Keywords
Solution
Use the await
keyword with the Promise
instead of calling then
on it (see Example 1-5). Consider again the getUsers
function from “Working with Promises”. This function returns a Promise
that resolves to a list of users.
Example 1-5. Using the await
keyword
// A function must be declared with the async keyword
// in order to use await in its body.
async
function
listUsers
()
{
try
{
// Equivalent to getUsers().then(...)
const
userList
=
await
getUsers
();
console
.
log
(
'User List:'
);
userList
.
forEach
(
user
=>
{
console
.
log
(
user
.
name
);
});
}
catch
(
error
)
{
// Equivalent to .catch(...)
console
.
error
(
'Failed to load the user list:'
,
error
);
}
}
Discussion
await
is an alternative syntax for working with Promise
s. Instead of calling then
with a callback that takes the result as its argument, the expression effectively “pauses” execution of the rest of the function and returns the result when the Promise
is fulfilled.
If the Promise
is rejected, the await
expression throws the rejected value. This is handled with a standard try
/catch
block.
Using Promises in Parallel
Solution
Collect all the Promise
s, and pass them to Promise.all
. This function takes an array of Promise
s and waits for them all to complete. It returns a new Promise
that is fulfilled once all the given Promise
s are fulfilled, or rejects if any of the given Promise
s are rejected (see Example 1-6).
Example 1-6. Loading multiple users with Promise.all
// Loading three users at once
Promise
.
all
([
getUser
(
1
),
getUser
(
2
),
getUser
(
3
)
]).
then
(
users
=>
{
// users is an array of user objects—the values returned from
// the parallel getUser calls
}).
catch
(
error
=>
{
// If any of the above Promises are rejected
console
.
error
(
'One of the users failed to load:'
,
error
);
});
Discussion
If you have multiple tasks that don’t depend on one another, Promise.all
is a good choice. Example 1-6 calls getUser
three times, passing a different user ID each time. It collects these Promise
s into an array that is passed to Promise.all
. All three requests run in parallel.
Promise.all
returns another Promise
. Once all three users have loaded successfully, this new Promise
becomes fulfilled with an array containing the loaded users. The index of each result corresponds to the index of the Promise
in the input array. In this case, it returns an array with users 1
, 2
, and 3
, in that order.
What if one or more of these users failed to load? Maybe one of the user IDs doesn’t exist or there was a temporary network error. If any of the Promise
s passed to Promise.all
are rejected, the new Promise
immediately rejects as well. The rejection value is the same as that of the rejected Promise
.
If one of the users fails to load, the Promise
returned by Promise.all
is rejected with the error that occurred. The results of the other Promise
s are lost.
If you still want to get the results of any resolved Promises
(or errors from other rejected ones), you can instead use Promise.allSettled
. With Promise.allSettled
, a new Promise
is returned just like with Promise.all
. However, this Promise
is always fulfilled, once all of the Promise
s are settled (either fulfilled or rejected).
As shown in Example 1-7, the resolved value is an array whose elements each have a status
property. This is either fulfilled
or rejected
, depending on the result of that Promise
. If the status is fulfilled
, the object also has a value
property that is the resolved value. On the other hand, if the status is rejected
, it instead has a
reason
property, which is the rejected value.
Example 1-7. Using Promise.allSettled
Promise
.
allSettled
([
getUser
(
1
),
getUser
(
2
),
getUser
(
3
)
]).
then
(
results
=>
{
results
.
forEach
(
result
=>
{
if
(
result
.
status
===
'fulfilled'
)
{
console
.
log
(
'- User:'
,
result
.
value
.
name
);
}
else
{
console
.
log
(
'- Error:'
,
result
.
reason
);
}
});
});
// No catch necessary here because allSettled is always fulfilled.
Animating an Element with requestAnimationFrame
Solution
Use the requestAnimationFrame
function to schedule your animation updates to run at regular intervals.
Imagine you have a div
element that you want to hide with a fade animation. This is done by adjusting the opacity at regular intervals, using a callback passed to requestAnimationFrame
(see Example 1-8). The duration of each interval depends on the desired frames per second (FPS) of the animation.
Example 1-8. Fade-out animation using requestAnimationFrame
const
animationSeconds
=
2
;
// Animate over 2 seconds
const
fps
=
60
;
// A nice, smooth animation
// The time interval between each frame
const
frameInterval
=
1000
/
fps
;
// The total number of frames for the animation
const
frameCount
=
animationSeconds
*
fps
;
// The amount to adjust the opacity by in each frame
const
opacityIncrement
=
1
/
frameCount
;
// The timestamp of the last frame
let
lastTimestamp
;
// The starting opacity value
let
opacity
=
1
;
function
fade
(
timestamp
)
{
// Set the last timestamp to now if there isn't an existing one.
if
(
!
lastTimestamp
)
{
lastTimestamp
=
timestamp
;
}
// Calculate how much time has elapsed since the last frame.
// If not enough time has passed yet, schedule another call of this
// function and return.
const
elapsed
=
timestamp
-
lastTimestamp
;
if
(
elapsed
<
frameInterval
)
{
requestAnimationFrame
(
animate
);
return
;
}
// Time for a new animation frame. Remember this timestamp.
lastTimestamp
=
timestamp
;
// Adjust the opacity value and make sure it doesn't go below 0.
opacity
=
Math
.
max
(
0
,
opacity
-
opacityIncrement
)
box
.
style
.
opacity
=
opacity
;
// If the opacity hasn't reached the target value of 0, schedule another
// call to this function.
if
(
opacity
>
0
)
{
requestAnimationFrame
(
animate
);
}
}
// Schedule the first call to the animation function.
requestAnimationFrame
(
fade
);
Discussion
This is a good, performant way to animate elements using JavaScript that has good browser support. Because it’s done asynchronously, this animation won’t block the browser’s main thread. If the user switches to another tab, the animation is paused and requestAnimationFrame
isn’t called unnecessarily.
When you schedule a function to run with requestAnimationFrame
, the function is called before the next repaint operation. How often this happens depends on the browser and screen refresh rate.
Before animating, Example 1-8 does some calculations based on a given animation duration (2 seconds) and frame rate (60 frames per second). It calculates the total number of frames, and uses the duration to calculate how long each frame runs. If you want a different frame rate that doesn’t match the system refresh rate, this keeps track of when the last animation update was performed to maintain your target frame rate.
Then, based on the number of frames, it calculates the opacity adjustment made in each frame.
The fade
function is scheduled by passing it to a requestAnimationFrame
call. Each time the browser calls this function, it passes a timestamp. The fade
function calculates how much time has elapsed since the last frame. If not enough time has passed yet, it doesn’t do anything and asks the browser to call again next time around.
Once enough time has passed, it performs an animation step. It takes the calculated opacity adjustment and applies it to the element’s style. Depending on the exact timing, this could result in an opacity less than 0, which is invalid. This is fixed by using Math.max
to set a minimum value of 0.
If the opacity hasn’t reached 0 yet, more animation frames need to be performed. It calls requestAnimationFrame
again to schedule the next execution.
As an alternative to this method, newer browsers support the Web Animations API, which you’ll learn about in Chapter 8. This API lets you specify keyframes with CSS properties, and the browser handles updating the intermediate values for you.
Wrapping an Event API in a Promise
Solution
Create a new Promise
object and register event listeners within its constructor. When you receive the event you’re waiting for, resolve the Promise
with the value. Similarly, reject the Promise
if an error event occurs.
Sometimes this is called “promisifying” a function. Example 1-9 demonstrates promisifying the XMLHttpRequest
API.
Example 1-9. Promisifying the XMLHttpRequest
API
/**
* Sends a GET request to the specified URL. Returns a Promise that will resolve to
* the JSON body parsed as an object, or will reject if there is an error or the
* response is not valid JSON.
*
* @param url The URL to request
* @returns a Promise that resolves to the response body
*/
function
loadJSON
(
url
)
{
// Create a new Promise object, performing the async work inside the
// constructor function.
return
new
Promise
((
resolve
,
reject
)
=>
{
const
request
=
new
XMLHttpRequest
();
// If the request is successful, parse the JSON response and
// resolve the Promise with the resulting object.
request
.
addEventListener
(
'load'
,
event
=>
{
// Wrap the JSON.parse call in a try/catch block just in case
// the response body is not valid JSON.
try
{
resolve
(
JSON
.
parse
(
event
.
target
.
responseText
));
}
catch
(
error
)
{
// There was an error parsing the response body.
// Reject the Promise with this error.
reject
(
error
);
}
});
// If the request fails, reject the Promise with the
// error that was emitted.
request
.
addEventListener
(
'error'
,
error
=>
{
reject
(
error
);
});
// Set the target URL and send the request.
request
.
open
(
'GET'
,
url
);
request
.
send
();
});
}
Example 1-10 shows how to use the promisified loadJSON
function.
Example 1-10. Using the loadJSON
helper
// Using .then
loadJSON
(
'/api/users/1'
).
then
(
user
=>
{
console
.
log
(
'Got user:'
,
user
);
})
// Using await
const
user
=
await
loadJSON
(
'/api/users/1'
);
console
.
log
(
'Got user:'
,
user
);
Discussion
You create a Promise
by calling the Promise
constructor function with the new
operator. This function receives two arguments, a resolve
and reject
function.
The resolve
and reject
functions are supplied by the JavaScript engine. Within the Promise
constructor, you do your asynchronous work and listen for events. When the resolve
function is called, the Promise
immediately resolves to that value. Calling reject
works the same way—it rejects the Promise
with the error.
Creating your own Promise
can help these types of situations, but in general you usually don’t need to create them manually like this. If an API already returns a Promise
, you don’t need to wrap that in your own Promise
—just use it directly.
Get Web API Cookbook now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.