Asynchronous JavaScript: From Callbacks to Promises and Async/Await

Denis J.
7 min readApr 8

--

Overview

In this article I want to tackle a set of questions that I see often raised by newcomers to the JavaScript language. How does async/await work, what does it have to do with Promises and why do we even need it. To do that effectively I will need to have a practical definition of what asynchronous or non-blocking code really is. Also I will briefly visit the past to demonstrate the pain points that prevailed in programs that made multiple interdependent asynchronous calls in order to compose a final result using callbacks. There will be a easter egg towards the end to really dig a bit deeper. Let’s get started.

What does asynchronous/non-blocking mean?

Asynchronous in the context of programming, refers to a way of executing tasks or operations concurrently, without waiting for one task to complete before starting another. This approach allows multiple tasks to run simultaneously, making efficient use of system resources and improving the overall responsiveness of an application. Asynchronous programming is particularly useful when dealing with operations that can take a variable or unpredictable amount of time, such as network requests, file I/O, or any operation that involves interaction with external resources. It helps to prevent blocking the main execution flow of the program, allowing it to continue processing other tasks while waiting for the completion of time-consuming operations.

How does this translate to the real world?

Imagine a coffee shop where customers arrive to order various types of coffee and snacks. In this coffee shop, there is a single barista who is responsible for taking orders, brewing coffee, and preparing snacks.

In an asynchronous scenario, the barista takes an order from a customer and starts brewing coffee or preparing the snack. Instead of waiting for the coffee to finish brewing or the snack to be ready before moving on to the next customer, the barista can take multiple orders from other customers while the coffee is brewing or the snack is being prepared. This way, the barista efficiently handles multiple orders at once.

While the barista is taking orders, the coffee machine and the oven are working concurrently in the background, brewing coffee and baking snacks respectively. As soon as a coffee or snack is ready, the barista serves it to the corresponding customer and continues taking new orders or serving completed ones.

This scenario demonstrates the concept of asynchronous programming. The barista (the main execution flow) can continue taking orders (processing tasks) without being blocked by the time-consuming process of brewing coffee or preparing snacks (long-running operations). The coffee machine and oven (external resources) work concurrently, allowing the coffee shop to operate efficiently and serve customers in a timely manner.

In a synchronous scenario, the barista takes an order from a customer and starts brewing coffee or preparing the snack, but then waits for the coffee to finish brewing or the snack to be ready before moving on to the next customer. While the barista is waiting, no other customer can place an order, and the queue of waiting customers grows longer.

In this scenario, the barista (the main execution flow) becomes blocked by the time-consuming process of brewing coffee or preparing snacks (long-running operations). Consequently, the coffee shop operates inefficiently, with customers experiencing longer wait times for their orders.

The synchronous coffee shop analogy demonstrates the limitations of synchronous programming, where tasks are executed sequentially and the main execution flow is blocked while waiting for the completion of time-consuming operations. This can lead to inefficient use of resources and a slower, less responsive application or system.

How does this relate to JavaScript?

In the context of JavaScript and the event loop, the coffee shop analogy helps to illustrate the difference between synchronous and asynchronous programming and how they impact the overall performance and responsiveness of a JavaScript application.

JavaScript is single-threaded, which means that it can only execute one operation at a time. The event loop is a core part of JavaScript’s concurrency model, responsible for managing the execution of tasks, such as handling user interactions, network requests, timers, and other events.

In a synchronous JavaScript application (akin to the synchronous coffee shop), the main execution flow (the barista) processes tasks one at a time, waiting for each task to complete before moving on to the next. This approach can lead to performance issues and unresponsive applications, particularly when dealing with time-consuming operations, as the main execution flow is blocked while waiting for these operations to finish.

On the other hand, asynchronous programming in JavaScript (similar to the asynchronous coffee shop) allows the main execution flow (the barista) to continue processing other tasks while waiting for time-consuming operations to complete. The event loop plays a crucial role in managing asynchronous tasks by continuously monitoring the call stack and the task queue. When the call stack is empty, the event loop dequeues tasks from the task queue and pushes them onto the call stack for execution. This process enables JavaScript to handle multiple tasks concurrently without blocking the main execution flow, improving the overall performance and responsiveness of the application.

By leveraging asynchronous programming techniques, such as callbacks, Promises, and async/await, JavaScript developers can create applications that efficiently handle time-consuming operations, allowing the event loop (the barista) to continue processing other tasks in the meantime, ultimately providing a better user experience.

Callbacks, Promises, and Async/Await

Below are the examples demonstrating the evolution of JavaScript code from callback-based flow to Promises and finally to async/await using our little coffee shop analogy:

  1. Callback-based flow (rising complexity of multiple callbacks):
function brewCoffee(callback) {
setTimeout(() => {
callback(null, 'Coffee');
}, 1000);
}

function prepareSnack(callback) {
setTimeout(() => {
callback(null, 'Snack');
}, 500);
}

brewCoffee((error, coffee) => {
if (error) {
console.error('Error brewing coffee:', error);
} else {
console.log(coffee);
prepareSnack((error, snack) => {
if (error) {
console.error('Error preparing snack:', error);
} else {
console.log(snack);
// Additional nested callbacks would increase complexity
}
});
}
});

2. Improved version using Promises (then and catch):

function brewCoffee() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Coffee');
}, 1000);
});
}

function prepareSnack() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Snack');
}, 500);
});
}

brewCoffee()
.then(coffee => {
console.log(coffee);
return prepareSnack();
})
.then(snack => {
console.log(snack);
})
.catch(error => {
console.error('Error:', error);
});

3. Final version using async/await flow:

// the prepareSnack and brewCoffee function remain promises as before

async function serveOrder() {
try {
const coffee = await brewCoffee();
console.log(coffee);

const snack = await prepareSnack();
console.log(snack);
} catch (error) {
console.error('Error:', error);
}
}

serveOrder();

These examples demonstrate how the code becomes more readable and manageable when transitioning from callback-based flow to Promises, and finally to the async/await syntax. The async/await flow makes the code easier to understand and maintain, as it closely resembles synchronous code while still benefiting from the advantages of asynchronous programming.

But what is happening here? 🥚🐇

I have come to enjoy understanding things on a more fundamental level and I will make an educated guess you do too since you made it this far into the article. To really understand Promises let’s deconstruct the magic. The best way to do that is to build our own version of a Promise pattern, albeit simplified. We will call it AsyncTask. It will expose then and catch as methods and it will allow then to be chained.

class AsyncTask {
constructor(executor) {
this.callbacks = [];
this.catchCallbacks = [];
this.state = 'pending';
this.result = null;

const resolve = (value) => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.result = value;
this.callbacks.forEach((callback) => callback(value));
}
};

const reject = (reason) => {
if (this.state === 'pending') {
this.state = 'rejected';
this.result = reason;
this.catchCallbacks.forEach((callback) => callback(reason));
}
};

try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}

then(onFulfilled) {
return new AsyncTask((resolve, reject) => {
const wrappedOnFulfilled = (value) => {
try {
resolve(onFulfilled(value));
} catch (error) {
reject(error);
}
};

if (this.state === 'fulfilled') {
wrappedOnFulfilled(this.result);
} else if (this.state === 'pending') {
this.callbacks.push(wrappedOnFulfilled);
}
});
}

catch(onRejected) {
if (this.state === 'rejected') {
onRejected(this.result);
} else if (this.state === 'pending') {
this.catchCallbacks.push(onRejected);
}
return this;
}
}

This is essentially the guts of a Promise pattern. It’s an abstraction over callbacks. Obviously there is a ton of additional tooling and optimizations around it since it’s native to the language now but that’s really it.

This is how we use it with our previous coffee shop example:

function brewCoffee(order) {
return new AsyncTask((resolve, reject) => {
console.log('Brewing coffee:', order);

setTimeout(() => {
console.log('Coffee ready:', order);
resolve(order);
}, Math.random() * 2000);
});
}

function prepareSnack(order) {
return new AsyncTask((resolve, reject) => {
console.log('Preparing snack:', order);

setTimeout(() => {
console.log('Snack ready:', order);
resolve(order);
}, Math.random() * 3000);
});
}

function serveItems(items) {
console.log('Serving:', items.join(' and '));
return 'Served: ' + items.join(' and ');
}

function logServedStatus(status) {
console.log(status);
}

const coffeeOrder = brewCoffee('Cappuccino');
const snackOrder = prepareSnack('Croissant');

coffeeOrder
.then((coffee) => snackOrder.then((snack) => [coffee, snack])
.then(serveItems)
.then(logServedStatus))
.catch((error) => {
console.log('Error:', error);
});

console.log('Taking the next customer...');

The bit that we cannot really simulate here is the async/await keyword usage. It is important to understand that is just syntactic sugar that allows you to pretend the code runs synchronously. Under the hood it’s still using Promises.

Conclusion

Asynchronous programming is a fundamental concept not only in modern JavaScript but also in other popular programming languages like Go, Java, and C#. By embracing asynchronous techniques such as callbacks, Promises, and the more recent async/await syntax in JavaScript, developers can create applications that are more responsive, performant, and capable of handling time-consuming operations effectively.

Understanding asynchronous programming in JavaScript will make it easier to grasp similar concepts in other languages, as they often share the same principles and goals. For instance, Go employs goroutines and channels to achieve concurrency, Java uses threads and the CompletableFuture class, and C# leverages the async/await pattern with Task objects.

The evolution from callback-based flows to Promises and async/await has significantly improved the readability and maintainability of JavaScript code, making it easier for developers to manage complex operations and ensure a smooth user experience. The coffee shop analogy highlights the importance of asynchronous programming, showcasing how it enables the efficient processing of tasks in a concurrent manner, ultimately leading to a better-performing application.

As programming languages continue to evolve and mature, it is crucial for developers to stay up to date with the latest techniques and best practices in asynchronous programming. By mastering these concepts in JavaScript and understanding their application in other languages, developers can create high-quality, performant applications that meet the demands of modern users across various platforms and technologies.

--

--

Denis J.

Just a guy exploring topics in software engineering.