Node.js Async Await Tutorial – With Asynchronous JavaScript Examples

One of the most powerful yet confusing aspects of programming in Node.js is its asynchronous, non-blocking model for handling I/O. While this enables high throughput and efficient utilization of computing resources, it can be challenging for developers to wrap their heads around, especially when coming from languages and frameworks that default to synchronous execution.

In this tutorial, we‘ll dive deep into the world of asynchronous programming in Node.js. We‘ll explore how the Node.js runtime handles I/O under the hood, examine the different approaches that have evolved over time for wrangling asynchronous code, and walk through practical examples of how to leverage the latest async/await syntax to write concise, readable asynchronous functions. Let‘s get started!

Understanding the Node.js Asynchronous Model

At the heart of Node.js is the event loop, a magical place where I/O and other operations are dispatched, tracked, and coordinated across the runtime‘s worker threads behind the scenes. The event loop enables Node.js‘s non-blocking execution model.

In a nutshell, when your code makes an I/O call, like reading from a file or making an HTTP request, Node.js does not halt and wait for the response. Instead, it immediately continues executing the next lines of code. Once the I/O response comes back, an event is triggered and Node.js circles back to process the result. This is the secret sauce that allows Node.js to efficiently juggle many concurrent operations.

Here are the key components of the Node.js asynchronous architecture:

  • Event Queue: Incoming I/O requests, timers, and events are placed here to be processed by the event loop.
  • Event Loop: A single-threaded loop that continuously checks the event queue, dispatches I/O to worker threads, and processes callback functions.
  • Worker Threads: A pool of background threads that handle the grinding work of I/O and expensive tasks and trigger a callback when done.
  • Callback Queue: Callbacks corresponding to completed async operations wait here to be executed on the next event loop tick.

As an example, consider this simplified code snippet:


console.log(‘Starting app‘);

fs.readFile(‘file.txt‘, ‘utf8‘, (err, data) => { console.log(‘File read:‘, data); });

console.log(‘Doing other work‘);

The execution flow goes like this:

  1. "Starting app" is logged
  2. The fs.readFile asynchronous call is made, specifying a callback to run later
  3. "Doing other work" is immediately logged while the file is still being read
  4. When the file read completes, its callback is added to the callback queue
  5. On the next event loop iteration, the file callback is executed, logging "File read: …"

Mastering this flow is essential to successfully work with Node.js. But as you can imagine, complex scenarios requiring multiple or nested asynchronous calls can quickly become convoluted and hard to reason about. Luckily, JavaScript provides a few tools to help us out.

Evolving Approaches to Asynchronous JavaScript

The approaches to handling asynchronous code in JavaScript have gone through several iterations, each building upon the previous to improve readability and maintainability. Let‘s examine the key milestones.

Callbacks

The original way to handle asynchronous flow in JavaScript was through callback functions. The core idea is simple – pass a function as an argument to be called once the asynchronous operation completes.


fs.readFile(‘file.txt‘, ‘utf8‘, (err, data) => {
  if (err) {
    console.error(‘Read error:‘, err);
    return;
  }
  console.log(‘File read:‘, data);
});

This works great for basic scenarios. But more complex flows requiring sequenced or nested asynchronous calls devolve into the dreaded callback hell:


fs.readFile(‘file1.txt‘, ‘utf8‘, (err1, data1) => {
  if (err1) throw err1;

fs.readFile(‘file2.txt‘, ‘utf8‘, (err2, data2) => { if (err2) throw err2;

fs.readFile(‘file3.txt‘, ‘utf8‘, (err3, data3) => {
  if (err3) throw err3;

  console.log(data1, data2, data3);
});

});
});

Yuck. The nesting and boilerplate quickly erode readability and make errors harder to trace and handle. We need something better.

Promises

Promises provide a cleaner way to chain and compose asynchronous operations. A promise represents the eventual completion or failure of an operation and allows you to attach callbacks to handle the result.


fs.promises.readFile(‘file.txt‘, ‘utf8‘) 
  .then(data => {
    console.log(‘File read:‘, data);
  })
  .catch(err => {  
    console.error(‘Read error:‘, err);
  });

The .then() method is used to register callbacks on the promise and .catch() provides a way to centrally handle errors. Multiple asynchronous operations can be sequenced by chaining .then() calls:


fs.promises.readFile(‘file1.txt‘, ‘utf8‘)
  .then(data1 => {
    console.log(‘File 1 read‘);
    return fs.promises.readFile(‘file2.txt‘, ‘utf8‘); 
  })
  .then(data2 => {
    console.log(‘File 2 read‘);
    return fs.promises.readFile(‘file3.txt‘, ‘utf8‘);
  })  
  .then(data3 => {
    console.log(‘File 3 read‘);
    console.log(data1, data2, data3);
  })
  .catch(err => {
    console.error(‘Read error:‘, err);  
  });

This is much cleaner than the nested callback approach. The chain of .then() calls clearly conveys the sequence of asynchronous steps and error handling is centralized in a single .catch().

However, while promises are a big improvement, the syntax is still a bit clunky with all the .then() calls. There‘s an even more seamless way.

Async/Await

The async and await keywords, added in ES2017, provide a new way to work with promises that makes asynchronous code look and behave almost synchronously.

A function declared async automatically wraps its return value in a promise. Within an async function, the await keyword can be used to wait for a promise to resolve and return the result.


const readFile = async (file) => {
  try {
    const data = await fs.promises.readFile(file, ‘utf8‘);
    console.log(`File ${file} read: ${data}`); 
    return data;
  } catch (err) {
    console.error(`Read error: ${err.message}`);
  }
};

With this syntax, reading a file becomes an asynchronous operation. We can call it like this:


(async () => {
  const data = await readFile(‘file.txt‘);  
  console.log(‘Next steps with data:‘, data);
})();

The await keyword pauses execution until the promise returned by readFile() resolves. If the promise rejects, an error is thrown that we can catch. Multiple awaits can be used to sequence asynchronous steps:


(async () => {
  try {
    const data1 = await readFile(‘file1.txt‘);
    const data2 = await readFile(‘file2.txt‘);  
    const data3 = await readFile(‘file3.txt‘);
    console.log(data1, data2, data3);
  } catch (err) {
    console.error(‘Read error:‘, err.message);
  }  
})();  

This is extremely readable and mirrors the structure of synchronous code, with all the power of asynchronous execution under the hood. Async/await has quickly become the preferred way to handle asynchronous flow in modern JavaScript.

Asynchronous Best Practices and Pitfalls

While async/await makes our lives easier, there are still a few best practices and "gotchas" to be aware of.

One common pitfall is unnecessary sequentiality. Since await pauses execution, it‘s easy to accidentally make asynchronous operations sequential that could be done in parallel. For example:


const start = Date.now();

const foo = await someAsyncThing(); const bar = await anotherAsyncThing();

console.log(‘Tasks done in‘, Date.now() - start, ‘ms‘);

If foo and bar are independent, awaiting them sequentially will take longer than necessary. Instead, fire off both operations and await concurrently:


const start = Date.now();

const [foo, bar] = await Promise.all([someAsyncThing(), anotherAsyncThing()]);

console.log(‘Tasks done in‘, Date.now() - start, ‘ms‘);

Promise.all() takes an array of promises and returns a promise that resolves to an array of the results, allowing us to await multiple promises concurrently.

Another gotcha is error handling. Remember that any thrown error in an async function will reject the promise it returns. Be sure to catch rejected promises to avoid unhandled rejections:


const risky = async () => {
  throw new Error(‘Oops!‘);
};

// Catch and handle the error risky().catch(err => console.error(err.message));

// Or use a try/catch try { await risky(); } catch (err) { console.error(err.message);
}

Finally, while async/await is supported in the latest Node.js versions, if your code needs to run in older environments, you can use the Babel transpiler or a library like asyncawait to convert async/await to ES2015 promises.

Wrapping Up

In this deep dive, we explored the asynchronous model that underlies Node.js and how it enables efficient, non-blocking I/O. We traced the evolution of asynchronous patterns in JavaScript from callbacks to promises to async/await, seeing how each step improves readability and maintainability.

When working with asynchronous code in Node.js remember:

  • The event loop dispatches I/O operations to worker threads and processes results via callbacks
  • Promises provide a flexible way to chain and compose asynchronous operations
  • Async/await builds on promises to make asynchronous code more readable and structured
  • Avoid unintentional sequentiality by using Promise.all() to await multiple promises concurrently
  • Always handle promise rejections to avoid unhandled errors

I hope this tutorial has boosted your understanding of and confidence with asynchronous programming in Node.js. It‘s a powerful paradigm that lets us build fast, efficient, and scalable applications. Equipped with async/await and best practices, you‘re ready to master asynchronous flow. Happy coding!

Similar Posts