Async control flow in JavaScript: Promises, Microtasks, async/await

Updated . Posted . Visible to the public.

Slides for Henning's talk on Sep 21st 2017.


Understanding sync vs. async control flow

Talking to synchronous (or "blocking") API

print('script start')
html = get('/foo')
print(html)
print('script end')

Script outputs 'script start', (long delay), '<html>...</html>', 'script end'.

Talking to asynchronous (or "evented") API

print('script start')
get('foo', done: function(html) {
  print(html)
})
print('script end')

Script outputs 'script start', 'script end', (long delay), '<html>...</html>'.

Properties of sync APIs

  • function calls are blocking and return a value when they're done
  • easy control flow
  • computer idles while waiting for IO
  • requires threads for concurrency
    • requires mutexes (locks) for all non-immutable state shared between threads

This is the model of Ruby, Java, Elixir, PHP, Python, ...

Properties of async APIs

  • function calls are non-blocking and return immediately.
  • return values are passed to callback functions.
  • convoluted control flow
  • inherently concurrent
  • computer spends no time waiting for IO (accepting request, reading from database, pushing HTML over network)
  • time can be spent by switching to next context
  • no threads or locking required because nothing is really parallel

This is the model of many JavaScript APIs, EventMachine (Ruby), Netty (Java), LibEvent (C), Twisted (Python)

Javascript has a mix of Sync and async APIs

Math.pow(3) // sync API! Blocks and returns 9

$.ajax('/foo', done: function() { ... }) // async API! Returns at once and runs callbacks later

Typical use cases for async

  • Animations
  • Network calls
  • Delays (show tooltip after 100 ms, then animate)
  • When you wait for I/O most of the time (web servers, crawlers)

Most current and future HTML5 browser APIs have async signatures.

Async frontend example

We want to write a function that:

  • Fades out a <div class="users">...</div>
  • Makes a request to /users to fetch new HTML
  • Replaces the <div> with the fetched HTML
  • Fades in the new <div>.
  • Signals our caller that we're done

Solution with callbacks

function refreshUsers(callbacks) {
  $('.users').animate('fade-out', { 
    done: function() {
      $.ajax('/users', {
        done: function(html) {
          $('.users').replaceWith(html)
          $('.users').animate('fade-in', {
            done: callbacks.done
          })
        }
      })
    }
  })
}

refreshUsers({
  done: function() {
    console.log('Users refreshed!')
  }
);
  • This is hard to read!
  • If any step in the chain has an error, the chain silently stops

Callbacks with error handling

function refreshUsers(callbacks) {
  $('.users').animate('fade-out', { 
    done: function() {
      $.ajax('/users', {
        done: function(html) {
          $('.users').replaceWith(html)
          $('.users').animate('fade-in', {
            done: callbacks.done,
            error: callbacks.error
          })
        },
        error: callbacks.error
      })
    },
    error: callbacks.error
  })
}

refreshUsers({
  done: function() { console.log('Users refreshed!') },
  error: function() { console.log('Users error') }
})

Solution with promises

function refreshUsers() {
  var promise = $('.users').animate('fade-out')
  promise = promise.then(function() { 
    return $.ajax('/users') 
   })
  promise = promise.then(function(html) {
    $('.users').replaceWith(html)
    return $('.users').animate('fade-in')
  })
  return promise
}


refreshUsers().then(
  function() { console.log('Users refreshed!') },
  function() { console.log('Users error') }
)
  • Logic still lives in closures, but in a flat chain
  • Note how refreshUsers() takes no callback
  • Implicit error handling: Error callback is called when any step in the chain fails

Promises have three states

  1. pending
  2. settled and fulfilled
  3. settled and rejected

You will often hear the verb "resolve", which is not a synonym for "settle". It means "start the resolution procedure" which will eventually either fulfill or reject the promise. More on that later.

Basic promise API

promise = new Promise(function(resolve, reject) {
  // do long-running job and eventually
  // call resolve(value) or reject(reason)
})

promise.then(onFulfilled, onRejected)

A promise-based alternative to setTimeout

JavaScript has an async function setTimeout that calls a given function after a given number of milliseconds:

setTimeout(function() {
  console.log('200 ms have passed!')
}, 200)

Let's write a function whenTimeout that returns a promise when the time has passed:

whenTimeout(200).then(
  function() { console.log('200 ms have passed!') }
)

function whenTimeout(millis) {
  return new Promise(function(resolve, reject) {
    setTimeout(function() { resolve() }, millis);
  })
}

Now let's extend whenTimeout so it rejects if the given time is negative:

whenTimeout(200).then(
  function() { console.log('200 ms have passed!') },
  function(reason) { console.error('Something went wrong: %o', reason) }
)

function whenTimeout(millis) {
  return new Promise(function(resolve, reject) {
    if (millis < 0) {
      reject('Please pass a positive number')
    }
    setTimeout(function() { resolve() }, millis);
  })
}

Chaining promises

Each call to then() returns a new promise:

fun1().then(fun2).then(fun3).then(fun4)
      |          |          |          |
   Promise       |          |          |
              Promise       |          |
                        Promise        |
                                    Promise

Promises decouple functions from control flow

Your functions no longer need to manage what happens after:

function fadeOut() {
  return $('.users').animate('fade-out').promise()
}

function requestUsers() {
  return $.ajax('/users') 
}

function updateUsers(html) {
  $('.users').replaceWith(html)
  return $('.users').animate('fade-in').promise()
}

Functions can be freely composed in an async control flow:

fadeOut().then(requestUsers).then(updateUsers)

Utilities

A promise that is already fulfilled

Promise.resolve(4)

This is the same as:

new Promise(function(resolve, reject) { resolve(4) })

A promise that is already rejected

Promise.reject('something went wrong')

This is the same as:

new Promise(function(resolve, reject) { reject('something went wrong') })

Waiting for multiple promises to fulfill

allDone = Promise.all([fetchUsers(), fetchPosts()])
allDone.then(function(users, posts) { ... })

Resolution procedure

Many people use "resolve" as a synonym for "fulfill", but it really isn't.

Resolving with a value

Calling resolve() with a value fulfills the promise with that value:

promise = new Promise(function(resolve, reject) {
  resolve(3) // fulfills promise with the value 3
})

promise.then(function(x) {
  // x is now 3
})

Resolving with another promise

Calling resolve() with another promise adopts the state of that promise:

var otherPromise = Promise.resolve(3)

promise = new Promise(function(resolve, reject) {
  // adopt the state of otherPromise
  resolve(otherPromise)
})

promise.then(function(x) {
  // x is now 3
})

This means resolve() can reject your promise!

var otherPromise = Promise.reject(3)

promise = new Promise(function(resolve, reject) {
  // adopt the state of otherPromise
  resolve(otherPromise)
})

promise.then(function(x) {
  // this is never called
})

promise.catch(function(x) {
  // x is now 3
})

Each then() callback gets the settlement value of the previous callback:

promise = Promise.resolve(1)
promise = promise.then(x) {
  // x is now 1
  return 2
}
promise = promise.then(y) {
  // y is now 2
  return 3
}
promise = promise.then(z) {
  // z is now 3
}

If a then() callback returns a promise, the chain waits until that returned promise is settled:

promise = Promise.resolve(1)
promise = promise.then(x) {
  // x is now 1
  return whenTimeout(1000)
}
promise = promise.then() {
  // 1000 milliseconds have passed
  return 2
}

promise = promise.then(y) {
  // y is now 2
}

Returning a promise is a way to delay further resolution. E.g. when a promise callback discovers it needs to do more async work:

function fetchUserAndOptionalAvatar() {
  return fetchUser().then(user) {
    if (user.avatar) {
      return fetchAvatar()
    } else {
      return user
    }
  }
} 

Promise caveats

Handlers are only called once

You can only settle a promise once. Thus handlers are only called once. So do not use promises for event handlers:

whenClicked().then(function() {
  // bad! this will only run once
})

Use vanilla callbacks or observables for event handlers:

whenClicked(function() {
  // vanilla function can be called often
})

It does not matter when you call then()

It does not matter if you register then(handler) before or after the promise is resolved.

Registering a handler on a settled promise will call the handler.

Promise callbacks are never called synchronously

then() is an async function. This means that, even if your promise is already settled, a new then(handler) will not be called in the current execution frame. It will be called in the next microtask instead:

console.log('script start')
fulfilledPromise = Promise.resolve(3)
fulfilledPromise.then(function() {
  console.log('promise handler')
})
console.log('script stop')

This prints 'script start', 'script stop', 'promise handler'.

More on microtasks later!

Error handling

We already learned that each then() returns a new Promise:

fun1().then(fun2).then(fun3).then(fun4)
      |          |          |          |
   Promise       |          |          |
              Promise       |          |
                        Promise        |
                                    Promise

If fun1() fulfills but fun2() rejects, all subsequent chain links reject:

fun1().then(fun2).then(fun3).then(fun4)
      |          |          |          |
  fulfilled      |          |          |
              rejected      |          |
                        rejected       |
                                   rejected

In the example above, the callbacks fun3 and fun4 will not be called.

Async functions should not throw errors!

Don't do this:

function whenTimeout(millis) {
  return new Promise(function(resolve, reject) {
    if (millis < 0) {
      throw('Please pass a positive number')
    }
    setTimeout(resolve, millis);
  })
}

You are forcing callers to handle both sync and async errors:

try {
  whenTimeout(200).then(
    function() { console.log('200 ms have passed!') },
    function(reason) { console.error('Something went wrong: %o', reason) }
  )
} catch (reason) {
  console.error('Something went wrong: %o', reason)
}

Async function signal failure by returning a rejected promise:

function whenTimeout(millis) {
return new Promise(function(resolve, reject) {
  if (millis < 0) {
    reject('Please pass a positive number')
  }
  setTimeout(resolve, millis);
})
}

This way callers only need to handle async errors:

whenTimeout(200).then(
function() { console.log('200 ms have passed!') },
function(reason) { console.error('Something went wrong: %o', reason) }
)

This will become more important when we talk about async/await later.

Exceptions are converted within then() handlers

function fetchUsers() {
  return Promise.resolve([])
}

promise = fetchUsers().then(function(users) {
  if (users.length) {
    ...
  } else {
    throw "no users found"
  }
})

promise.catch(function(reason) { console.error(reason) })

If fetchUsers() fulfills with an empty Array, promise is now rejected with "no users found".

If you have a large blob of sync code and need to convert it to an async API, a hack is to put it all into the then() handler of a resolved promise. This way thrown errors will automatically be converted to rejected promise:

function whenTimeout(millis) {
  return Promise.resolve().then(function() {
    if (millis < 0) {
      throw('Please pass a positive number') // will reject the whenTimeout() promise
    }

    return new Promise(function(resolve, reject) {
      setTimeout(resolve, millis);
    })
  });
}

When to handle errors

We know we can pass an onRejected handler:

promise.then(onFulfilled, onRejected) // returns new promise

But examples often only pass an onFulfilled handler. So when do we need to pass onRejected?

You can omit it only if your then() value is returned because the promise returned by promise1.then therefore adopts the state of promise2:

promise1 = new Promise(...)
promise2 = new Promise(...)

handleError = function(e) { console.error(e) }

promise1.then(
  function(x) {
    return promise2.then(onFulfilled)
  }
).catch(handleError) // will handle rejections for both promise1 and promise2

This is the equivalent of unhandled exceptions bubbling up in sync control flow.

Here is a bad example where we omit the onRejected handler, but don't return the resulting promise:

promise1 = new Promise(...)
promise2 = new Promise(...)

handleError = function(e) { console.error(e) }

promise1.then(
  function(x) {
    promise2.then(onFulfilled) // nothing will handle rejection of promise2
    return 'value'
  }
).catch(handleError) // will handle rejections for promise1 only

This is the equivalent of an empty catch {} clause that silently eats exceptions.

If you do this, Chrome will print an error to the console, informing you that you didn't handle a rejection:

Unhandled promise rejection Promise { ... }    

Promise API was built to model sync control flow

With promises you can do anything that you can do with sync APIs.

It is useful to be able to translate basic control flows from sync to async style.

Return a value

Sync

function foo() {
  return 'foo'
}

Async

function foo() {
  return Promise.resolve('foo')
}

Throw an exception

Sync

function foo() {
  throw 'foo'
}

Async

function foo() {
  return Promise.reject('foo')
}

Note that we don't throw the rejected promise. We just return it.

Do something, then something else

Sync

foo()
bar()

Async

foo().then(bar)

Call a function with the return value of a previous call

Sync

let x = foo()
bar(x)

Async

foo().then(bar)

Or more verbose:

foo().then(function(x) { bar(x) })

Call a function, then throw an exception if we don't like its return value

Sync

x = foo()

if (x > 0) {
  return x * x
} else {
  throw "Bad value"
}

Async

foo().then(function(x) {
  if (x > 0) {
    return Promise.resolve(x * x)
  } else {
    return Promise.reject("Bad value")
  }
})

Catch and handle an exception

Sync

try {
  x = foo()
  return x * x
} catch (e) {
  return -1
}

Async

foo().then(
  function(x) { return x * x },
  function(e) { return Promise.resolve(-1) }
)

Or you can also write:

foo()
  .then(function(x) { return x * x })
  .catch(function(e) { return Promise.resolve(-1) })

promise.catch(fn) is shorthand for promise.then(null, fn).

async/await converts from async flow to sync flow!

async/await is syntactic sugar that lets you write async code in a sync style.

You know this from jQuery:

$.ajax('/users').then(
  function(html) { console.log(html) }
})

With await we can write the same code like this:

html = await $.ajax('/users')
console.log(html)

Your transpiler will convert this into vanilla JavaScript using promises or generators.
async/await is available in Babel and CoffeeScript 2 (which needs Babel).

Writing functions with async

function throwOnMonday() {
  if (new Date().getDay() == 1) {
    return Promise.reject("oh no, it's monday!")
  } else {
    return Promise.resolve("all good")
  }
}

With async our function will convert its return value into a fulfilled promise:

async function throwOnMonday() {
  if (new Date().getDay() == 1) {
    throw "oh no, it's monday"
  } else {
    return "all good"
  }
}

Control flow with async/await

Sync control flow

function f1() { return 2 }
function f2(x) { return x * x }

try {
  var x = f1()
  var y = f2(x)
  console.log("y is %o", y)
} catch (e) {
  handleError(e)
}

If f1() and f2() have an async API, our control flow must become async:

function f1() { return Promise.resolve(2) }
function f2(x) { return Promise.resolve(x * x) }

promise = f1()

promise = promise.then(
  function(x) { return f2(x) }
)

promise = promise.then(
  function(y) { console.log("y is %o", y) },
  function(e) { handleError(e) }
)

With async/await we can pretend f1() and f2() are sync again:

async function f1() { return 2 }
async function f2(x) { return x * x }

try {
  var x = await f1()
  var y = await f2(x)
  console.log("y is %o", y)
} catch (e) {
  handleError(e)
}

More practical async/await example

async function refreshUsers() {
  await $('.users').animate('fade-out')
  var html = await $.ajax('/users')
  $('.users').replaceWith(html)
  await $('.users').animate('fade-in')
}

await refreshUsers()
console.log('Users refreshed!')

async/await footgun

The error from throwError() below will never be caught, because asyncError() does not really throw an error. It returns a rejected promise!

async function asyncError() {
  throw new Error('will never be caught')
}

async function foo() {
  try {
    return asyncError()
  } catch(e) {
    console.error(e)
  }
}

You need to be super careful when you want to convert from async style. If you want to handle async calls in a sync call, you must use await:

Here is the fixed code:

async function asyncError() {
  throw new Error('will never be caught')
}

async function foo() {
  try {
    return await asyncError() // additional await here
  } catch(e) {
    console.error(e)
  }
}

Async APIs are viral

  • You can call sync functions from an async function.
  • You can not call async functions from sync functions!
  • If your function calls any async function, it must itself be async
  • When you write a JavaScript function API that you need to keep stable, think hard whether it needs to be async one day. Converting a function signature from sync to async means you need to change all call sites!

API stability vs. convenience

Making every function async is the safest bet if you need to keep your API stable. Almost every modern HTML5 browser API is async for that reason.

However, async functions are also much more painful to use than sync functions. This is super convenient:

array1 = []
array1.push('a')
array1.push('b')

array2 = []
array2.push('c')
array2.push('d')

array3 = array1.concat(array2)

console.log(array3)

Compare this to an async version of the same:

whenArray1Done = newArray().then(array) {
  return array.push('a')
}.then(array) {
  return array.push('b')
}

whenArray2Done = newArray().then(array) {
  return array.push('b')
}.then(array) {
  return array.push('c')
}

Promise.all([whenArray1Done, whenArray2Done]).then(function(array1, array2) {
  return array1.concat(array2)
}).then(function(array3) {
  console.log(array3)
})

Great post by Bob Nystrom: What color is your function Show archive.org snapshot

Microtasks

You have probably used setTimeout(0, fn) to push work into the "next frame".

setTimeout(0) pushes work into the next task, which roughly means "after the browser had a chance to render the results of this frame and JavaScript regains control".

JavaScript task
Render
JavaScript task
Render
JavaScript task
Render

Pushing a task is super expensive because the browser might render before your task gets called. It's also very easy to run into timeout clamping Show archive.org snapshot when a callback pushes a callback.

Promise callbacks are run in the next microtask instead. This is a separate queue for lighter tasks:

JavaScript task
JavaScript microtask
Render
JavaScript task
JavaScript microtask
JavaScript microtask
JavaScript microtask
JavaScript microtask
Render
JavaScript task
Render

Mutation Observers, postMessage, MessageChannel all use the microtask queue.

Great visualization by Jake Archibald: Tasks, microtasks, queues and schedules Show archive.org snapshot

Browser support

About jQuery deferreds

jQuery has shipped with the promise-like $.Deferred() Show archive.org snapshot for many years.

  • jQuery deferreds are not Promises/A+ compatible in jQuery 1 and 2. Calling .then() on resolved promises runs callbacks synchronously!
  • Event jQuery 3 uses tasks instead of microtasks to schedule promise callbacks. This makes your code slow and susceptible to timeout clamping Show archive.org snapshot .
  • The deferred pattern is considered an anti-pattern in the JS community
  • async/await will always transpile to global.Promise and you want async / await eventually.

I recommend to just use native Promise and polyfill with the 2 KB or Zousan Show archive.org snapshot . You can even polyfill Promise with jQuery itself using 339 bytes.

Testing async code

We want to test this code:

whenLoaded = new Promise(function(resolve, reject) { $(resolve) })

function foo() { }    

function callFooWhenLoaded() { whenLoaded.then(foo) }

This test fails:

describe('callFooWhenLoaded()', function() {
  it('calls foo()', function() {
    spyOn(window, 'foo')
    callFooWhenLoaded()
    expect(foo).toHaveBeenCalled()
  })
})

The test fails since we're expecting an effect that hasn't happened yet! A test for async code must itself be async.

This new attempt pushes action and expectation into the same microtask:

describe('callFooWhenLoaded()', function() {
  it('calls foo()', function() {
    spyOn(window, 'foo')
    callFooWhenLoaded().then(function() {
      expect(foo).toHaveBeenCalled()        
    })
  })
})

However, this test breaks your test runner! Jasmine thinks your test is over too soon.

Finally, here is the correct test. Notice the done argument:

describe('callFooWhenLoaded()', function() {
  it('calls foo()', function(done) {
    spyOn(window, 'foo')
    callFooWhenLoaded().then(function() {
      expect(foo).toHaveBeenCalled()
      done()        
    })
  })
})

Note that this would also work:

describe('callFooWhenLoaded()', function() {
  it('calls foo()', function(done) {
    spyOn(window, 'foo')
    callFooWhenLoaded()
    Promise.resolve().then(function() {
      expect(foo).toHaveBeenCalled()
      done()        
    })
  })
})

Can you imagine the footgun when testing that a function has not been called?

Resources

Henning Koch
Last edit
Michael Leimstädtner
License
Source code in this card is licensed under the MIT License.
Posted by Henning Koch to makandra dev (2017-09-14 12:59)