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
- pending
- settled and fulfilled
- 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
- 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 Show archive.org snapshot - If you need to support IE11, a very fast and small (2 KB gzipped) Polyfill is Zousan Show archive.org snapshot
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 toglobal.Promise
and you wantasync
/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?