Back to top

Stop Blocking Your Code! Mastering Asynchronous JavaScript

Published: Jan 6, 2024


Understanding Asynchronous Code


Asynchronous programming has become an essential concept for any modern JavaScript developer. But what exactly does it mean and why does it matter?


What is asynchronous code?


In the simplest terms, asynchronous code allows long-running actions to complete without blocking subsequent operations.


Let’s compare it to synchronous code:


console.log('Start'); 

// Long operation like fetching data from API
const data = fetchData();  

console.log('End');

In this synchronous example, fetchData() needs to fully complete before End logs. If fetchData() takes 5 seconds, our program is stuck waiting during that time.


Now let’s make it asynchronous:


console.log('Start');

fetchDataAsynchronously(response => {
    // This callback handles the response  
    console.log(response);   
});

console.log('End'); 

With this approach fetchDataAsynchronously() can process in the background while the rest of our code runs. This prevents blocking and allows maximum efficiency!


Some common examples of async operations:

  • Fetching data from an external API
  • Reading/writing files
  • Animations and visual transitions
  • Intensive computations

Why asynchronous code matters


Here are some key reasons asynchronous programming is crucial: Performance: Asynchronous operations unlock huge speed improvements by preventing blocking. Responsiveness: The UI stays active while async tasks process in the background. No more freezing! Scalability: Servers can handle more simultaneous requests when async ops prevent bottlenecking.


Mastering asynchronous JavaScript unlocks game-changing performance gains for complex, data-driven web apps. These capabilities enabled many of the rich interactive sites we enjoy today.


The evolution of async JavaScript


JavaScript’s asynchronous capabilities have rapidly evolved:

  • Callbacks: Early async via nested callbacks led to callback hell.
  • Promises: Abstracted callbacks for cleaner async code.
  • Async/await: Write async code that reads like sync! Clean and simple.

Learning to leverage these modern asynchronous techniques is now a vital skill for any web developer.


Callback Functions In Depth


Callbacks are at the core of achieving asynchronous behavior in JavaScript. Let’s take a deeper look at how they work.


What are callback functions?


A callback function is a function passed into another function as an argument, to be executed after some operation:


function doSomething(callback) {
    // do things  
    callback(); // Callback invoked  
}

doSomething(() => {
    // I am a callback  
});

This allows:

  • Asynchronous flow: doSomething() continues executing without blocking on the callback.
  • Inversion of control: The control flow is inverted by handing off to another function.

This pattern is referred to as a higher-order function - a function that accepts other functions as parameters and/or returns a function.


Key reasons we use callbacks:

  • Continue code execution without blocking
  • Abstract away & modularize operations

Avoiding “callback hell”


The callback approach can get messy with nesting:


doA(() => {
    doB(() => {
        doC(() => {
            // and so on...
        });
    });
}); 

This “callback hell” with deeply nested callbacks is difficult to read and reason about.


There are a few solutions:

  • Modularization - Break callbacks into named functions
  • Promise chains - Abstract callbacks into promise chains
  • Async/await - Write asynchronous code that reads synchronously

Promises: Beyond the Basics


Promises offer a major improvement in writing asynchronous JavaScript by handling callbacks for you. Let’s go beyond promise fundamentals and explore more advanced usage.


Understanding promises internally


Conceptually, promises have 3 states:

  • Pending - Initial state, neither fulfilled nor rejected.
  • Fulfilled - Operation completed successfully.
  • Rejected - Operation failed.

Behind the scenes, callbacks are still orchestrating state changes:


let promise = new Promise((resolve, reject) => {
// Initiate async operation 

if (success) {
resolve(value); // Callback for fulfillment 
} else {  
reject(error); // Callback for rejection
}
});

The Promise constructor accepts a callback with resolve and reject functions to trigger state changes.


This internal mechanism enables abstracting away callback details in our own code for cleaner usage.


Advanced promise chaining


We can chain multiple async operations with then():


functionA()
  .then((resA) => functionB(resA)) 
  .then((resB) => functionC(resB))
  .catch((err) => handleError(err)); 

This keeps code modular with each function handling a single step.


We can also execute promises in parallel with Promise.all and complete fast with Promise.race.


Debugging promises


Debugging promises requires understanding their asynchronous execution. Useful techniques involve:

  • Logging state changes
  • Handling errors gracefully
  • Understanding timing with concurrent operations

Mastering promises provides a very flexible abstraction for working with asynchronous code!


Promise Composition


When dealing with complex asynchronous flows, it’s best to compose many smaller reusable promises:


function getUser() {
    return fetchUserData() // Single purpose
        .then(saveUserToDB) // Modular 
        .then(sendWelcomeEmail);
} 

function authenticate() {
    return getUser() 
        .then(loginUser) // Reusable
        .then(loadDashboard) 
}

authenticate().catch(handleErrors);

This keeps each promise simple while enabling complex sequences. It also facilitates reusability of common tasks.


We can also use Promise.allSettled to handle an array of promises easily:


Promise.allSettled([
    fetchUser(),
    fetchPosts(), 
    fetchComments()  
]).then((results) => {
    // all promises settled even if some rejected
});

Promise Cancellation


Promises don’t support cancellation natively, but we can leverage patterns like:

  • Setting a variable that resolves check within promise callbacks
  • Using wrapper libraries like p-cancelable to enable cancellation

Promise Patterns


Useful patterns include:

  • Promisify - Convert callback APIs to promise-based
  • Deferred - Separate promise creation from resolution for advanced handling

Best Practices


Follow these promise guidelines:

  • Avoid new Promise constructor for better readability
  • Use finally() method for cleanup regardless of outcome

Async/Await Mastery


The async/await syntax introduced in ES2017 revolutionized working with asynchronous JavaScript by making asynchronous code read more like synchronous code. Let’s dig into some advanced use cases!


Handling errors with async/await


We can use standard try/catch blocks:


async function getUser() {
    try {
        const user = await fetchUser(); 
        return user;
    } catch (error) {
        console.log('Could not fetch user', error);
    } 
}

Or directly at the call site:


const user = await getUser().catch(handleError);

This models typical synchronous error handling but without blocking.


Async function use cases


The async keyword is very versatile:

  • Use async with arrow functions const fn = async () => {}
  • Write async class methods async loginUser()
  • Async iterators enable using for await..of loops

async function* fetchPages() {
    yield fetchPage1(); 
    yield fetchPage2();
    // ...
}

for await (let page of fetchPages()) {
    handlePage(page);
}

This keeps our asynchronous code neat and readable.


Async/await offers a clean look feel for promises while remaining entirely non-blocking. It has become an essential tool for any modern JavaScript developer.


Fetch API vs AJAX


The Fetch API and AJAX both allow making network requests for data in JavaScript. How do they compare?


Conceptual Overview


The Fetch API is the modern approach for HTTP requests:

  • Introduced in 2015 to replace XMLHttpRequest
  • Based on promises that offer better chaining and error handling
  • Provides a more direct logical flow

AJAX refers to the traditional XMLHttpRequest (XHR) object for requests:

  • Previously required for asynchronous requests
  • More complex interface based on events and callbacks
  • Been in use much longer with wider legacy browser support

Fetch usage:


fetch(url)
    .then(handleResponse) 
    .catch(handleError);

AJAX usage:


const xhr = new XMLHttpRequest();
xhr.onload = handleResponse; 
xhr.onerror = handleError;
xhr.open('GET', url); 
xhr.send();

Fetch offers a simpler modern approach but AJAX has wider legacy browser coverage.


Direct Syntax Comparison


Let’s contrast their direct syntax:


// Fetch
try {
    let response = await fetch(url);
    let data = response.json();
    displayData(data);
} catch(error) { 
    showErrorMessage(error); 
}

// AJAX 
let xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = () => {
    let data = JSON.parse(xhr.response); 
    displayData(data);
}
xhr.onerror = () => showErrorMessage();
xhr.send();

We can see Fetch provides a much cleaner control flow resemblance to synchronous code with support for await/async.


Overall, Fetch dramatically improves working with requests in JavaScript. But AJAX is still useful for supporting legacy browsers.


Error Handling and Debugging


Robust error handling and debugging is crucial when working with asynchronous JavaScript:


Error Handling Strategies


  • Use .catch() clauses in promises for readable error handling
  • Wrap async/await code in try/catch blocks
  • Handle errors at the global level when relevant
  • Log errors to track down tricky bugs

Debugging Asynchronous Code


Debugging promises and async can be tricky but tools can help:


  • Leverage browser DevTools to place breakpoints, profile operations, and trace promise states
  • Use debug libraries like stacktrace.js for enhanced call stacks
  • Log promise states to output console for diagnostics

Careful error handling practices combined with debugging tools gives visibility into async execution flows.


Best Practices and Considerations


Additionally, keep these best practices in mind:


Mitigating Stale Closures


Stale closures occur when enclosing the incorrect variable in an async callback. Address with:


  • Binding closures to reference values explicitly
  • Avoiding unnecessary closures

Simplifying Code with Promise Utilities


Abstract code with helper libraries:


  • bluebird for advanced promise capabilities
  • async for easier async-wait logic

Structuring Asynchronous Projects


Modular architecture helps manage complex applications:


  • Separate business logic units performing async tasks
  • Build workflows referencing these modules

Conclusion


Asynchronous programming opens up critical performance capabilities for JavaScript applications. Callbacks, promises, and async/await give several effective models for asynchronous logic.


By mastering these approaches, leveraging debugging tools, and applying best practices - we can build complex, efficient asynchronous workflows.


The capabilities are ever-evolving to further improve asynchronous operations and interoperability with modern JavaScript features.