Callbacks work fine for handling asynchronous code in JavaScript, but promises and the async and await keywords are cleaner and more flexible. Credit: Thinkstock Dealing with asynchronous code—meaning any kind of code that doesn’t execute immediately—can be tricky. Asynchronous behavior is one of the main sources of complexity in any software environment. It represents a break in execution flow that may lead to any number of outcomes. This article introduces the three ways to handle asynchronous behavior in JavaScript. We’ll start with a look at callbacks, then I’ll introduce promises and the async and await keywords as more modern alternatives. What is asynchronous code? Asynchronous code (async code) says: go do something while I do other things, then let me know what happened when the results are ready. Also known as concurrency, async is important in a variety of use cases but is especially vital when interacting with external systems and the network. In the browser, making remote API calls is a ubiquitous use case for asynchronous code, but there are innumerable others on both the front- and back-end. Callbacks in JavaScript Callbacks were the only natively supported way to deal with async code in JavaScript until 2016, when the Promise object was introduced to the language. However, JavaScript developers had been implementing similar functionality on their own years before promises arrived on the scene. Let’s take a look at some of the differences between callbacks and promises, and see how we can use each technique to coordinate multiple layers of execution. A classic callback example Asynchronous functions that use callbacks take a function as a parameter, which will be called once the work completes. If you’ve ever used something like setTimeout in the browser, you’ve used callbacks. Listing 1. Callbacks in timeouts // You can define your callback separately... let myCallback = () => { console.log('Called!'); }; setTimeout(myCallback, 3000); // … but it’s also common to see callbacks defined inline setTimeout(() => { console.log('Called!'); }, 3000); console.log(“This happens first!”); Listing 1 says, in two different ways: after 3000 milliseconds, output “Callid!” to the console. In both cases, the line “This happens first!” will output first. That’s because these are asynchronous calls, and the code execution flow continues on while the timeouts are waiting to happen. That is the essence of asynchronous programming. Nested callbacks and the pyramid of doom Callbacks work well for handling asynchronous code, but they get tricky when you start coordinating multiple asynchronous functions. For example, if we wanted to wait two seconds and log something, then wait three seconds and log something else, then wait four seconds and log something else, our syntax would quickly become deeply nested. This is a state sometimes known as callback hell. Listing 2. Nested callbacks with setTimeout setTimeout(() => { console.log('First Callback!'); setTimeout(() => { console.log('Second Callback!'); setTimeout(() => { console.log('Third Callback!'); }, 4000); }, 3000); }, 2000); Listing 2 offers a glimpse of how ugly callbacks can get. The more code we place inside of nested calls, the more difficult it is to figure out what is going on and where each call completes. This may seem like a trivial example (and it is), but it’s not uncommon to make several web requests in a row based on the return results of a previous request. The “nested callbacks” problem crops up all the time. In Listing 3, we see a more involved example in a Node.js environment, where we’re dealing with multiple files. Listing 3. Callback hell with Node.js files fs.readdir('/path/to/dir', function (err, files) { if (err) { console.log(err); return; } files.forEach(function (file) { fs.readFile('/path/to/dir/' + file, 'utf8', function (err, contents) { if (err) { console.log(err); return; } // do something with the file fs.writeFile('/path/to/new/dir/' + file, contents, function (err) { if (err) { console.log(err); return; } // do something with the file }); }); }); }); It’s already difficult to follow along with this code, even though it’s not doing anything very complex and none of the actual business logic is there. It’s hard to figure out where you are and what file handles are open when. We’re just looping through each file in a directory and reading the contents and using it in a new file in a different directory. So, now you’ve seen the limitations of callbacks. They are fine for simple uses but not great in more complex situations. Fortunately, modern JavaScript gives us the Promise object and async and await keywords as more flexible solutions. Promises Promises require the function be written such that it returns a Promise object, which has standard features for handling subsequent behavior and coordinating multiple promises. We can wrap a timeout call in a Promise to see how things work. You can see examples of timeouts in some of the NPM libs and learn more about them here. Listing 4 presents a promise-based timeout called wait. Listing 4. Promise-based timeout function wait(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } In Listing 4, we get a glimpse into how service code can use a Promise to deal with asynchronous conditions. In essence, our wait function returns a Promise object, which calls the resolve argument passed into the Promise constructor. (Incidentally, resolve is an example of a higher-order function.) The code might seem complex at first, but the idea is simple: When the code passed to the Promise constructor calls the resolve function, the client code will be alerted that the Promise has been completed. Note that the name resolve is not essential; it is whatever argument is passed into the function passed to the Promise. Similar support exists for error conditions. Next, in Listing 5, you can see the wait function being used with the then function. Listing 5. Using Promise with then wait(3000).then(() => { console.log('Called after 3 seconds'); }); The basic idea is that we can now just call wait, passing in the relevant argument (the wait time) and not have to pass in the callback handler as an argument. Instead, the handler is given in the .then() call. This opens up the possibility of chaining together multiple calls, like we see in Listing 6. Listing 6. Chaining promises with then wait(2000) .then(() => { console.log('First Callback!'); return wait(3000); }) .then(() => { console.log('Second Callback!'); return wait(4000); }) .then(() => { console.log('Third Callback!'); return wait(4000); }); We can also put calls together using the Promise.all() static function, as shown in Listing 7. Listing 7. Using Promise.all() Promise.all([ wait(2000), wait(3000), wait(4000) ]).then(() => console.log('Everything is done!')); Promise also has a .race() method that returns as soon as any of the promises resolve or error out. JavaScript async/await As a final example, Listing 8 shows how to use our wait() function with the async and await keywords. Listing 8. Using JavaScript async/await async function foo() { await wait(3000); console.log('Called after 3 seconds'); } foo(); Listing 8 creates an asynchronous function, foo(), with the async keyword. This keyword tells the interpreter that there will be an await keyword inside. This keyword allows for executing an asynchronous function like wait() as though it were synchronous. The code will pause here until wait(3000) completes, and then continue on. Await functions hold more capability, including handling results. See How to use async and await in JavaScript for more about these keywords. Related content feature 14 great preprocessors for developers who love to code Sometimes it seems like the rules of programming are designed to make coding a chore. Here are 14 ways preprocessors can help make software development fun again. By Peter Wayner Nov 18, 2024 10 mins Development Tools Software Development feature Designing the APIs that accidentally power businesses Well-designed APIs, even those often-neglected internal APIs, make developers more productive and businesses more agile. By Jean Yang Nov 18, 2024 6 mins APIs Software Development news Spin 3.0 supports polyglot development using Wasm components Fermyon’s open source framework for building server-side WebAssembly apps allows developers to compose apps from components created with different languages. By Paul Krill Nov 18, 2024 2 mins Microservices Serverless Computing Development Libraries and Frameworks news Go language evolving for future hardware, AI workloads The Go team is working to adapt Go to large multicore systems, the latest hardware instructions, and the needs of developers of large-scale AI systems. By Paul Krill Nov 15, 2024 3 mins Google Go Generative AI Programming Languages Resources Videos