DEV Community

Hugo Di Francesco
Hugo Di Francesco

Posted on • Originally published at codewithhugo.com on

Async JavaScript: history, patterns and gotchas

A look at the history, patterns and gotchas of asynchronous operations in JavaScript.

We’ll go through the pros and cons of callbacks, Promises and async/await. Present some pitfalls to bear in mind as well as introducing how you would deal with certain situations.

Live-coding/workshop section touching on both Node and client-side JS situations at github.com/HugoDF/async-js-presentation/tree/master/workshop.

This given as a talk at Codebar London January Monthly 2019, see the slides:

View the original slides on SpeakerDeck or from the GitHub repo.

Table of Contents 🐳 :

Asynchronicity in JavaScript

Primitives:- Callbacks- Promises- (Observables)- async/await

What’s asynchronous in a web application?

Most things:1. any network calls (HTTP, database)2. timers (setTimeout, setInterval)3. filesystem access… Anything else that can be offloaded

In JavaScript, these operations are non-blocking.

HTTP Request in Python:

data = request(myUrl)
print(data)

HTTP Request in JavaScript:

request(myUrl, (err, data) => {
  console.log(data);
});

Why non-blocking I/O?

JavaScript was conceived as a UI programming language. In UI, you don’t want to freeze UI interactions while you wait for a server to respond for example.

Non-blocking I/O means waiting doesn’t cost you compute cycles.

How non-blocking I/O is implemented (in JavaScript):- pass a “callback” function- it’s called with the outcome of the async operation

Node-style callbacks

myAsyncFn((err, data) => {
  if (err) dealWithIt(err);
  doSomethingWith(data);
})

A callback is:

  • “just” a function
  • in examples, usually anonymous functions (pass function () {} directly)
  • according to some style guides, should be an arrow function (() => {})
  • called when the async operation

A Node-style callback is:

  • called with any error(s) as the first argument/parameter, if there’s no error, null is passed
  • called with any number of “output” data as the other arguments

ie. (err, data) => { /* more logic */ }

Node-style callbacks: problems

1. Callback hell

myAsyncFn((err, data) => {
  if (err) handle(err)
  myOtherAsyncFn(data, (err, secondData) => {
    fun(data, secondData, (err) => {
      if (err) handle(err)
    })
    fn(data, secondData, (err) => {
      if (err) handle(err)
    })
  })
})

For each asynchronous operation:- extra level of indent- lots of names for async output: data, secondData

2. Shadowing variables

myAsyncFn((err, data) => {
  if (err) handle(err)
  myOtherAsyncFn(data, (err, secondData) => {
    fun(data, secondData, (err) => {
      if (err) handle(err)
    })
    fn(data, secondData, (err) => {
      if (err) handle(err)
    })
  })
})

  • err (in myAsyncFn callback) !== err (in myOtherAsyncFn callback) despite having the same nam

3. Duplicated error handling

  • 1 call to handle(err) per operation
myAsyncFn((err, data) => {
  if (err) handle(err)
  myOtherAsyncFn(data, (err, secondData) => {
    fun(data, secondData, (err) => {
      if (err) handle(err)
    })
    fn(data, secondData, (err) => {
      if (err) handle(err)
    })
  })
})

4. Swallowed errors

Ideal failure:- fail early- fail fast- fail loud

Spot the unhandled error:

myAsyncFn((err, data) => {
  if (err) handle(err)
  myOtherAsyncFn(data, (err, secondData) => {
    fun(data, secondData, (err) => {
      if (err) handle(err)
    })
    fn(data, secondData, (err) => {
      if (err) handle(err)
    })
  })
})

The silent error is where the comment is.

myAsyncFn((err, data) => {
  if (err) handle(err)
  myOtherAsyncFn(data, (err, secondData) => {
    // Missing error handling!
    fun(data, secondData, (err) => {
      if (err) handle(err)
    })
    fn(data, secondData, (err) => {
      if (err) handle(err)
    })
  })
})

That err doesn’t get handled. Linters would have caught that (I hope), whining that err was defined but not used. That’s living on the edge a little bit.

Callback problems

The issues with callbacks boil down to the following.

Callback hell with its many indents and variable names.

Shadowed variables with all the issues that brings.

Duplicated error-handling which makes it easy to swallow errors.

Bring on the Promise

myAsyncFn()
  .then((data) => Promise.all([
    data,
    myOtherAsyncFn(data),
  ]))
  .then(([data, secondData]) => Promise.all([
    fun(data, secondData),
    fn(data, secondData),
  ]))
  .then(/* do anything else */)
  .catch((err) => handle(err));

Pros

Promises are chainable , you can return a Promise from .then, tack another .then and keep it going, no crazy indent stuff.

You can define a single error handler using .catch added to the end of your promise chain.

One small function per async step (inside .then) makes it easier to break down long asynchronous flows.

Cons

You define a lot of tightly scoped functions, passing data from one call to the next is very verbose eg.:

.then((data) => Promise.all([
  data,
  myOtherAsyncFn(data),
])

Promise gotchas

Nesting them is tempting

myAsyncFn()
  .then((data) =>
    myOtherAsyncFn(data)
      .then(
        ([data, secondData]) =>
          Promise.all([
            fun(data, secondData),
            fn(data, secondData),
          ])
      )
  )
  .catch((err) => handle(err))

Solution: Avoid the Pyramid of Doom ☠️

myAsyncFn()
  .then((data) => Promise.all([
    data,
    myOtherAsyncFn(data),
  ]))
  .then(([data, secondData]) => Promise.all([
    fun(data, secondData),
    fn(data, secondData),
  ]))
  .then(/* do anything else */)
  .catch((err) => handle(err))

Promises “flatten”, you can return a Promise from a then and keep adding .then that expects the resolved value.

onRejected callback

.then takes two parameters, onResolved and onRejected, so the following works:

myAsyncFn()
  .then(
    (data) => myOtherAsyncFn(data),
    (err) => handle(err)
  );

But we’re back to doing per-operation error-handling like in callbacks (potentially swallowing errors etc.)

Solution: avoid it, in favour of .catch

myAsyncFn()
  .then(
    (data) => myOtherAsyncFn(data)
  )
  .catch((err) => handle(err));

Unless you specifically need it, eg. when you use redux-thunk and making HTTP calls, you also .catch rendering errors from React.

In that case, it’s preferrable to use onRejected.

async/await

(async () => {
  try {
    const data = await myAsyncFn();
    const secondData = await myOtherAsyncFn(data);
    const final = await Promise.all([
      fun(data, secondData),
      fn(data, secondData),
    ]);
    /* do anything else */
  } catch (err) {
    handle(err);
  }
})();

Given a Promise (or any object that has a .then function), await takes the value passed to the callback in .then.

await can only be used inside a function that is async.Top-level (outside of async function) await is coming, currently you’ll get a syntax error though.

(async () => {
  console.log('Immediately invoked function expressions (IIFEs) are cool again')
  const res = await fetch('https://jsonplaceholder.typicode.com/todos/2')
  const data = await res.json()
  console.log(data)
})()

// SyntaxError: await is only valid in async function
const res = await fetch(
  'https://jsonplaceholder.typicode.com/todos/2'
)

async functions are “just” Promises. Which means you can call an async function and tack a .then onto it.

const arrow = async () => { return 1 }
const implicitReturnArrow = async () => 1
const anonymous = async function () { return 1 }
async function expression () { return 1 }

console.log(arrow()); // Promise { 1 }
console.log(implicitReturnArrow()); // Promise { 1 }
console.log(anonymous()); // Promise { 1 }
console.log(expression()); // Promise { 1 }

Example: loop through sequential calls

With async/await:

async function fetchSequentially(urls) {
  for (const url of urls) {
    const res = await fetch(url);
    const text = await res.text();
    console.log(text.slice(0, 100));
  }
}

With promises:

function fetchSequentially(urls) {
  const [url, ...rest] = urls
  fetch(url)
    .then(res => res.text())
    .then(text => console.log(text.slice(0, 100)))
    .then(fetchSequentially(rest));
}

Example: share data between calls

const myVariable = await fetchThing() -> easy

async function run() {
  const data = await myAsyncFn();
  const secondData = await myOtherAsyncFn(data);
  const final = await Promise.all([
    fun(data, secondData),
    fn(data, secondData),
  ]);

  return final
}

We don’t have the whole Promise-flow of:

.then(() => Promise.all([dataToPass, promiseThing]))
.then(([data, promiseOutput]) => { })

Example: error handling

In the following example, the try/catch gets any error and logs it.

The caller of the function has no idea anything failed.

async function withErrorHandling(url) {
  try {
    const res = await fetch(url);
    const data = await res.json();
    return data
  } catch(e) {
    console.log(e.stack)
  }
}

withErrorHandling(
  'https://jsonplaceholer.typicode.com/todos/2'
  // The domain should be jsonplaceholder.typicode.com
).then(() => { /* but we'll end up here */ })

Cons of async/await

Browser support is only good in latest/modern browsers.

Polyfills (async-to-gen, regenerator runtime) are big, so sticking to Promises if you’re only using async/await for syntactic sugar is a good idea.

Node 8+ supports it natively though, no plugins, no transpilation, no polyfills, so async/await away there.

Keen functional programming people would say it leads to a more “imperative” style of programming, I don’t like indents so I don’t listen to that argument.

Gotchas

Creating an error

throw-ing inside an async function and return Promise.reject work the same

.reject and throw Error objects please, you never know which library might do an instanceof Error check.

async function asyncThrow() {
  throw new Error('asyncThrow');
}
function rejects() {
  return Promise.reject(new Error('rejects'))
}
async function swallowError(fn) {
  try { await asyncThrow() }
  catch (e) { console.log(e.message, e. __proto__ ) }
  try { await rejects() }
  catch (e) { console.log(e.message, e. __proto__ ) }
}
swallowError() // asyncThrow Error {} rejects Error {}

What happens when you forget await?

Values are undefined, Promise is an object that has few properties.

You’ll often see: TypeError: x.fn is not a function.

async function forgotToWait() {
  try {
    const res = fetch('https://jsonplaceholer.typicode.com/todos/2')
    const text = res.text()
  } catch (e) {
    console.log(e);
  }
}

forgotToWait()
// TypeError: res.text is not a function

The console.log output of Promise/async function (which is just a Promise) is: Promise { <pending> }.

When you start debugging your application and a variable that was supposed to contain a value logs like that, you probably forgot an await somewhere.

async function forgotToWait() {
  const res = fetch('https://jsonplaceholer.typicode.com/todos/2')
  console.log(res)
}

forgotToWait()
// Promise { <pending> }

Promises evaluate eagerly ✨

Promises don’t wait for anything to execute, when you create it, it runs:

new Promise((resolve, reject) => {
  console.log('eeeeager');
  resolve();
})

The above code will immediately print ‘eeeeager’, tip: don’t create Promises you don’t want to run.

Testing gotchas 📙

Jest supports Promises as test output (therefore also async functions):

const runCodeUnderTest = async () => {
  throw new Error();
};

test('it should pass', async () => {
  doSomeSetup();

  await runCodeUnderTest();
  // the following never gets run
  doSomeCleanup();
})

If you test fails, the doSomeCleanup function doesn’t get called so you might get cascading failures.

Do your cleanup in “before/after” hooks, async test bodies crash and don’t clean up.

describe('feature', () => {
  beforeEach(() => doSomeSetup())
  afterEach(() => doSomeCleanup())
  test('it should pass', async () => {
    await runCodeUnderTest();
  })
})

Patterns

A lot of these are to avoid the pitfalls we’ve looked in the “gotchas” section.

Running promises in parallel 🏃

Using Promise.all, which expects an array of Promises, waits until they all resolve (complete) and calls .then handler with the array of resolved values.

function fetchParallel(urls) {
  return Promise.all(
    urls.map(
      (url) =>
      fetch(url).then(res => res.json())
    )
  );
}

Using Promise.all + map over an async function, an async function is… “just a Promise”.

Good for logging or when you’ve got non-trivial/business logic

function fetchParallel(urls) {
  return Promise.all(
    urls.map(async (url) => {
      const res = await fetch(url);
      const data = await res.json();
      return data;
    })
  );
}

Delay execution of a promise

Promises are eager, they just wanna run! To delay them, wrap them in a function that returns the Promise.

function getX(url) {
  return fetch(url)
}

// or

const delay = url => fetch(url)

No Promise, no eager execution. Fancy people would call the above “thunk”, which is a pattern to delay execution/calculation.

Separate synchronous and asynchronous operations

A flow in a lot of web applications that rely on asynchronous operations for read and write is the following.

Fetch data, doing an asynchronous operation. Run synchronous operations using the data in-memory. Write the data back with an asynchronous call.

const fs = require('fs').promises

const fetchFile = () =>
  fs.readFile('path', 'utf-8');
const replaceAllThings = (text) =>
  text.replace(/a/g, 'b');
const writeFile = (text) =>
  fs.writeFile('path', text, 'utf-8');

(async () => {
  const text = await fetchFile();
  const newText = replaceAllThings(text);
  await writeFile(newText);
})();

A lot of built-in functions don’t wait for a Promise to resolve. If you mix string manipulation/replacement and Promises you’ll end up with [object Promise] everywhere your code injected the Promise object instead of the resolved value.

Running promises sequentially

Using recursion + rest/spread and way too much bookkeeping…

function fetchSequentially(urls, data = []) {
  if (urls.length === 0) return data
  const [url, ...rest] = urls
  return fetch(url)
    .then(res => res.text())
    .then(text =>
      fetchSequentially(
        rest,
        [...data, text]
      ));
}

Using await + a loop, less bookkeeping, easier to read.

async function fetchSequentially(urls) {
  const data = []
  for (const url of urls) {
    const res = await fetch(url);
    const text = await res.text();
    data.push(text)
  }
  return data
}

Remember to only make sequeuntial calls if the nth call rely on a previous call’s output. Otherwise you might be able to run the whole thing in parallel.

Passing data in sequential async calls

Return array + destructuring in next call, very verbose in Promise chains:

async function findLinks() { /* some implementation */ }

function crawl(url, parentText) {
  console.log('crawling links in: ', parentText);
  return fetch(url)
    .then(res => res.text())
    .then(text => Promise.all([
      findLinks(text),
      text
    ]))
    .then(([links, text]) => Promise.all(
      links.map(link => crawl(link, text))
    ));
}

Using await + data in the closure:

async function findLinks() { /* someimplementation */ }

async function crawl(url, parentText) {
  console.log('crawling links in: ', parentText);
  const res = await fetch(url);
  const text = await res.text();
  const links = await findLinks(text);
  return crawl(links, text);
}

Error handling

Using try/catch, or .catch, try/catch means you’ll also be catch-ing synchronous errors.

function withCatch() {
  return fetch('borked_url')
    .then(res => res.text())
    .catch(err => console.log(err))
}

async function withBlock() {
  try {
    const res = await fetch('borked_url');
    const text = await res.text();
  } catch (err) {
    console.log(err)
  }
}

Workshop Examples

Example code at github.com/HugoDF/async-js-presentation/tree/master/workshop

“callbackify”-ing a Promise-based API

We’re going to take fetch (see MDN article on fetch),a browser API that exposes a Promise-based API to make HTTP calls.

We’re going to write a get(url, callback) function, which takes a URL, fetches JSON from it and calls the callback with it (or with the error).

We’ll use it like this:

get('https://jsonplaceholder.typicode.com/todos', (err, data) => {
  console.log(data)
})

To being with let’s define a get function with the right parameters, call fetch for the URL and get data:

// only needed in Node
const fetch = require('node-fetch')

function get(url, callback) {
  fetch(url)
    .then((res) => res.json())
    .then((data) => { /* we have the data now */})
}

Once we have the data, we can call callback with null, data:

// only needed in Node
const fetch = require('node-fetch')

function get(url, callback) {
  fetch(url)
    .then((res) => res.json())
    .then((data) => callback(null, data))
}

And add the error handling step, .catch((err) => callback(err)):

// only needed in Node
const fetch = require('node-fetch')

function get(url, callback) {
  fetch(url)
    .then((res) => res.json())
    .then((data) => callback(null, data))
    .catch((err) => callback(err))
}

That’s it, we’ve written a wrapper that uses a callback API to make HTTP requests with a Promise-based client.

Getting data in parallel using callbacks: the pain

Next we’ll write a function that gets todos by id from the jsonplaceholder API using the get function we’ve defined in the previous section.

Its usage will look something like this (to get ids 1, 2, 3, 10, 22):

getTodosCallback([1, 2, 3, 10, 22], (err, data) => {
  if (err) return console.log(err)
  console.log(data)
})

Let’s define the function, we take the array of ids, and call get with its URL (baseUrl + id).

In the callback to the get, we’ll check for errors.

Also, if the data for all the ids has been fetched, we’ll call the callback with all the data.

That’s a lot of bookkeeping and it doesn’t even necessarily return the data in the right order.

const baseUrl = 'https://jsonplaceholder.typicode.com/todos'

function getTodosCallback(ids, callback) {
  const output = []
  const expectedLength = ids.length

  ids.forEach(id => {
    get(`${baseUrl}/${id}`, (err, data) => {
      if (err) callback(err)

      output.push(data)

      if (output.length === expectedLength) {
        callback(null, output)
      }
    })
  })
}

Here’s the same functionality implemented with straight fetch:

function getTodosPromise(ids) {
  return Promise.all(
    ids.map(async (id) => {
      const res = await fetch(`${baseUrl}/${id}`);
      const data = await res.json();
      return data;
    })
  )
}

Shorter, denser, and returns stuff in order.

“promisify”-ing a callback-based API

Historically Node’s APIs and fs in particular have used a callback API.

Let’s read a file using a Promise instead of readFile(filePath, options, (err, data) => {}).

We want to be able to use it like so:

readFile('./01-callbackify-fetch.js', 'utf8')
  .then(console.log)

The Promise constructor takes a function which has 2 arguments, resolve and reject. They’re both functions and we’ll want to resolve() with a sucessful value and reject() on error.

So we end up with the following:

const fs = require('fs')

function readFile(path, encoding) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, encoding, (err, text) => {
      if (err) return reject(err)
      resolve(text)
    })
  })
}

That’s all there is to it.

Why we don’t mix async and sync operations

Let’s define an abritrary problem: I have some JSON files with information about browsers in a folder.

Given a piece of text that contains the browser name I would like to inject the statistics from the files in the folder.

Let’s do a naive implementation, we have a loadBrowserData async function that reads the file and JSON.parse-s it.

We have a badIdea async function which loops through browsers and calls text.replace() with the browser name as the first parameter and an async function that fetches data and formats it as the second.

String.replace does support a callback as the second parameter but it doesn’t await it, it just expects a synchronous function, which means the following code:

const fs = require('fs').promises
const path = require('path')

const browsers = ['chrome', 'edge', 'firefox', 'safari']

async function loadBrowserData(name) {
  const data = await fs.readFile(path.resolve(__dirname, './04-data', `${name}.json`), 'utf8');
  return JSON.parse(data)
}

async function badIdea(text) {
  let newText = text
  browsers.forEach((browser) => {
    newText = newText.replace(browser, async (match) => {
      const {
        builtBy,
        latestVersion,
        lastYearUsage
      } = await loadBrowserData(browser);
      return `${browser} (${builtBy}, latest version: ${latestVersion}, usage: ${lastYearUsage})`
    })
  })
  return newText
}

const myText = `
We love chrome and firefox.

Despite their low usage, we also <3 safari and edge.
`;

(async () => {
  console.log(await badIdea(myText));
})()

Logs out:

We love [object Promise] and [object Promise].

Despite their low usage, we also <3 [object Promise] and [object Promise].

If instead we load up all the browser data beforehand and use it synchronously, it works:

const fs = require('fs').promises
const path = require('path')

const browsers = ['chrome', 'edge', 'firefox', 'safari']

async function loadBrowserData(name) {
  const data = await fs.readFile(path.resolve(__dirname, './04-data', `${name}.json`), 'utf8');
  return JSON.parse(data)
}

async function betterIdea(text) {
  const browserNameDataPairs = await Promise.all(
    browsers.map(
      async (browser) => [browser, await loadBrowserData(browser)]
    )
  );
  const browserToData = browserNameDataPairs.reduce((acc, [name, data]) => {
    acc[name] = data
    return acc
  }, {})

  let newText = text

  browsers.forEach((browser) => {
    newText = newText.replace(browser, () => {
      const {
        builtBy,
        latestVersion,
        lastYearUsage
      } = browserToData[browser];
      return `${browser} (${builtBy}, latest version: ${latestVersion}, usage: ${lastYearUsage})`
    })
  })

  return newText
}

const myText = `
We love chrome and firefox.

Despite their low usage, we also <3 safari and edge.
`;

(async () => {
  console.log(await betterIdea(myText));
})()

It logs out the expected:

We love chrome (Google, latest version: 71, usage: 64.15%) and firefox (Mozilla, latest version: 64, usage: 9.89%).

Despite their low usage, we also <3 safari (Apple, latest version: 12, usage: 3.80%) and edge (Microsoft, latest version: 18, usage: 4.30%).

Further Reading

Are good reads in and around this subject. The secret to understanding asynchronous JavaScript behaviour is to experiment: turn callbacks into Promises and vice-versa.

View the original slides on SpeakerDeck or from the GitHub repo.

Let me know @hugo__df if you need a hand 🙂.

Top comments (0)