A brief history of asynchronous programming in JavaScript
JavaScript is regularly referred to as "async by default", but the way in which async code is commonly handled has changed over JavaScript's lifetime. We've moved from callbacks to promises to async/await over the years, but each of these approaches in related to its predecessors in one way or another.
Asynchronous vs Synchronous
To begin, let's quickly make clear the difference between asynchronous and synchronous code. When code is synchronous, it's executed in "line order", meaning each task defined by your code is executed to completion before moving onto the next task. In JavaScript, that might mean something like this.
console.log("Hello world")
const name = "kyle";
console.log("It's me, " + name)
console.log("Some lovely code :)")
All of this code executes synchronously - each line is executed to completion before the program moves to the next line.
Asynchronous programming works in the opposite way though: the program moves to the next line before the previous line has executed to completion. We'll dig into some example throughout this post, but common cases involving asynchronous code are database connections, HTTP requests, and other instances where your program might have to wait for a response from some other source.
Here's a great StackOverflow answer that does a good ELI5 for the difference:
SYNCHRONOUS
You are in a queue to get a movie ticket. You cannot get one until everybody in front of you gets one, and the same applies to the people queued behind you.
ASYNCHRONOUS
You are in a restaurant with many other people. You order your food. Other people can also order their food, they don't have to wait for your food to be cooked and served to you before they can order. In the kitchen restaurant workers are continuously cooking, serving, and taking orders. People will get their food served as soon as it is cooked.
With (hopefully) a good baseline understanding of these programming concepts, let's dive into how JavaScript has handled asynchronous code throughout its lifetime as a programming language.
Callbacks
Callbacks are an essential concept in JavaScript and other asynchronous languages. Because JavaScript relies heavily on event and asynchronous processing, callbacks are core to the language. JavaScript implements higher order functions, meaning that functions can be stored in named variables and passed as arguments to other functions. A function passed as an argument to another function is typically referred to as a callback. Callbacks are the original and oldest way to handle async calls in JavaScript.
setTimeout
is one of the simplest examples of a function that accepts a callback:
setTimeout(function () {
console.log('It has been a second!')
}, 1000)
Here's an example of how you might implement your own method that accepts a callback:
function validateInput (input, callback) {
var result = { errors: [] }
if (!input.name || input.name.length < 6) {
result.errors.push('Invalid name')
}
if (!input.email) {
result.errors.push('Email must be provided')
}
callback(result)
}
validateInput({ name: 'Kyle', email: 'kyle@example.com' }, function (result) {
if (result.errors.length) {
console.error('Whoops')
} else {
console.log('Hooray')
}
})
It's very easy to fall into "callback hell" when you have to chain several functions together that all accept callbacks. Consider some Node code where we connect to MySQL and use standard callbacks to run some queries that depend on return values from other queries.
var config = require('./config.json')
var mysql = require('mysql')
// Note: this is inefficient and bad on purpose to prove a point :)
function updateUserEmail (oldEmail, newEmail, callback) {
var connection = mysql.createConnection(config)
connection.connect()
connection.query('SELECT id FROM users WHERE email = ?', [oldEmail], function (error, results) {
if (error) {
throw(error)
}
var userId = results[0].id
connection.query('SELECT is_active FROM users WHERE user_id = ?', [userId], function (error, results) {
if (error) {
throw(error)
}
var isActive = results[0].is_active
if (!isActive) {
throw new Error('Error - user is inactive')
}
connection.query('UPDATE users SET email = ? WHERE id = ?', [newEmail, userId], function (error, results) {
if (error) {
throw(error)
}
if (results[0].affectedRows === 0) {
throw new Error('Error - failed to update user')
}
connection.query('SELECT * FROM users WHERE id = ?' [userId], function (error, results) {
if (error) {
throw(error)
}
callback(results[0])
})
})
}
})
connection.end()
}
try {
updateUserEmail('kyle@example.com', 'kyle2@example.com', function(changedUser) {
console.log(changedUser)
})
} catch (error) {
console.error(error)
}
Promises
A Promise
is an object that represents the eventual result of an async operation. Promises can be resolved
or rejected
with values, and they're similar to Tasks
or Futures
in other languages like C# or Java.
We can instantiate a Promise
with a constructor that takes a function like so
new Promise((resolve, reject) => {
if (foo) {
return resolve('foo')
}
reject('not foo')
})
Promises transition through three states: pending
, fulfilled
, and rejected
. We can chain onto Promises to perform meaningful operations with the then
method. The catch
method is used to catch rejections.
somePromise(foo)
.then(data => {
console.log(data)
})
.catch(error => {
console.error(error)
})
Promises can be chained, and errors will "bubble up" to a single catch
handler at the end, which makes them very powerful for reducing nesting and unifying your scope.
somePromise(foo)
.then(data => {
return transformData(data)
})
.then(newData => {
if (newData.bar) {
return logData(newData)
}
return logSomethingElse(newData)
})
.catch(error => {
console.error(error)
})
Promises are a powerful pattern for cleaning up callback-laden code. Here's the example with the MySQL calls from above rewritten with Promises.
const config = require('./config.json')
const mysql = require('mysql2/promise')
function updateUserEmail (oldEmail, newEmail ) {
mysql.createConnection(config)
.then(connection => connection.execute('SELECT id FROM users WHERE email = ?', [oldEmail])
.then([{ id }] => {
this.userId = id
return connection.execute('SELECT is_active FROM users WHERE user_id = ?', [userId])
})
.then([{ is_active }] => {
if (!is_active) {
throw new Error('Error - user is inactive')
}
return connection.execute('UPDATE users SET email = ? WHERE id = ?', [newEmail. this.userId])
})
.then(() => connection.execute('SELECT * FROM users WHERE id = ?', [this.userId])
.then([user] => user)
}
updateUserEmail('kyle@example.com', 'kyle2@example.com')
.then(changedUser => console.log(changedUser))
.catch(error => console.error(error))
Async/Await
Async/Await is a layer of syntactic sugar on top of Promises that eliminates another layer of nesting. By marking a function as async
, we gain access to the await
keyword. await
lets us "unwrap" Promises inline, and treat pending Promises as if they were resolved sychronously. You can only await
functions that return Promises. If you await
a function that does not return a Promise
, it's result will be wrapped in a Promise.resolve
call.
// With a Promise
function getData() {
return fetch('example.com/api/data')
.then(body => body.json())
.then(data => console.log(JSON.stringify(data)))
}
// With async/await
async function getData() {
const body = await fetch('example.com/api/data')
const data = await body.json()
console.log(JSON.stringify(data))
}
Catching errors in async/await blocks is a matter of using JavaScript's standard try/catch
construct. Similar to Promises, this error will "bubble up", so you only need one catch
block for a given block of async code.
async function getData() {
try {
const body = await fetch('example.com/api/data')
const data = await body.json()
console.log(JSON.stringify(data))
} catch (error) {
console.error(error)
}
}
Here's our MySQL example rewritten with async/await. By leveraging libraries and interfaces that return Promises (like MySQL2), you can wind up with some really concise async code.
const config = require('./config.json')
const mysql = require('mysql2/promise')
async function updateUserEmail (oldEmail, newEmail ) {
const connection = await mysql.createConnection(config)
const userId = (await connection.execute('SELECT id FROM users WHERE email = ?', [oldEmail]))[0].id
const isActive = (await connection.execute('SELECT is_active FROM users WHERE user_id = ?', [userId])[0].is_active)
await connection.execute('UPDATE users SET email = ? WHERE id = ?', [newEmail. userId])
return (await connection.execute('SELECT * FROM users WHERE id = ?', [this.userId]))[0]
}
// You actually can't use `await` in the top level scope, so you'd need to put this
// into a separate `async` function or something in the real world
try {
const user = await updateUserEmail('kyle@example.com', 'kyle2@example.com')
console.log(user)
} catch (error) {
console.error(error)
}
And that's that! Now you've seen a few practical examples of asynchronous code and how JavaScript is equipped to handle these use cases. In modern JavaScript, it's helpful to have an understanding of each of these async patterns and how they relate to one another. async/await
is definitely the most modern approach to async code, but you'll still run into plenty of callbacks, and having a good understanding of Promises
is important to effectively utlize async/await
.
Top comments (0)