Luciano Mammino (@loige)
SAILSCONF
2022-06-22
Get these slides!
👋 I'm Luciano (🇮🇹🍕🍝🤌)
👨💻 Senior Architect @ fourTheorem (Dublin 🇮🇪)
📔 Co-Author of Node.js Design Patterns 👉
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
callbacks
promises
Async/Await
async generators
streams
event emitters
util.promisify()
Promise.all()
Promise.allSettled()
😱
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
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
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
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
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
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
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?
doSomethingAsync(arg1, arg2, cb)
This is a callback
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!
doSomethingAsync(arg1, arg2, (err, data) => { if (err) { // ... handle error return } // ... do something with data })
Always handle errors first!
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
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 🔥)
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!
We need to trust that the async function will call our callbacks when the async work is completed!
const promiseObj = doSomethingAsync(arg1, arg2)
An object that represents the status of the async operation
const promiseObj = doSomethingAsync(arg1, arg2)
A promise object is a tiny state machine with 2 possible states
const promiseObj = doSomethingAsync(arg1, arg2)
promiseObj.then((data) => {
// ... do something with data
})
const promiseObj = doSomethingAsync(arg1, arg2) promiseObj.then((data) => { // ... do something with data }) promiseObj.catch((err) => { // ... handle errors }
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
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)
new Promise ((resolve, reject) => { // ... })
this is the promise executor function. It allows you to specify how the promise behave.
This function is executed immediately!
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)
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
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()
queryDB(dbClient, 'SELECT * FROM bookings')
.then((data) => {
// ... do something with data
})
.catch((err) => {
console.error('Failed to run query', err)
})
.finally(() => {
dbClient.disconnect()
})
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
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
try { const data = await doSomethingAsync(arg1, arg2) // ... process the data } catch (err) { // ... handle error }
If we await a promise that eventually rejects we can capture the error with a regular try/catch block
async function doSomethingAsync(arg1, arg2) { // ... }
special keyword that marks a function as async
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 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 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 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
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')
}
}
const users = ['Peach', 'Toad', 'Mario', 'Luigi']
for (const userId of users) {
await cancelLatestBooking(userId)
}
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!
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
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 }
]
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'
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>
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>
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 }}
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)
})
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
/api/hotels/rome
DB
Web server
DB
Web server
/api/hotels/rome
/api/hotels/rome
/api/hotels/rome
DB
Web server
📘 Requests in-flight
/api/hotels/rome
/api/hotels/rome
/api/hotels/rome
/api/hotels/rome
✅
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)
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
Our data fetching function (with batching)
Note: no async because we will explicitly return a promise
if the current request is already pending we will return the existing promise for that request
otherwise we fetch the data from the db
Note: no await here because we don't want to suspend the execution
We save the promise representing the current pending request
Once the promise resolves we remove it from the list of pending requests
returns the promise representing the pending request
Without request batching
With request batching (+90% avg req/sec)*
* This is an artificial benchmark and results might vary significantly in real-life scenarios. Always run your own benchmarks before deciding whether this optimization can have a positive effect for you.
Cover photo by Kier In Sight on Unsplash
THANKS! 🙌