Promise and async await
Gohomewho
Posted on July 31, 2022
Probably most of us learn Promise
for the first time is when we start learning how to call an API with fetch
to get recourses on the web. They are two complex topics. It is difficult to learn them at once. In this article, we will learn how to use Promise
and its syntax sugar async await
. So later when you learn fetch
, you don't need to worry about Promise
.
By the way, if you don't know what API means or you have been confused by the term, I like to think it as anything that people make it easy for us to use. In web, people usually refers API to an URL that you can simply call it, and it will give you data or do some stuff on the server side. API can mean different things in different context. Promise
is a browser API, which means that it is not a built in feature in JavaScript. If we run JavaScript in Browsers, we can easily use those browser API, because people have made them easy for us.
If you'd like to follow along, you can open the console of devtools and code right there. It would be better to open the console on a new browser tab, because sometimes there are errors that you need to refresh to get rid of, but you don't want to refresh the page you currently reading.
Understand how to use promise
I think there are two keys to understand how to use Promise
, callback and the order of code execution.
Callback
Callback is essentially a function, it is passed to another function as an argument. How callback is called and used is decided inside the function that we pass the callback to. Let's look at an example.
// make a function that accept a callback
function myFunc(callback) {
const state = {
status: 'pending',
value: undefined
}
// callback is just a variable name that you can name it whatever you like
// but since we call it like a function, it has to be a function
// here, we just simply call it
callback()
return state
}
Calling myFunc
without passing a function as an argument. That callback
parameter inside myFunc
will be undefined, so this will error.
myFunc()
// Uncaught TypeError: callback is not a function
Passing a function as a callback to myFunc
. We can define inline function as an argument directly when we are calling a function.
myFunc(() => {
console.log('hello')
})
// hello
// {status: 'pending', value: undefined}
'hello' is logged by the callback and the object is returned by myFunc
.
Generally when you see a function that accept a callback, that callback will be called with some variables that lives inside the function.
function myFunc(callback) {
const state = {
status: 'pending',
value: undefined
}
// add a function
function resolve() {
state.status = 'fulfilled'
}
// add another function
function reject() {
state.status = 'rejected'
}
// pass new added function as arguments
callback(resolve, reject)
return state
}
Now our callback is called with two arguments, so we can define the callback with two parameters.
const result = myFunc((res, rej) => {})
result // {status: 'pending', value: undefined}
I name the parameters of the callback res
and rej
on purpose. We can name parameters whatever we want. But if we don't call the callback in myFunc
with those two arguments resolve
and reject
, then res
and rej
will be undefined. Just like what we saw earlier why we call myFunc()
without passing a callback will show error. res
and rej
do not magically appear. It can be confused because normally we would define a function before we use it. On the other hand, callback is like it is used before we define it. Because it is already used somehow and somewhere inside a function, we'll need to follow the rules.
We can call the resolve
function passed from myFunc
in our callback.
const result = myFunc((res, rej) => {
res() // res is the resolve function passed from myFunc
})
// status becomes 'fulfilled'
result // {status: 'fulfilled', value: undefined}
Modify resolve
and reject
function so they can take value and change the state.
function myFunc(callback) {
const state = {
status: 'pending',
value: undefined
}
// accept a parameter
function resolve(v) {
state.status = 'fulfilled'
// modify state.value with that parameter
state.value = v
}
// add a parameter
function reject(v) {
state.status = 'rejected'
// modify state.value with that parameter
state.value = v
}
callback(resolve, reject)
return state
}
Calling resolve
with a value in the callback.
// we only want to call resolve
// so we can omit defining a second parameter on the callback
const result = myFunc((res) => {
res('nice')
})
result // {status: 'fulfilled', value: 'nice'}
Calling reject
with a value in the callback.
const result = myFunc((res, rej) => {
rej('bad')
})
result // {status: 'rejected', value: 'bad'}
We can use a condition to determine whether we call resolve
or reject
.
const result = myFunc((res, rej) => {
// a lots of logic
// ...
const someCondition = true // imagine it could be true or false
if(someCondition === true) {
res('nice')
} else {
rej('bad')
}
})
Generally, a function that accepts callbacks is more generic, and the callbacks are for us to control the state that created from the function. In our example, myFunc
accept a callback and call that callback with two functions resolve
and reject
. In that callback, we can do some computation and use resolve
and reject
as we want to modify the state created from myFunc
.
We could also call res
and rej
"callbacks". Personally, I often get confused by the word, so I just call them "functions" and focus on the code rather than a term.
Now we know how things get passed around with callback, we can move on to the next part.
The order of code execution
JavaScript runs synchronous code immediately. Synchronous code can schedule something to run asynchronously. When an asynchronous task is done and ready to do something else, it will be run after the current synchronous code is finished.
What is synchronous code
Synchronous code are run one by one. console.log()
is synchronous code, we run it and it finishes instantly.
console.log(1) // run after previous code
console.log(2) // run after console.log(1)
console.log(3) // run after console.log(2)
// 1
// 2
// 3
What is asynchronous code
setTimeout()
is a browser API that can schedule something to run asychronously. In this example, setTimeout()
is called immediately after console.log(1)
and right before the console.log(3)
. The callback we pass to setTimeout()
, however, is run asynchronously. That's why we'll see 3
being logged before 2
.
console.log(1) // run after previous code
// setTimeout is run immediately after console.log(1)
setTimeout(() => {
console.log(2) // the callback is scheduled to run asynchronously after 1000ms
}, 1000)
console.log(3) // run after setTimeout
// 1
// 3
// 2
The second argument of setTimout()
schedules how much time to run the callback later. 1000ms doesn't guarantee to be exactly 1000ms. It has to wait current synchronous code to be finished. For example, if the timer is count to 999ms, and there is an user interaction that triggers a for loop that takes 500ms to run, the callback of the setTimout()
will need to wait that for loop, asynchronous code won't interrupt the order of current synchronous code.
Asynchronous code are always run after current synchronous code. Although we set the second argument of setTimeout()
to 0ms
, the callback of setTimeout()
is still scheduled as an asynchronous code, it has to wait current synchronous code to be finished.
console.log(1)
setTimeout(() => {
console.log(2)
}, 0) // set delay to 0ms
console.log(3)
// 1
// 3
// 2
We can imagine that JavaScript has a list of current synchronous code waiting to be executed. When asynchronous code is ready, it will be added at the end of that list and becomes part of the synchronous code.
If you are interested in learning more about how JavaScript run our code, I would recommend watching this series on YouTube created by Akshay Saini
Promise
Promise is a way to schedule asynchronous code. Unlike setTimeout
that only schedules asynchronous code to run later. We can run synchronous code when we create a Promise
and schedule other things to run asynchronously. Let's first learn how promise work on its own, and then we'll learn how it works with other code.
How promise works on its own
This is how we create a Promise
. new Promise()
with a callback () => {}
. new is a JavaScript keyword used to create a new object. We can create an array like this new Array()
, but usually we simply write the literal form []
. Promise
doesn't have a literal form, so we need to create one like new Promise(callback)
.
new Promise(() => {})
// Promise {<pending>}
Notice that there is an callback that does nothing. If we don't provide a callback to new Promise()
, it will error. If you don't know why, you probably should go back to previous section where we break down callback.
Inside Promise
, it calls our callback with two arguments, resolve
and reject
. They are two functions provided from the promise to our callback. They are used to control the state of Promise
.
new Promise((resolve, reject) => {})
// Promise {<pending>}
When we call resolve()
, the state of the promise becomes "fulfilled".
new Promise((resolve, reject) => {
resolve()
})
// Promise {<fulfilled>: undefined}
We didn't pass argument to resolve(), so the result of this promise is undefined
. it's like when we call a function that doesn't not explicitly return anything, the function will always return undefined.
This time we resolve()
the promise with a value 'nice'. The state of the promise becomes "fulfilled" and the result is "nice".
new Promise((resolve, reject) => {
resolve('nice')
})
// Promise {<fulfilled>: 'nice'}
A promise could be resolved or rejected. reject
works similar to resolve
, it changes the state of the promise and provides a result.
new Promise((resolve, reject) => {
reject('bad')
})
// Promise {<rejected>: 'bad'}
If you run this code, you will see Uncaught (in promise) bad
. We'll deal with it later, so let's ignore it for now.
At this point, we only make synchronous code.
console.log(1)
new Promise((resolve, reject) => {
console.log(2)
resolve('nice')
console.log(3)
})
console.log(4)
// this result shows that everything runs in order, synchronously.
// 1
// 2
// 3
// 4
Keep one thing in mind, when we construct a promise, it schedules something to run asynchronously immediately. The action of starting a task is synchronous. It is the action of handling the result of resolve
or reject
being asynchronous.
So, how do we handle the result?
Promise
object has a .then
method that we can use to handle the result of a "fulfilled" state promise which has been resolved with resolve()
.
.then()
also accept a callback, it will pass the result of the previous Promise
to the callback.
new Promise((resolve, reject) => {
resolve('nice')
}).then((result) => {
console.log(result)
})
// nice
If you run this code in the console, you probably have noticed one thing. After "nice" is logged, there is a promise logged.
// Promise {<fulfilled>: undefined}
Before we learn where this promise comes from, we need to know why it is logged.
When we call a function in the console, it will automatically log the result of the function. If a function doesn't explicitly return anything, it returns undefined.
console.log(1)
// 1
// console.log() doesn't explicitly return anything, so it returns undefined.
// undefined
I am using chrome and I am not sure if this happens in other browsers.
Alright, so where does that promise come from? In fact, what we return from .then()
will be a new "fulfilled" state promise with the result of the returned value. We only console.log(result)
in .then()
and didn't return anything explicitly, so the result is undefined.
new Promise((resolve, reject) => {
resolve('nice')
}).then((result) => {
console.log(result)
})
// nice
// Promise {<fulfilled>: undefined}
This means that we can chain .then
on and on.
new Promise((resolve, reject) => {
resolve('nice')
}).then((result) => {
console.log(result) // 'nice'
}).then((result) => {
console.log(result) // undefined
})
We can use .catch()
to handle the result of a "rejected" state promise which has been rejected with reject()
. We can schedule something to run like promise1.then().then().catch()
. if one of the promises in the chain is rejected, .then()
will be skipped and .catch()
will take the rejected result.
new Promise((resolve, reject) => {
reject('bad')
}).then((result) => {
// previous promise is rejected, so the code inside .then() is skipped
console.log(result)
}).catch((error) => {
console.log(error) // bad is logged from here
})
// bad
We handle the rejected promise, so we won't see Uncaught (in promise) bad
error this time. You probably have noticed Promise {<fulfilled>: undefined}
appear in the console again. This is something you'd like to keep in mind. What we return from .catch()
is also a "fulfilled" state promise.
We can return a rejected promise in .then()
or .catch()
with throw new Error('some message')
.
new Promise((resolve, reject) => {
resolve('nice')
}).then((result) => {
if(result !== 'great') {
// throw error inside .then will return a rejected promise
throw new Error('not good enough')
}
}).catch((error) => {
console.log(error)
})
// Error: not good enough
Throwing an error inside .catch()
.
new Promise((resolve, reject) => {
reject('bad')
}).then((result) => {
console.log(result)
}).catch((error) => {
if(error === 'bad') {
throw new Error('this is really bad')
}
})
// Uncaught (in promise) Error: this is really bad
The error we see in the console proves that we returns a rejected promise.
Promise.reject()
is an another way to return a rejected promise. This is useful when we want to return a more complex data like an object.
// return a rejected promise in .then
new Promise((resolve, reject) => {
resolve('nice')
}).then((result) => {
return Promise.reject({reason: 'something went wrong'})
})
// Promise {<rejected>: {…}}
// return a rejected promise in .catch
new Promise((resolve, reject) => {
reject('bad')
}).catch((error) => {
return Promise.reject({reason: 'something went wrong'})
})
// Promise {<rejected>: {…}}
Notice that we didn't add new
keyword to Promise.reject()
. It is a method available on the Promise
object. It's similar to how we call Array.isArray()
, using the Array
object.
Returning another rejected promise from .catch()
seems to be redundant. The idea is that sometimes we would handle in different ways. Returning a rejected promise means that we can .catch()
it in other places.
How promise work with other code
So far we only make Promise
and resolve or reject it immediately. In real world, we usually don't know how much time doing something can take, but we want to react to it as soon as something is done. That's the value of scheduling asynchronous code with Promise
. Let's see an example to help understand how asynchronous code run.
// define a variable
let promiseResolver = null
new Promise((resolve, reject) => {
// store the resolve of this promise to the variable
promiseResolver = resolve
}).then((result) => {
console.log(1, result)
}).then((result) => {
console.log(2, result)
})
// This is the first promise we created with new Promise(callback)
// Promise {<pending>}
We don't see 1
and 2
being logged from those two .then()
because .then()
is only called when the previous promise is resolved.
Now we can call the resolve
function stored in promiseResolver
to resolve the first promise.
promiseResolver('hello')
// 1 'hello'
// 2 undefined
As soon as we call promiseResolver('hello')
, first .then()
can run and 1
is logged as expect. second .then()
also runs because the first .then()
implicitly return a "fulfilled" promise with a value undefined
. That's why the second log is 2 undefined
. Although we see the results from .then()
right after we resolve the first promise, asynchronous code has to wait the current synchronous code to be finished, which means that if a promise is resolved or rejected during some synchronous code running, .then()
or .catch()
will be queued and run after those synchronous code already in the list are finished.
A promise can only be "fulfilled" or "rejected" once. Let's modify the example and explicitly return a new promise in the first .then()
.
let promiseResolver = null
new Promise((resolve, reject) => {
promiseResolver = resolve
}).then((result) => {
console.log(1, result)
// explicitly return a new promise
return new Promise(() => {})
}).then((result) => {
console.log(2, result)
})
promiseResolver only stores the resolve function of the first promise, so calling it multiple times will only work for the first time.
// resolve first promise
promiseResolver('hello')
// 1 'hello'
// no effect
promiseResolver('hello')
promiseResolver('hello')
promiseResolver('hello')
The second .then()
will never be triggered because we didn't store the resolve function of the promise returned from the first promise, which means that the state of the promise will always stay "pending".
This time we also store the resolve function of the promise returned from first .then()
.
let promiseResolver = null
new Promise((resolve, reject) => {
// store resolve function of this promise
promiseResolver = resolve
})
.then((result) => {
console.log(1, result)
// explicitly return a new promise
return new Promise((resolve, reject) => {
// store resolve function of this promise
promiseResolver = resolve
})
})
.then((result) => {
console.log(2, result)
})
Resolving the first and the second promise with the resolve functions that were assigned to promiseResolver
.
// resolve first promise created by new Promise()
promiseResolver('hello')
// run first .then
// 1 'hello'
// resolve second promise returned by first .then
promiseResolver('hi')
// run second .then
// 2 'hi'
Again, .then
only runs after the previous promise is resolved.
Let's see how promise work with more synchronous code.
console.log(1)
new Promise((resolve, reject) => {
console.log(2)
resolve()
console.log(3)
}).then(() => {
console.log(4) // 4 is here
})
console.log(5)
// 1
// 2
// 3
// 5
// 4 is here
This is a promise that resolves immediately. The code inside new Promise()
's callback is synchronous. Promise can schedule asynchronous code with .then()
. We want to console.log(4)
after the promise is resolved as soon as possible, but asynchronous code has to wait until all synchronous code are finished. When JavaScript see asynchronous code, it will put asynchronous code aside and read next statement. if next statement is synchronous, JavaScript runs that code immediately. if next statement is asynchronous, JavaScript puts that code aside again and read the next statement. When asynchronous code is ready to be run, it will be queued to the end of the synchronous code. That's why 4
is logged after 5
.
Challenge:
Can you reason about the result of this snippet?
console.log(1)
new Promise((resolve, reject) => {
console.log(2)
resolve()
}).then(() => {
console.log(3)
})
new Promise((resolve, reject) => {
resolve()
console.log(4)
}).then(() => {
console.log(5)
}).then(() => {
console.log(6)
})
new Promise((resolve, reject) => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
})
console.log(9)
Result:
1
2
4
7
9
3
5
8
6
It's totally fine if you don't get the correct result by reading the snippet, but I think you are able to tell why after you see the result. We won't use promises like this snippet in real world. This is only for testing if you understand the order of synchronous and asynchronous code.
async await
async
function and await
is the syntax sugar to work with Promise
.
Creating an async function is easy. Define a function and put the async
keyword before the function
keyword. What we return from a async function will implicitly be a "fulfilled" promise.
async function myFunc() {
}
myFunc()
// Promise {<fulfilled>: undefined}
We can explicitly throw new Error
or return Promise.reject()
to return a rejected promise.
async function myFunc() {
return Promise.reject({reason: 'something went wrong'})
}
myFunc()
// Promise {<rejected>: {…}}
The benefit of using async function is that we can use await
keyword to make asynchronous code look like synchronous code.
async function myFunc() {
console.log(1)
// await will wait this promise to be done before moving on to next statement.
await new Promise((resolve, reject) => {
console.log(2)
resolve()
})
// below code runs after the promise being await is done
console.log(3)
}
myFunc()
// 1
// 2
// 3
This is the same code writing without async
and await
. We need to chain a .then()
to get the correct order of running console.log(3)
.
function myFunc() {
console.log(1)
new Promise((resolve, reject) => {
console.log(2)
resolve()
}).then(() => {
console.log(3)
})
}
myFunc()
We can await
a promise to store the result of a "fulfilled" promise with a variable
async function myFunc() {
const result = await new Promise((resolve, reject) => {
resolve('hello')
})
console.log(result)
}
myFunc()
// hello
async function returns a promise, so it can also be await
. Unless we explicitly return something or the code reach the end of a async function, the promise state would be "pending", which means that calling an async function with await
, the code of that async function has to be done before moving on.
// async function returns a promise
async function myFunc() {
console.log(1)
await new Promise((resolve, reject) => {
console.log(2)
// delay the resolve
setTimeout(() => {
resolve()
}, 1000)
})
console.log(3)
}
// calling another async function inside this async function
async function anotherFunc() {
console.log(4)
// await an async function
await myFunc()
console.log(5)
}
anotherFunc()
// 4
/*
await myFunc()
1
2
wait 1000ms
3
*/
// 5
We can use try catch
to handle rejected promise inside async function.
async function myFunc() {
try {
await new Promise((resolve, reject) => {
reject('something went wrong')
})
} catch(error) {
console.log(error)
}
}
myFunc()
// something went wrong
try catch is not a special thing for async function. It is meant to be used to catch errors.
We can handle error in the child scope and return a "fulfilled" promise, and decide what to do with the result inside the parent scope.
async function myFunc() {
try {
await new Promise((resolve, reject) => {
// change the rejected value here to see different result from calling anotherFunc()
reject('not a big deal')
})
} catch(error) {
if (error === 'not a big deal') {
return 'ok'
} else {
return 'bad'
}
}
}
async function anotherFunc() {
try {
// the function being called is the child scope
const result = await myFunc()
if (result === 'ok') {
console.log('keep running code below')
} else {
throw new Error('throw error in try will jump to catch')
}
// other code
// ...
} catch(error) {
console.log(error)
}
}
anotherFunc()
// keep running code below
We can also return a rejected promise from the child scope, so we don't need to examine the result again in try
block of the parent scope and can handle the error directly in catch
block.
async function myFunc() {
try {
await new Promise((resolve, reject) => {
reject('is a big deal')
})
} catch(error) {
if (error === 'not a big deal') {
return 'ok'
} else {
// explicitly return a rejected promise
return Promise.reject('bad')
}
}
}
async function anotherFunc() {
try {
// await a promise that is rejected will jump to catch
const result = await myFunc()
// there is an error above
// so rest of the code in try block won't run
// ...
} catch(error) {
console.log(error)
}
}
anotherFunc()
Which one should be used is based on your needs.
Wrap up
In this article, we learn the necessary fundamentals to understand Promise
. We learn how to use Promise
and also learn how to use async await
to work with Promise
. I've tried to make this article as simple as possible, but learning promise can be frustrated. I hope this article can help make your journeys a little bit easier.
Posted on July 31, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.