Callbacks and Promises, Simply Explained
Callbacks?
// caller function foo(callback) { callback('world'); } // callback function function myCallback(name) { console.log(`Hello ${name}`); // "hello world" } // pass callback to caller foo(myCallback);
To understand callbacks you first need to understand how NodeJS runs your code in general. Everything in NodeJS is controlled by the "event loop", because at it's heart NodeJS is a single, giant, very complex loop.
When you run code in NodeJS, each line is interpreted by the underlying V8 (JavaScript Engine). Basic language operations, such as math and string manipulation instantly return the result back to the caller. But other operations such as network requests, reading and writing files and accessing system hardware are not immediately executed, and instead added to the NodeJS event loop's "callstack". The event loop constantly executes available tasks in LIFO(Last in First Out) order. If a task forces the event loop to finish it's computation before processing other event loop elements, we say that it "blocks" the event loop. Furthermore, we call the type of task that blocks until completion, a synchronous task.
This is an amazing article if you want to learn more about the event loop. Seriously, it's great!
There's another type of task that can be registered to the event loop, a asynchronous task. As you might expect, asynchronous tasks are the opposite of synchronous tasks and do not block the event loop. Instead, async tasks are expected to provide a function they can "call back" that will handle the processing of whatever results from the async event completion. This explains what callbacks are, but why are they needed?
Why Callbacks?
Imagine if websites had to load all of their assets 1 by 1 in the browser, and couldn't render until absolutely everything was retrieved. If that was the case, it would take Gmail over 30 seconds to show up on my computer. Callbacks solve that problem by allowing tasks that consume very little CPU to run for a long time without blocking other tasks. Just to be clear, this isn't parallelism, because two things aren't happening at once (NodeJS is single threaded).
Most of the core NodeJS API's, such as filesystem, are implemented async, to allow minimal blocking of the event loop. If it's still not clear, the best way I've found to generalize when you need a callback is the following:
If code interacts with another system, and that system cannot guarantee it's reliability (file system, network, gpu), a callback may be needed.
For example, if you send a POST request to stripe.com, you cannot guarantee how fast (if it all) stripe.com will respond. To handle this unreliability, you send the POST request in a non-blocking fashion, and register a callback that will be invoked when the stripe.com server responds. And because that stripe.com request is asynchronous, you can make a concurrent (not parallel) request to the AWS S3 service (as an example) and shave huge chunks from your application load time.
Why Callbacks Are Bad
Over time people began to get frustrated with callbacks. Theoretically callbacks are a great solution for deferred code execution. Unfortunately, real use encourages deep callback nesting to handle nested events (async events that result from another async event)
Obviously you don't need callbacks for something like string manipulation. This is just a contrived example to keep things clean and simple.
// caller function foo(callback) { callback('world', myNestedCallback); } // inner inner callback function myNestedNestedCallback(name, callback) { console.log(`Hello ${name}`); // prints "Hello First Name: Mr. world" } // inner callback function myNestedCallback(name, callback) { callback(`First Name: ${name}`); } // callback function function myCallback(name, callback) { callback(`Mr. ${name}`, myNestedNestedCallback); } // pass callback to caller foo(myCallback);
This is known as "callback hell" because of how confusing code can become when it's nested inside many callbacks. Determining the current scope and available variables often becomes incredibly challenging.
Callbacks are ok when you need to load multiple things and don't care about the order they're handled, but they're not great when you need to write ordered, sequential code. In most cases, people used deep callback chains as artificially sequential code. There needed to be a solution that didn't block the event loop, but allowed code to be ordered without extreme nesting.
Promises
No matter what you've heard, a Promise is really just a fancy callback. It's quite literally a wrapper around a callback function with a well defined API. The Promise API allows you to query the state of the underlying async event, and has methods that allow you to register logic to handle the result or error generated, from the underlying async events completion. Promises primarily solve the nesting problem, as they turn code that looks like this:
// caller function foo(callback) { callback('world', myNestedCallback); } // inner inner callback function myNestedNestedCallback(name, callback) { console.log(`Hello ${name}`); // prints "Hello First Name: Mr. world" } // inner callback function myNestedCallback(name, callback) { callback(`First Name: ${name}`); } // callback function function myCallback(name, callback) { callback(`Mr. ${name}`, myNestedNestedCallback); } // pass callback to caller foo(myCallback);
Into this:
function myNestedNestedCallback(name) { return new Promise((resolve, reject) => { console.log(`Hello ${name}`); // prints "Hello First Name: Mr. world" }) } function myNestedCallback(name) { return new Promise((resolve, reject) => { resolve(`First Name: ${name}`); }); } function myCallback(name) { return new Promise((resolve, reject) => { resolve(`Mr. ${name}`); }); } myCallback().then(myNestedCallback).then(myNestedNestedCallback);
If you wanted to convert code that currently uses a callback into equivalent code using a Promise, this is a good reference:
// callback way function addCallback(a, b, callback) { callback(a + b); } // promise way function addPromise(a, b) { return new Promise((resolve, reject) => { resolve(a + b); }); }
If you're interacting with a callback based API, and want to convert it to a Promise externally,
// signature function makeHTTPRequest(url, method, callback) {} const convertedToPromise = new Promise((resolve, reject) => { makeHTTPRequest('google.com', 'GET', (body, err) => { if (err) { return reject(err); } return resolve(body); }); }); convertedToPromise.then((res) => console.log(res)); // prints response from google.com
Many callbacks can also automagically be converted to their "promisified" versions through the util
package in NodeJS.
const { promisify } = require('util'); function addCallback(a, b, callback) { callback(a + b); } const asyncAdd = promisify(addCallback); asyncAdd(3, 6).then((res) => console.log(res)); // "9"
Async await
Lastly, we have async
and await
. Similar to the relationship between a Promise and a callback, async
and await
are really just way of using Promises. async
& await
provide a syntax to write Promise code that looks like native sync code, which usually results in much more readable and maintainable JavaScript code. When you use the async
identifier on a function, it's equivalent to the following Promise code.
// async version async function add(a, b) { return a + b; // really returns a Promise under the hood } // equivalent code but promise way function addPromise(a, b) { return new Promise((resolve, reject) => { resolve(a + b); }); } add(1, 2).then((res) => console.log(res)); // "3" addPromise(1, 2).then((res) => console.log(res)); // "3"
In fact, all async
functions return a full-fledged Promise object. await
provides additional functionality for async
methods. When await is used before a call to an async function it implies that the code should directly return the async result to the left hand side of the expression, instead of using an explicit async task. This allows you to write ordered sync-style code, while reaping all the benefits of async evaluation. If it still doesn't make sense, here's what the equivalent of await
is in Promises.
async function add(a, b) { return a + b; } async function main() { const sum = await add(6, 4); console.log(sum); // "10" }
Remember await
is just a hack for .then()
allowing the code to be styled without nesting. There is no functional different between the above code and below code.
function addPromise(a, b) { return new Promise((resolve, reject) => { resolve(a + b); }); } addPromise(6, 4).then((res => console.log(res))); // "10"
Conclusion
I hope this helped those who were still struggling to understand the core mechanics behind callbacks and Promises. For the most part, it's all just a bunch of syntactic sugar, and not really that complex.
If you're still struggling with the underlying concepts such as parallel, asynchronous and concurrent, I recommend the recent article I wrote covering those topics.