The evolution of async JavaScript & its patterns

Luciano Mammino (@loige)

2023-03-09

👋 I'm Luciano (🇮🇹🍕🍝🤌)

👨‍💻 Senior Architect @ fourTheorem

📔 Co-Author of Node.js Design Patterns  👉

Let's connect!

  loige.co (blog)

  @loige (twitter)

  loige (twitch)

  lmammino (github)

Grab the slides

$ ~ whoami

Always re-imagining

We are a pioneering technology consultancy focused on AWS and serverless

✉️ Reach out to us at  hello@fourTheorem.com

😇 We are always looking for talent: fth.link/careers

We can help with:

Cloud Migrations

Training & Cloud enablement

Building high-performance serverless applications

Cutting cloud costs

We host a weekly podcast about AWS

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!

(without spawning extra threads!)

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

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!

Anatomy of callback-based non-blocking code

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

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
}

Promise help us to be more in control!

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 picture by Jason Leung on Unsplash

Thanks GIF by Stefaníe Shank

THANKS!  🙌 ❤️

Grab these slides!

😍 Grab the book

The evolution of async JavaScript and its patterns - Node.js One Cape Town

By Luciano Mammino

The evolution of async JavaScript and its patterns - Node.js One Cape Town

If you started using JavaScript in more recent years there is a chance you are relying heavily on async/await and that you try to avoid as much as possible having to deal with promises and callbacks. The truth is that async/await is built on top of promises and promises, in turn, are built on top of callbacks… In this talk we will try to put all these concepts in order and try to build a mental framework that can help you to make the best possible use of async/await today. To master the current tools, it’s often useful to do a step back and learn a bit of the history. This talk will also explore some common async patterns and finally explore an interesting optimisation use case that is made possible only after mastering the async journey.

  • 1,334