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  👉

Let's connect!

  loige.co (blog)

  @loige (twitter)

  loige (twitch)

  lmammino (github)

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

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.

Closing Notes

  • JavaScript can be a very powerful and convenient language when we have to deal with a lot of I/O (e.g. web servers)
  • The async story has evolved a lot in the last 10-15 years: new patterns and language constructs have emerged
  • Async/Await is probably the best way to write async code today
  • To use Async/Await correctly you need to understand Promise and callbacks
  • Take your time and invest in learning the fundamentals

Cover photo by Kier In Sight on Unsplash

THANKS! 🙌

Async JavaScript and Node.js Design Patterns

By Luciano Mammino

Async JavaScript and Node.js Design Patterns

Let's take a walk into the JavaScript/Node.js ecosystem and let's try to understand what makes this ecosystem so special compared to other languages. Why is the async paradigm so convenient in the world of full stack web development and what are the challenges that come with it? In this talk, we will discuss the JavaScript async history, some common gotchas and some interesting design patterns that are unique to the JavaScript ecosystem.

  • 2,143