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
- Node-style callbacks
- Bring on the Promise
- async/await
- Gotchas
- Patterns
- Workshop Examples
- Further Reading
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
(inmyAsyncFn
callback) !==err
(inmyOtherAsyncFn
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
- About non-blocking I/O in Node.js docs: nodejs.org/en/docs/guides/blocking-vs-non-blocking/
- Async JavaScript: From Callbacks, to Promises, to Async/Await by Tyler McGinnis
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)