Asynchronous JavaScript programming allows you to execute long-running tasks in the background while allowing the browser to respond to events and run other code to handle these events. Asynchronous programming is relatively new in JavaScript, and the syntax to support it was not available when the first edition of this book was published.
JavaScript concepts such as promise
, async
, and await
make your code tidier and easy to read without blocking the main thread. async
functions were introduced as part of ES7 in 2016 and are now supported on all browsers. Let’s look at some patterns that use these features to structure our application flows.
In JavaScript, synchronous code is executed in a blocking manner, meaning that the code is executed serially, one statement at a time. The following code can run only after the execution of the current statement has been completed. When you call a synchronous function, the code inside that function will execute from start to finish before the control returns to the caller.
On the other hand, asynchronous code is executed in a nonblocking manner, meaning that the JavaScript engine can switch to execute this code in the background while the currently running code is waiting on something. When you call an asynchronous function, the code inside the function will execute in the background, and the control returns to the caller immediately.
Here is an example of synchronous code in JavaScript:
function
synchronousFunction
()
{
// do something
}
synchronousFunction
();
// the code inside the function is executed before this line
And here is an example of asynchronous code in JavaScript:
function
asynchronousFunction
()
{
// do something
}
asynchronousFunction
();
// the code inside the function is executed in the background
// while control returns to this line
You can generally use asynchronous code to perform long-running operations without blocking the rest of your code. Asynchronous code is suitable when making network requests, reading or writing to a database, or doing any other type of I/O (input/output) operation.
Language features such as async
, await
, and promise
make writing asynchronous code in JavaScript easier. They allow you to write asynchronous code in a way that looks and behaves like synchronous code, making it easier to read and understand.
Let’s briefly look at the differences between callbacks, promises, and async
/await
before diving into each in more depth:
// using callbacks
function
makeRequest
(
url
,
callback
)
{
fetch
(
url
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
callback
(
null
,
data
))
.
catch
(
error
=>
callback
(
error
));
}
makeRequest
(
'http://example.com/'
,
(
error
,
data
)
=>
{
if
(
error
)
{
console
.
error
(
error
);
}
else
{
console
.
log
(
data
);
}
});
In the first example, the makeRequest
function uses a callback to return the result of the network request. The caller passes a callback
function to makeRequest
, which is called back with either the result(data
) or an error
:
// using promises
function
makeRequest
(
url
)
{
return
new
Promise
((
resolve
,
reject
)
=>
{
fetch
(
url
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
resolve
(
data
))
.
catch
(
error
=>
reject
(
error
));
});
}
makeRequest
(
'http://example.com/'
)
.
then
(
data
=>
console
.
log
(
data
))
.
catch
(
error
=>
console
.
error
(
error
));
In the second example, the makeRequest
function returns a promise
that resolves with the result of the network request or rejects with an error. The caller can use the then
and catch
methods on the returned promise to handle the result of the request:
// using async/await
async
function
makeRequest
(
url
)
{
try
{
const
response
=
await
fetch
(
url
);
const
data
=
await
response
.
json
();
console
.
log
(
data
);
}
catch
(
error
)
{
console
.
error
(
error
);
}
}
makeRequest
(
'http://example.com/'
);
In the third example, the makeRequest
function is declared with the async
keyword, which allows it to use the await
keyword to wait for the result of the network request. The caller can use the try
and catch
keywords to handle any errors that may occur during the execution of the function.
Callback functions in JavaScript can be passed to another function as an argument and executed after some asynchronous operation is completed. Callbacks were commonly used to handle the results of asynchronous operations, such as network requests or user input.
One of the main disadvantages of using callbacks is that they can lead to what is known as “callback hell”—a situation where nested callbacks become challenging to read and maintain. Consider the following example:
function
makeRequest1
(
url
,
callback
)
{
// make network request
callback
(
null
,
response
);
}
function
makeRequest2
(
url
,
callback
)
{
// make network request
callback
(
null
,
response
);
}
function
makeRequest3
(
url
,
callback
)
{
// make network request
callback
(
null
,
response
);
}
makeRequest1
(
'http://example.com/1'
,
(
error
,
data1
)
=>
{
if
(
error
)
{
console
.
error
(
error
);
return
;
}
makeRequest2
(
'http://example.com/2'
,
(
error
,
data2
)
=>
{
if
(
error
)
{
console
.
error
(
error
);
return
;
}
makeRequest3
(
'http://example.com/3'
,
(
error
,
data3
)
=>
{
if
(
error
)
{
console
.
error
(
error
);
return
;
}
// do something with data1, data2, data3
});
});
});
In this example, the makeRequest1
function makes a network request and then calls the callback
function with the result of the request. The callback
function then makes a second network request using the makeRequest2
function, which calls another callback
function with its result. This pattern continues for the third network request.
Promises are a more modern approach to handling asynchronous operations in JavaScript. A promise is an object that represents the result of an asynchronous operation. It can be in three states: pending, fulfilled, or rejected. A promise is like a contract that can be settled if it is fulfilled or rejected.
You can create a promise using the Promise
constructor, which takes a function as an argument. The function receives two arguments: resolve
and reject
. The resolve
function is called when the asynchronous operation is completed successfully, and the reject
function is called if the operation fails.
Here is an example that shows how you can use promises to make network requests:
function
makeRequest
(
url
)
{
return
new
Promise
((
resolve
,
reject
)
=>
{
fetch
(
url
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
resolve
(
data
))
.
catch
(
error
=>
reject
(
error
));
});
}
makeRequest
(
'http://example.com/'
)
.
then
(
data
=>
console
.
log
(
data
))
.
catch
(
error
=>
console
.
error
(
error
));
In this example, the makeRequest
function returns a promise
representing the network request’s result. The fetch
method is used inside the function to make the HTTP request. If the request succeeds, the promise is fulfilled with the data from the response. If it fails, the promise is rejected with the error. The caller can use the then
and catch
methods on the returned promise to handle the result of the request.
One of the main advantages of using promises over callbacks is that they provide a more structured and readable approach to handling asynchronous operations. This allows you to avoid “callback hell” and write code that is easier to understand and maintain.
The following sections provide additional examples that will be relevant to help you understand the different promise design patterns that you can use in JavaScript.
This pattern allows you to chain multiple promises together to create more complex async
logic:
function
makeRequest
(
url
)
{
return
new
Promise
((
resolve
,
reject
)
=>
{
fetch
(
url
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
resolve
(
data
))
.
catch
(
error
=>
reject
(
error
));
});
}
function
processData
(
data
)
{
// process data
return
processedData
;
}
makeRequest
(
'http://example.com/'
)
.
then
(
data
=>
processData
(
data
))
.
then
(
processedData
=>
console
.
log
(
processedData
))
.
catch
(
error
=>
console
.
error
(
error
));
This pattern uses a cache to store the results of promise function calls, allowing you to avoid making duplicate requests:
const
cache
=
new
Map
();
function
memoizedMakeRequest
(
url
)
{
if
(
cache
.
has
(
url
))
{
return
cache
.
get
(
url
);
}
return
new
Promise
((
resolve
,
reject
)
=>
{
fetch
(
url
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
{
cache
.
set
(
url
,
data
);
resolve
(
data
);
})
.
catch
(
error
=>
reject
(
error
));
});
}
In this example, we’ll demonstrate how to use the memoizedMakeRequest
function to avoid making duplicate requests:
const
button
=
document
.
querySelector
(
'button'
);
button
.
addEventListener
(
'click'
,
()
=>
{
memoizedMakeRequest
(
'http://example.com/'
)
.
then
(
data
=>
console
.
log
(
data
))
.
catch
(
error
=>
console
.
error
(
error
));
});
Now, when the button is clicked, the memoizedMakeRequest
function will be called. If the requested URL is already in the cache, the cached data will be returned. Otherwise, a new request will be made, and the result will be cached for future requests.
This pattern uses promises and functional programming techniques to create a pipeline of async
transformations:
function
transform1
(
data
)
{
// transform data
return
transformedData
;
}
function
transform2
(
data
)
{
// transform data
return
transformedData
;
}
makeRequest
(
'http://example.com/'
)
.
then
(
data
=>
pipeline
(
data
)
.
then
(
transform1
)
.
then
(
transform2
))
.
then
(
transformedData
=>
console
.
log
(
transformedData
))
.
catch
(
error
=>
console
.
error
(
error
));
This pattern allows you to retry a promise if it fails:
function
makeRequestWithRetry
(
url
)
{
let
attempts
=
0
;
const
makeRequest
=
()
=>
new
Promise
((
resolve
,
reject
)
=>
{
fetch
(
url
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
resolve
(
data
))
.
catch
(
error
=>
reject
(
error
));
});
const
retry
=
error
=>
{
attempts
++
;
if
(
attempts
>=
3
)
{
throw
new
Error
(
'Request failed after 3 attempts.'
);
}
console
.
log
(
`Retrying request: attempt
${
attempts
}
`
);
return
makeRequest
();
};
return
makeRequest
().
catch
(
retry
);
}
This pattern uses a higher-order function to create a decorator that can be applied to promises to add additional behavior:
function
logger
(
fn
)
{
return
function
(...
args
)
{
console
.
log
(
'Starting function...'
);
return
fn
(...
args
).
then
(
result
=>
{
console
.
log
(
'Function completed.'
);
return
result
;
});
};
}
const
makeRequestWithLogger
=
logger
(
makeRequest
);
makeRequestWithLogger
(
'http://example.com/'
)
.
then
(
data
=>
console
.
log
(
data
))
.
catch
(
error
=>
console
.
error
(
error
));
async
/await
is a language feature that allows a programmer to write asynchronous code as if it were synchronous. It is built on top of promises, and it makes working with asynchronous code easier and cleaner.
Here is an example of how you might use async
/await
to make an asynchronous HTTP request:
async
function
makeRequest
()
{
try
{
const
response
=
await
fetch
(
'http://example.com/'
);
const
data
=
await
response
.
json
();
console
.
log
(
data
);
}
catch
(
error
)
{
console
.
error
(
error
);
}
}
In this example, the makeRequest
function is asynchronous because it uses the async
keyword. Inside the function, the await
keyword is used to pause the execution of the function until the fetch
call resolves. If the call succeeds, the data is logged to the console. If it fails, the error is caught and logged to the console.
Let us now look at some other patterns using async
.
This pattern involves composing multiple async
functions together to create more complex async
logic:
async
function
makeRequest
(
url
)
{
const
response
=
await
fetch
(
url
);
const
data
=
await
response
.
json
();
return
data
;
}
async
function
processData
(
data
)
{
// process data
return
processedData
;
}
async
function
main
()
{
const
data
=
await
makeRequest
(
'http://example.com/'
);
const
processedData
=
await
processData
(
data
);
console
.
log
(
processedData
);
}
This pattern allows you to run async
tasks in sequence using the Promise.resolve
method:
async
function
main
()
{
let
result
=
await
Promise
.
resolve
();
result
=
await
makeRequest1
(
result
);
result
=
await
makeRequest2
(
result
);
result
=
await
makeRequest3
(
result
);
console
.
log
(
result
);
}
This pattern uses a cache to store the results of async
function calls, allowing you to avoid making duplicate requests:
const
cache
=
new
Map
();
async
function
memoizedMakeRequest
(
url
)
{
if
(
cache
.
has
(
url
))
{
return
cache
.
get
(
url
);
}
const
response
=
await
fetch
(
url
);
const
data
=
await
response
.
json
();
cache
.
set
(
url
,
data
);
return
data
;
}
This pattern uses async
/await
and functional programming techniques to create a pipeline of async
transformations:
async
function
transform1
(
data
)
{
// transform data
return
transformedData
;
}
async
function
transform2
(
data
)
{
// transform data
return
transformedData
;
}
async
function
main
()
{
const
data
=
await
makeRequest
(
'http://example.com/'
);
const
transformedData
=
await
pipeline
(
data
)
.
then
(
transform1
)
.
then
(
transform2
);
console
.
log
(
transformedData
);
}
This pattern allows you to retry an async
operation if it fails:
async
function
makeRequestWithRetry
(
url
)
{
let
attempts
=
0
;
while
(
attempts
<
3
)
{
try
{
const
response
=
await
fetch
(
url
);
const
data
=
await
response
.
json
();
return
data
;
}
catch
(
error
)
{
attempts
++
;
console
.
log
(
`Retrying request: attempt
${
attempts
}
`
);
}
}
throw
new
Error
(
'Request failed after 3 attempts.'
);
}
This pattern uses a higher-order function to create a decorator that can be applied to async
functions to add additional behavior:
function
asyncLogger
(
fn
)
{
return
async
function
(...
args
)
{
console
.
log
(
'Starting async function...'
);
const
result
=
await
fn
(...
args
);
console
.
log
(
'Async function completed.'
);
return
result
;
};
}
@
asyncLogger
async
function
main
()
{
const
data
=
await
makeRequest
(
'http://example.com/'
);
console
.
log
(
data
);
}
In addition to the patterns discussed in the previous sections, let’s take a look at some practical examples of using async
/await
in JavaScript.
async
function
makeRequest
(
url
)
{
try
{
const
response
=
await
fetch
(
url
);
const
data
=
await
response
.
json
();
console
.
log
(
data
);
}
catch
(
error
)
{
console
.
error
(
error
);
}
}
async
function
readFile
(
filePath
)
{
try
{
const
fileData
=
await
fs
.
promises
.
readFile
(
filePath
);
console
.
log
(
fileData
);
}
catch
(
error
)
{
console
.
error
(
error
);
}
}
async
function
writeFile
(
filePath
,
data
)
{
try
{
await
fs
.
promises
.
writeFile
(
filePath
,
data
);
console
.
log
(
'File written successfully.'
);
}
catch
(
error
)
{
console
.
error
(
error
);
}
}
async
function
main
()
{
try
{
const
[
data1
,
data2
]
=
await
Promise
.
all
([
makeRequest1
(),
makeRequest2
()
]);
console
.
log
(
data1
,
data2
);
}
catch
(
error
)
{
console
.
error
(
error
);
}
}
async
function
main
()
{
try
{
const
data1
=
await
makeRequest1
();
const
data2
=
await
makeRequest2
();
console
.
log
(
data1
,
data2
);
}
catch
(
error
)
{
console
.
error
(
error
);
}
}
const
cache
=
new
Map
();
async
function
makeRequest
(
url
)
{
if
(
cache
.
has
(
url
))
{
return
cache
.
get
(
url
);
}
try
{
const
response
=
await
fetch
(
url
);
const
data
=
await
response
.
json
();
cache
.
set
(
url
,
data
);
return
data
;
}
catch
(
error
)
{
throw
error
;
}
}
const
button
=
document
.
querySelector
(
'button'
);
button
.
addEventListener
(
'click'
,
async
()
=>
{
try
{
const
data
=
await
makeRequest
(
'http://example.com/'
);
console
.
log
(
data
);
}
catch
(
error
)
{
console
.
error
(
error
);
}
});
async
function
makeRequest
(
url
)
{
try
{
const
response
=
await
fetch
(
url
);
const
data
=
await
response
.
json
();
return
data
;
}
catch
(
error
)
{
throw
error
;
}
}
async
function
retry
(
fn
,
maxRetries
=
3
,
retryDelay
=
1000
)
{
let
retries
=
0
;
while
(
retries
<=
maxRetries
)
{
try
{
return
await
fn
();
}
catch
(
error
)
{
retries
++
;
console
.
error
(
error
);
await
new
Promise
(
resolve
=>
setTimeout
(
resolve
,
retryDelay
));
}
}
throw
new
Error
(
`Failed after
${
retries
}
retries.`
);
}
retry
(()
=>
makeRequest
(
'http://example.com/'
)).
then
(
data
=>
{
console
.
log
(
data
);
});
function
asyncDecorator
(
fn
)
{
return
async
function
(...
args
)
{
try
{
return
await
fn
(...
args
);
}
catch
(
error
)
{
throw
error
;
}
};
}
const
makeRequest
=
asyncDecorator
(
async
function
(
url
)
{
const
response
=
await
fetch
(
url
);
const
data
=
await
response
.
json
();
return
data
;
});
makeRequest
(
'http://example.com/'
).
then
(
data
=>
{
console
.
log
(
data
);
});
This chapter covered an extensive set of patterns and examples that can be useful when writing asynchronous code for executing long-running tasks in the background. We saw how callback
functions made way for promises and async
/await
to execute one or many async
tasks.
In the next chapter, we will look at another angle of application architecture patterns. We will look at how the patterns for modular development have evolved over time.