Async JavaScript and Node.js Design Patterns
Luciano Mammino (@loige)
SAILSCONF
2022-06-22
Get these slides!
Let me introduce myself first...
👋 I'm Luciano (🇮🇹🍕🍝🤌)
👨💻 Senior Architect @ fourTheorem (Dublin 🇮🇪)
📔 Co-Author of Node.js Design Patterns 👉
Always re-imagining
We are a pioneering technology consultancy leveraging modern application development and serverless technologies to help our clients become agile digital businesses. Curious and deep learners, we give our honest assessments in the context of your business.
Accelerated Serverless | AI as a Service | Platform Modernisation
Fact: Async JavaScript is tricky!
callbacks
promises
Async/Await
async generators
streams
event emitters
util.promisify()
Promise.all()
Promise.allSettled()
😱
Agenda
- Async WUT?!
- Callbacks
- Promises
- Async / Await
- async Patterns
- Mixed style async
- A performance trick!
What does async even mean?
-
In JavaScript and in Node.js, input/output operations are non-blocking.
-
Classic examples: reading the content of a file, making an HTTP request, loading data from a database, etc.
Blocking style vs JavaScript
Blocking style
JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
Blocking style vs JavaScript
Blocking style
JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
Blocking style vs JavaScript
Blocking style
JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
Blocking style vs JavaScript
Blocking style
JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
Blocking style vs JavaScript
Blocking style
JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
(done)
Blocking style vs JavaScript
Blocking style
JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
(done)
(done)
Non-blocking I/O is convenient:
you can do work while waiting for I/O!
But, what if we need to do something when the I/O operation completes?
Once upon a time there were...
Callbacks
Anatomy of callback-based non-blocking code
doSomethingAsync(arg1, arg2, cb)
This is a callback
Anatomy of callback-based non-blocking code
doSomethingAsync(arg1, arg2, (err, data) => {
// ... do something with data
})
You are defining what happens when the I/O operations completes (or fails) with a function.
doSomethingAsync will call that function for you!
Anatomy of callback-based non-blocking code
doSomethingAsync(arg1, arg2, (err, data) => { if (err) { // ... handle error return } // ... do something with data })
Always handle errors first!
An example
- Fetch the latest booking for a given user
- If it exists print it
An example
getLatestBooking(userId, (err, booking) => {
if (err) {
console.error(err)
return
}
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
} else {
console.log(`No booking found for user ${userId}`)
}
})
Callback
Error handling
Do something with the data
A more realistic example
- Fetch the latest booking for a given user
- If it exists, cancel it
- If it was already paid for, refund the user
getLatestBooking(userId, (err, booking) => {
if (err) {
console.error(err)
return
}
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
cancelBooking(booking.id, (err) => {
if (err) {
console.error(err)
return
}
if (booking.paid) {
console.log('Booking was paid, refunding the user')
refundUser(userId, booking.paidAmount, (err) => {
if (err) {
console.error(err)
return
}
console.log('User refunded')
})
}
})
} else {
console.log(`No booking found for user ${userId}`)
}
})
getLatestBooking(userId, (err, booking) => {
if (err) {
console.error(err)
return
}
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
cancelBooking(booking.id, (err) => {
if (err) {
console.error(err)
return
}
if (booking.paid) {
console.log('Booking was paid, refunding the user')
refundUser(userId, booking.paidAmount, (err) => {
if (err) {
console.error(err)
return
}
console.log('User refunded')
})
}
})
} else {
console.log(`No booking found for user ${userId}`)
}
})
Nested callback
Another
nested callback
Repeated error handling
getLatestBooking(userId, (err, booking) => {
if (err) {
console.error(err)
return
}
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
cancelBooking(booking.id, (err) => {
if (err) {
console.error(err)
return
}
if (booking.paid) {
console.log('Booking was paid, refunding the user')
refundUser(userId, booking.paidAmount, (err) => {
if (err) {
console.error(err)
return
}
console.log('User refunded')
})
}
})
} else {
console.log(`No booking found for user ${userId}`)
}
})
THE PIRAMID OF DOOM
(or callback hell 🔥)
🤷♀️
Some times, just refactoring the code can help...
function cancelAndRefundBooking(booking, cb) {
cancelBooking(booking.id, (err) => {
if (err) { return cb(err) }
if (!booking.paid) {
return cb(null, {refundedAmount: 0})
}
refundUser(booking.userId, booking.paidAmount, (err) => {
if (err) { return cb(err) }
return cb(null, {refundedAmount: booking.paidAmount})
})
})
}
a callback-based function
In case of error, we propagate it through the callback
in case of success, we return a result object through the callback
Note how we inverted the condition here to be able to return early!
getLatestBooking(userId, (err, booking) => {
if (err) {
console.error(err)
return
}
if (booking) {
cancelAndRefundBooking(booking, (err, result) => {
if (err) {
console.error(err)
return
}
console.log(`Booking cancelled (${result.refundedAmount} refunded)`)
})
}
})
Call our helper function with a callback function
Handle errors
Success path
🥳 We removed one level of nesting and some code duplication!
😟
Is this the best we can do?
Let's talk about
Promise
With callbacks we are not in charge!
We need to trust that the async function will call our callbacks when the async work is completed!
Promise help us to be more in control!
const promiseObj = doSomethingAsync(arg1, arg2)
An object that represents the status of the async operation
Promise help us to be more in control!
const promiseObj = doSomethingAsync(arg1, arg2)
A promise object is a tiny state machine with 2 possible states
- pending (still performing the async operation)
-
settled (completed)
- ✅ fullfilled (witha value)
- 🔥 rejected (with an error)
Promise help us to be more in control!
const promiseObj = doSomethingAsync(arg1, arg2)
promiseObj.then((data) => {
// ... do something with data
})
Promise help us to be more in control!
const promiseObj = doSomethingAsync(arg1, arg2) promiseObj.then((data) => { // ... do something with data }) promiseObj.catch((err) => { // ... handle errors }
Promises can be chained ⛓
This solves the pyramid of doom problem!
doSomethingAsync(arg1, arg2) .then((result) => doSomethingElseAsync(result)) .then((result) => doEvenMoreAsync(result) .then((result) => keepDoingStuffAsync(result)) .catch((err) => { /* ... */ })
These return a promise
Every .then() receives the value that has been resolved by the promised returned in the previous step
.catch() will capture errors at any stage of the pipeline
Promises can be chained ⛓
This solves the pyramid of doom problem!
doSomethingAsync(arg1, arg2)
.then((result) => doSomethingElseAsync(result))
// ...
.catch((err) => { /* ... */ })
.finally(() => { /* ... */ })
.finally() will run when the promise settles (either resolves or rejects)
How to create a promise
new Promise ((resolve, reject) => { // ... })
this is the promise executor function. It allows you to specify how the promise behave.
This function is executed immediately!
How to create a promise
new Promise ((resolve, reject) => {
// ... do something async
// reject(someError)
// resolve(someValue)
})
call reject() to mark the promise as settled with an error (rejected)
call resolve() to mark the promise as settled with a value (resolved)
How to create a promise
Promise.resolve('SomeValue') Promise.reject(new Error('SomeError'))
Easy way to create a promise that is already resolved with a given value
easy way to create a promise that is already rejected with a given error
How to create a promise (example)
function queryDB(client, query) {
return new Promise((resolve, reject) => {
client.executeQuery(query, (err, data) => {
if (err) {
return reject(err)
}
resolve(data)
})
})
}
Executor Function
Async action
Callback
Handle errors and propagate them with reject()
In case of success, propagate the result with resolve()
How to create a promise (example)
queryDB(dbClient, 'SELECT * FROM bookings')
.then((data) => {
// ... do something with data
})
.catch((err) => {
console.error('Failed to run query', err)
})
.finally(() => {
dbClient.disconnect()
})
Let's re-write our example with Promise
- Fetch the latest booking for a given user
- If it exists, cancel it
- If it was already paid for, refund the user
getLatestBooking(userId)
.then((booking) => {
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
return cancelBooking(booking.id)
}
console.log(`No booking found for user ${userId}`)
})
.then((cancelledBooking) => {
if (cancelledBooking && cancelledBooking.paid) {
console.log('Booking was paid, refunding the user')
return refundUser(userId, cancelledBooking.paidAmount)
}
})
.then((refund) => {
if (refund) {
console.log('User refunded')
}
})
.catch((err) => {
console.error(err)
})
No pyramid of doom, but it's tricky to handle optional async steps correctly
enters...
Async/Await
Sometimes, we just want to wait for a promise to resolve before executing the next line...
const promiseObj = doSomethingAsync(arg1, arg2) const data = await promiseObj // ... process the data
await allows us to do exactly that
const data = await doSomethingAsync(arg1, arg2) // ... process the data
We don't have to assign the promise to a variable to use await
Sometimes, we just want to wait for a promise to resolve before executing the next line...
try { const data = await doSomethingAsync(arg1, arg2) // ... process the data } catch (err) { // ... handle error }
Unified error handling
If we await a promise that eventually rejects we can capture the error with a regular try/catch block
Async functions
async function doSomethingAsync(arg1, arg2) { // ... }
special keyword that marks a function as async
Async functions
async function doSomethingAsync(arg1, arg2) { return 'SomeValue' }
An async function implicitly returns a promise
function doSomethingAsync(arg1, arg2) { return Promise.resolve('SomeValue') }
These two functions are semantically equivalent
Async functions
async function doSomethingAsync(arg1, arg2) { throw new Error('SomeError') }
function doSomethingAsync(arg1, arg2) { return Promise.reject(new Error('SomeError')) }
These two functions are semantically equivalent
Similarly, throwing inside an async function implicitly returns a rejected promise
Async functions
async function doSomethingAsync(arg1, arg2) { const res1 = await doSomethingElseAsync() const res2 = await doEvenMoreAsync(res1) const res3 = await keepDoingStuffAsync(res2) // ...
}
inside an async function you can use await to suspend the execution until the awaited promise resolves
Async functions
async function doSomethingAsync(arg1, arg2) { const res = await doSomethingElseAsync() if (res) { for (const record of res1.records) { await updateRecord(record) } } }
Async functions make it very easy to write code that manages asynchronous control flow
Let's re-write our example with async/await
- Fetch the latest booking for a given user
- If it exists, cancel it
- If it was already paid for, refund the user
async function cancelLatestBooking(userId) {
const booking = await getLatestBooking(userId)
if (!booking) {
console.log(`No booking found for user ${userId}`)
return
}
console.log(`Found booking for user ${userId}`, booking)
await cancelBooking(booking.id)
if (booking.paid) {
console.log('Booking was paid, refunding the user')
await refundUser(userId, booking.paidAmount)
console.log('User refunded')
}
}
Mini summary
- Async/Await generally helps to keep the code simple & readable
- To use Async/Await you need to understand Promise
- To use Promise you need to understand callbacks
- callbacks → Promise → async/await
- Don't skip any step of the async journey!
Async Patterns ❇️
Sequential execution
const users = ['Peach', 'Toad', 'Mario', 'Luigi']
for (const userId of users) {
await cancelLatestBooking(userId)
}
Sequential execution (gotcha!)
const users = ['Peach', 'Toad', 'Mario', 'Luigi']
users.forEach(async (userId) => {
await cancelLatestBooking(userId)
})
⚠️ Don't do this with Array.map() or Array.forEach()
Array.forEach() will run the provided function without awaiting for the returned promise, so all the invocation will actually happen concurrently!
Concurrent execution (Promise.all)
const users = ['Peach', 'Toad', 'Mario', 'Luigi']
await Promise.all(
users.map(
userId => cancelLatestBooking(userId)
)
)
Promise.all() receives a list of promises and it returns a new Promise. This promise will resolve once all the original promises resolve, but it will reject as soon as ONE promise rejects
Concurrent execution (Promise.allSettled)
const users = ['Peach', 'Toad', 'Mario', 'Luigi']
const results = await Promise.allSettled(
users.map(
userId => cancelLatestBooking(userId)
)
)
[
{ status: 'fulfilled', value: true },
{ status: 'fulfilled', value: true },
{ status: 'rejected', reason: Error },
{ status: 'fulfilled', value: true }
]
Mixing async styles
👩🍳
You want to use async/await but...
you have a callback-based API! 😣
Node.js offers promise-based alternative APIs
Callback-based
Promise-based
setTimeout, setImmediate, setInterval
import timers from 'timers/promises'
import fs from 'fs'
import fs from 'fs/promises'
import stream from 'stream'
import stream from 'stream/promises'
import dns from 'dns'
import dns from 'dns/promises'
util.promisify()
import { gzip } from 'zlib' // zlib.gzip(buffer[, options], callback)
import { promisify } from 'util'
const gzipPromise = promisify(gzip)
const compressed = await gzipPromise(Buffer.from('Hello from Node.js'))
console.log(compressed) // <Buffer 1f 8b 08 00 00 00 00 ... 00 00 00>
Promisify by hand 🖐
import { gzip } from 'zlib' // zlib.gzip(buffer[, options], callback)
function gzipPromise (buffer, options) {
return new Promise((resolve, reject) => {
gzip(buffer, options, (err, gzippedData) => {
if (err) {
return reject(err)
}
resolve(gzippedData)
})
})
}
const compressed = await gzipPromise(Buffer.from('Hello from Node.js'))
console.log(compressed) // <Buffer 1f 8b 08 00 00 00 00 ... 00 00 00>
What if we we want to do the opposite? 🤷
Convert a promise-based function to a callback-based one
OK, this is not a common use case, so let me give you a real example!
Nunjucks async filters
var env = nunjucks.configure('views')
env.addFilter('videoTitle', function(videoId, cb) {
// ... fetch the title through youtube APIs
// ... extract the video title
// ... and call the callback with the title
}, true)
{{ data | myCustomFilter }}
We are forced to pass a callback-based function here! 🤷♂️
input data
Transformation function
Ex: {{ youtubeId | videoTitle }}
util.callbackify()
import { callbackify } from 'util'
import Innertube from 'youtubei.js' // from npm
async function videoTitleFilter (videoId) {
const youtube = await new Innertube({ gl: 'US' })
const details = await youtube.getDetails(videoId)
return details.title
}
const videoTitleFilterCb = callbackify(videoTitleFilter)
videoTitleFilterCb('18y6OjdeR6o', (err, videoTitle) => {
if (err) {
console.error(err)
return
}
console.log(videoTitle)
})
Callbackify by hand ✋
import Innertube from 'youtubei.js' // from npm
async function videoTitleFilter (videoId) {
// ...
}
function videoTitleFilterCb (videoId, cb) {
videoTitleFilter(videoId)
.then((videoTitle) => cb(null, videoTitle))
.catch(cb)
}
videoTitleFilterCb('18y6OjdeR6o', (err, videoTitle) => {
// ...
})
receives a callback
calls the async version and gets a promise
pass the result to cb in case of success
propagates the error in case of failure
OK, Cool!
But is this stuff worth it?
🧐
Let me show you a cool performance
trick for Web Servers!
😎
The request batching pattern
one user
/api/hotels/rome
DB
Web server
The request batching pattern
multiple users (no batching)
DB
Web server
/api/hotels/rome
/api/hotels/rome
/api/hotels/rome
The request batching pattern
multiple users (with batching!)
DB
Web server
📘 Requests in-flight
/api/hotels/rome
/api/hotels/rome
/api/hotels/rome
/api/hotels/rome
✅
The web server
import { createServer } from 'http'
const urlRegex = /^\/api\/hotels\/([\w-]+)$/
createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost')
const matches = urlRegex.exec(url.pathname)
if (!matches) {
res.writeHead(404, 'Not found')
return res.end()
}
const [_, city] = matches
const hotels = await getHotelsForCity(city)
res.writeHead(200)
res.end(JSON.stringify({ hotels }))
}).listen(8000)
The data fetching function (with batching)
let pendingRequests = new Map()
function getHotelsForCity (cityId) {
if (pendingRequests.has(cityId)) {
return pendingRequests.get(cityId)
}
const asyncOperation = db.query({
text: 'SELECT * FROM hotels WHERE cityid = $1',
values: [cityId],
})
pendingRequests.set(cityId, asyncOperation)
asyncOperation.finally(() => {
pendingRequests.delete(cityId)
})
return asyncOperation
}
Global map to store the requests in progress