Skip to main content

Command Palette

Search for a command to run...

Asynchronous JavaScript: From Callbacks to Promises and Async/Await

Updated
4 min read

JavaScript operates on a single thread and uses a non-blocking execution model. This means that when it encounters a long-running operation—such as fetching data from a network or reading a large file—it initiates the process and immediately continues executing the rest of the code.

Handling the results of these operations requires specific programming patterns. Over the years, JavaScript has evolved from using callbacks to Promises, and finally to the modern async/await syntax.

Here is a breakdown of how asynchronous JavaScript is structured and why the modern standards exist.

The Original Approach: Callbacks

Before modern features were introduced, the standard method for handling asynchronous tasks was the callback function. A callback is a function passed as an argument to another function, which is then executed when the asynchronous operation completes.

Consider a sequence where we need to read a file, process its contents, and write the result to a new file.

// Reading, processing, and writing using callbacks
readFile('source.txt', function(err, data) {
    if (err) {
        console.error("Failed to read file", err);
        return;
    }
    
    // Process the data
    processData(data, function(err, processedData) {
        if (err) {
            console.error("Failed to process data", err);
            return;
        }
        
        // Write the new data
        writeFile('destination.txt', processedData, function(err) {
            if (err) {
                console.error("Failed to write file", err);
                return;
            }
            
            console.log("File successfully copied and processed!");
        });
    });
});

The Problem: The Pyramid of Doom

As we can see in the example above, each subsequent asynchronous operation must be nested inside the previous one. This creates a deeply indented structure commonly referred to as "Callback Hell" or the "Pyramid of Doom."

Furthermore, error handling is entirely manual and repetitive. We must explicitly check for errors at every single step. If we forget to include an error check, the application can fail silently.

The Structural Upgrade: Promises

To solve the nesting and error-handling issues of callbacks, ES6 (2015) introduced Promises.
A Promise is a JavaScript object that represents the eventual completion or failure of an asynchronous operation and its resulting value.

Promise States

A Promise always exists in one of three mutually exclusive states:

  1. Pending: The initial state. The asynchronous operation is currently running.

  2. Fulfilled: The operation completed successfully, and the resulting data is available.

  3. Rejected: The operation failed, and an error reason is provided.

Once a Promise transitions to Fulfilled or Rejected, it is considered settled and its state cannot change.

// The same file operations, refactored to use Promises
readFile('source.txt')
    .then(data => {
        // Read was successful, initiate processing
        return processData(data);
    })
    .then(processedData => {
        // Processing successful, initiate writing
        return writeFile('destination.txt', processedData);
    })
    .then(() => {
        console.log("File successfully copied and processed!");
    })
    .catch(err => {
        // A single block to handle errors from any step in the chain
        console.error("Pipeline error:", err);
    });

By returning Promises and chaining .then() methods, the code structure becomes flat. Crucially, the .catch() method at the end of the chain will catch an error generated by any of the preceding operations, eliminating the need for repetitive error checks.

The Modern Standard: Async/Await

While Promises resolved the nesting issue, chaining .then() methods still required a syntax that differed significantly from standard, top-to-bottom synchronous code.

Introduced in ES2017, async/await is syntactic sugar built directly on top of Promises. It allows developers to write asynchronous code that reads sequentially, exactly like synchronous code.

When we place the await keyword before a function that returns a Promise, it pauses the execution of that specific function block until the Promise settles.

// The modern standard for asynchronous operations
async function handleFiles() {
    try {
        // Execution pauses here until readFile completes
        const data = await readFile('source.txt');
        
        // Pauses again until processData completes
        const processedData = await processData(data);
        
        // Pauses until writeFile completes
        await writeFile('destination.txt', processedData);
        
        console.log("File successfully copied and processed!");
        
    } catch (err) {
        // Errors are caught using standard try...catch blocks
        console.error("Pipeline error:", err);
    }
}

handleFiles();

Why Async/Await is the Standard

  1. Linear Readability: The code executes visually from top to bottom without any nested callbacks or chained methods.

  2. Standardized Error Handling: By utilizing try...catch blocks, asynchronous errors are handled using the exact same syntax as synchronous errors.

  3. Simplified Debugging: Because the code executes line-by-line, we can easily place breakpoints and step through asynchronous operations in debugging tools, which is notoriously difficult with nested callbacks.

If we are handling background tasks, network requests, or file systems in modern JavaScript, utilizing async/await with try...catch provides the most robust and maintainable architecture.

JavaScript Fundamentals for Developers

Part 2 of 22

Learn JavaScript from the ground up with clear explanations and practical examples. This series covers core JavaScript concepts like variables, arrays, functions, loops, objects, and modern ES6 features to help you build a strong foundation in JavaScript.

Up next

Map and Set in JavaScript

While Objects and Arrays are the undeniable workhorses of JavaScript, they come with historical baggage and specific limitations. ES6 introduced Map and Set to solve these exact problems, giving us cl