Posted almost 3 years ago. Visible to the public. Repeats.

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

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


Understanding sync vs. async control flow

Talking to synchronous (or "blocking") API

Copy
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

Copy
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

Copy
Math.pow(3) // sync API! Blocks and returns 9 $.ajax('/foo', done: function() { ... }) // async API! Returns at once and runs callbacks later

Typical uses 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

Copy
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

Copy
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

Copy
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

Copy
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:

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

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

Copy
whenTimeout(200).then( function() { console.log('200 ms have past!') } ) 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:

Copy
whenTimeout(200).then( function() { console.log('200 ms have past!') }, 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:

Copy
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:

Copy
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:

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

Utilities

A promise that is already settled

Copy
Promise.resolve(4)

This is the same as:

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

A promise that is already rejected

Copy
Promise.reject('something went wrong')

This is the same as:

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

Waiting for multiple promises to settle

Copy
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:

Copy
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:

Copy
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!

Copy
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:

Copy
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:

Copy
promise = Promise.resolve(1) promise = promise.then(x) { // x is now 1 return whenTimeout(1000) } promise = promise.then() { // 1000 milliseconds have past 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 discoveres it needs to do more async work:

Copy
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:

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

Use vanilla callbacks or observables for event handlers:

Copy
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:

Copy
console.log('script start') resolvedPromise = Promise.resolve(3) resolvedPromise.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:

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

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

Copy
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:

Copy
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:

Copy
try { whenTimeout(200).then( function() { console.log('200 ms have past!') } 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:

Copy
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:

Copy
whenTimeout(200).then( function() { console.log('200 ms have past!') } 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

Copy
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:

Copy
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:

Copy
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:

Copy
promise1 = new Promise(...) promise2 = new Promise(...) handleError = function(e) { console.error(e) } promise1.then( function() { // ok to omit onRejected since promise1 will adopts our state: return promise2.then(onFulfilled) }, 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:

Copy
promise1 = new Promise(...) promise2 = new Promise(...) handleError = function(e) { console.error(e) } promise1.then( function() { promise2.then(onFulfilled) // nothing will handle rejection of promise2 return 'value' }, 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:

Copy
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

Copy
function foo() { return 'foo' }

Async

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

Throw an exception

Sync

Copy
function foo() { throw 'foo' }

Async

Copy
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

Copy
foo() bar()

Async

Copy
foo().then(bar)

Call a function with the return value of a previous call

Sync

Copy
x = foo() bar(x)

Async

Copy
foo().then(bar)

Or more verbose:

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

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

Sync

Copy
x = foo() if (x > 0) { return x * 2 } else { throw "Bad value" }

Async

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

Catch and handle an exception

Sync

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

Async

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

Or you can also write:

Copy
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:

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

With await we can write the same code like this:

Copy
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

Copy
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:

Copy
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

Copy
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:

Copy
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:

Copy
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

Copy
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!

Copy
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:

Copy
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:

Copy
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:

Copy
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() { console.log(array3) })

Great post by Bob Nystrom: What color is your function

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"

Copy
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 when a callback pushes a callback pushes a callback.

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

Copy
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

Browser support

  • There are many different promise implementations (jQuery, Angular $q, Bluebird), but you should always prefer native Promise API
  • Native Promise is supported in all current browsers except IE11
  • If you need to support IE11, a very fast and small (2 KB gzipped) Polyfill is Zousan

About jQuery deferreds

jQuery has shipped with the promise-like $.Deferred() 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.
  • 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. You can even polyfill Promise with jQuery itself using 339 bytes.

Testing async code

We want to test this code:

Copy
whenLoaded = new Promise(function(resolve, reject) { $(resolve) }) function foo() { } function callFooWhenLoaded() { whenLoaded.then(foo) }

This test fails:

Copy
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:

Copy
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:

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

Note that this would also work:

Copy
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

Once an application no longer requires constant development, it needs periodic maintenance for stable and secure operation. makandra offers monthly maintenance contracts that let you focus on your business while we make sure the lights stay on.

Owner of this card:

Avatar
Henning Koch
Last edit:
3 months ago
by Henning Koch
About this deck:
We are makandra and do test-driven, agile Ruby on Rails software development.
License for source code
Posted by Henning Koch to makandra dev
This website uses short-lived cookies to improve usability.
Accept or learn more