Promises and Asynchronous Code
Javascript runs in a single threaded process, meaning that only one line of code is executing at one time. This is typical of most programming langauges but since Javascript code is often event driven, developers might assume that many things happen at once in response to different events.
Javascript code is executed using an event loop. This is a high-level loop
that waits for a task, executes it and then sleeps until the next task is
available. In the browser, a task might be loading a Javascript file via a
<script></script>
tag or running the event handler when the user clicks on a
button. If a task is scheduled when the Javascript engine is already busy
running another task, it is added to a queue (called the macro-task queue).
The event loop will pull the next task from the queue when it completes what it
is doing.
While the Javascript engine is busy executing code, nothing else happens in the browser. If the code changes the DOM, the updates don't get rendered to the screen until the task has finished. Since it can only run one task at a time, the browser can't respond to new input until that task has finished. This means that tasks in Javscript code should be short and fast so that the browser can remain responsive to the user.
If you have a task that would normally take some time, it is useful to split
it up into smaller tasks so that other things can be done. This can be done
with the setTimeout
function which schedules a function to be run at a later
time (if no time delay is given, it will be scheduled as the next task). Here's
an example where we want to run an expensive function 1000 times but split it up
into batches of 100 to allow for other tasks to be run and the page to be
updated as we go.
const process = (start) => {
if (start > 1000) {
return
}
for(let i=start; i<(start+100); i++) {
processThatTakesTime(i);
}
// schedule the next batch
setTimeout(() => process(100))
}
setTimeout
is an example of asynchronous code. This is code that doesn't
execute one line after another (synchronous) but code that we know will run
at some future time.
Promises
A very common case that arises in Javascript programming is having to wait for a result from an external process such as a web request, database update or file system read. In other languages, the program would just wait for the external request to complete and then carry on execution with the result. Because we want to maximise responsiveness in Javascript execution, it is common for these tasks to run asynchronously.
Javascript introduces a pattern for dealing with asynchronous code called Promises which handle the common case of scheduling some code to run at a later time when a result is available from a time-consuming operation.
A very common example in a front-end, browser based application is the use of
the fetch
function to retrieve a resource from a URL. Since the server will
take some time in responding to the request, we don't want to freeze the browser
until it returns. Hence, we want to schedule the code that will handle the
response to run only when that response is available. This allows any other
tasks to run in the interim. We can achieve this with the .then
clause:
console.log('sending request');
fetch('http://example.com/')
.then((response) => {
console.log('got the response!');
doSomething(response)
});
console.log('request sent');
Here we have called fetch
to send a GET request to this URL. The .then
clause schedules a function to be called when that request returns, the response
will be passed to this function. That function can then handle the response
(eg. by updating the DOM for the current page).
The output from the above code will be:
sending request
request sent
got the response
The first line should be obvious. We see the second line (request sent
)
because the fetch call just sends off the HTTP request and schedules the then
clause to run at a later time. This means that the final line of code gets t
run before the then clause. Finally, we see got the response
output by the
then clause only when the response comes back from the remote server.
Handling Errors
If something goes wrong in the task that is being waited on in a promise, then
instead of being resolved, the promise is rejected. A rejected promise is
one that wasn't kept, for example, the HTTP request returned an error status.
We usually want to handle any case like this and we can use a .catch
clause
to provide some code that will run if the promise is rejected.
fetch('http://example.com/')
.then((response) => {
console.log('got the response!');
doSomething(response)
})
.catch((error) => {
console.log("Error in fetch: ", error);
tellTheUserAboutTheError();
});
The function in the .catch
clause will be called only if the promise returned
by fetch
is rejected.
A Note on Syntax
The format of the code above might look a bit confusing. I've written the
.then
and .catch
clauses on separate lines but the dot before
them indicates that they are really part of the previous line of code:
fetch('http://example.com').then(/*...*/).catch(/*...*/);
To understand this we need to realise that the fetch
function returns a promise
object. We then call the then
method of that object, passing in a function
that will be called if the promise resolves. The then
method also returns
the same promise, and we then call the catch
method on that. So, what you see
here is a chain of method calls on a promise object returned by fetch
.
Note that fetch
and the then
and catch
methods return immediately, they
don't cause the application to pause at all. They register code to run at a
future time. This is why any code after the fetch
call runs before the
then
or catch
clauses are executed.
Since these functions return promises rather than values, it is not usually useful to store the result in a variable. You might be tempted to write:
const response = fetch('http://example.com');
const body = response.body;
Here, response
is not the HTTP response, it's a promise that there will be a
response in the future (or an error). So response
doesn't have a body
property like a real HTTP response object would. Any work you do on the
response has to be within the then
clause that will be called later, when the
request returns and the promise is resolved.
Scheduling Promises
Functions like the one in the .then
clause above are called micro tasks
rather than the macro tasks mentioned above. Micro tasks have a separate
queue that will be run before DOM rendering takes place. So, the order of
execution in the event loop is:
- Select the next Macro task and execute
- Run any tasks from the micro-task queue until it's empty
- Render the DOM
Tasks will be put on the micro-task queue when they are ready, so our fetch task above would not be put on the queue until the response returned. If the server was particularly slow, that might not be until after later Macro tasks had run. So, we might send of the fetch request, the user could click a button to do a calculation, the page could be updated, and only then our request returns and our handler micro-task is called to process it.
Chains of Promises
The promise object returned by fetch
will usually resolve when the request
returns to give an object representing the HTTP response. If we attach a then
clause, it will be called with the response value once it is available. A very
common thing to do is to then call the '.json()` method on the response to parse
the response body as JSON
fetch('http://example.com/')
.then((response) => response.json())
Here we write a shorthand arrow function containing just one statement, the
effect of this is that the function returns whatever that statement returns. An
explicit return is not needed. response.json()
parses the body as JSON and
returns the resulting Javascript data structure, it too returns a promise which
means that to handle the parsed data we need another then
clause:
fetch('http://example.com/')
.then((response) => response.json())
.then((data) => {
const username = data.username;
const password = data.password;
performLogin(username, password);
})
.catch(error => {
notifyError(error);
});
Here I have a chain of promises that perform steps of the process in an asynchronous manner. This example is a very common idiom for handling a fetch request in a Javascript application.
Another example of chaining is shown below. Here I define my own promise that resolves itself after a delay of 300 ms by scheduling a macro task that will call the resolve function.
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("promises");
}, 300);
});
myPromise.then(value => `${value} are`)
.then(value => `${value} confusing`)
.then(value => console.log(value));
After 300 ms, the promise myPromise
will resolve to the string value
"promises"
, the first then
clause will be called with this value and it
returns a new string "promises are"
. Because this is a promise chain, the
second then
clause is now called with this new value and returns "promises
are confusing"
. Finally the last then
clause is called and outputs this
value to the console. Only one delay of 300ms is invoked here, before the first
promise is resolved. The other then clauses are fired as soon as the previous
one returns a value.
Async and Await
Sometimes, you want to wait for something to happen rather than write promise
based code. This might be in a server side application when you are processing
an HTTP request and need to query the database before returning a result. In
this case you don't want to fire of a promise, you want to wait for the database
result and then build the response from it. In this case we can use the
async
/await
pair of keywords.
We'll use fetch
again as an example but this time we want to wait for the
result to come back and process it on the next line. We can write an async
function as follows:
const process = async (url) => {
const response = await fetch(url);
const body = response.body;
console.log(body);
return('done');
}
The first thing to note is that you can only use the await
keyword in a
function that is marked as async
. You can't use await
outside of a
function. The effect of await
is to force the execution of the call to be
synchronous - instead of returning a promise, the call will block execution of
the script until the response is available and then return that. This means
that the next line of code can process the response.
The returned value of an async function is always a promise that will resolve
to the explicitly returned value. In the above example, the function will
return a promise that will resolve to 'done'
.
Here's another example of an async/await pair:
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("promises");
}, 300);
});
const myFunction = async () => {
const msg = await myPromise
return `${msg} are confusing`;
}
myFunction().then(v => console.log(v));
The function waits for the promise to resolve, then returns a string that
includes the value of the promise. When the function is called, it returns a
promise and we attach a then
clause to that to output the final value. The
result is again that "promises are confusing
" is output.
You will see examples of the use of promises and await/async. The best way to learn and understand them is to study these examples and ensure that you understand what they are doing. Try to keep the idea of future execution of code clear in your mind as you write.