A Guide to Async JavaScript: Mastering Callbacks and Promises

ganesh vganesh v
12 min read

As a fellow geek, I'm excited to share my insights into Asynchronous JavaScript with other enthusiasts and aspiring JavaScript beginners. In this blog, we'll delve into everything from the fundamentals to advanced concepts of asynchronous JavaScript, including callbacks and promises. This journey will deepen your understanding and transform your learning into lasting knowledge

Before we get in to the Aysnc Js ,we have to see what is synchronous Javascript then we will get clear picture of both of them

Synchronous JavaScript

By default, JavaScript runs in a synchronous, single-threaded way, meaning it processes code one line at a time from top to bottom. It waits for each line to finish before starting the next one. This makes the code run in a sequence, which is predictable but can be inefficient for tasks that take time, like network requests or file operations.

// example for synchronous code
console.log("hi geek")
console.log("welcome to my blog")
function welcome(name){
console.log("come in ",name)
}
function farewell(name) {
    logMessage(`goodbye, ${name}`);
}

welcome("ganesh")
farewell("ganesh")
//hi geek
//welcome to my blog
//come in ganesh
//goodbye, ganesh
  • The first line console.log("hi geek"); outputs "hi geek" to the console.

  • The second line console.log("welcome to my blog"); outputs "welcome to my blog" to the console.

  • The welcome function is called with the argument "ganesh", which outputs "come in ganesh" to the console.

  • The farewell function is called with the argument "ganesh", which outputs "goodbye, ganesh" to the console.

Each line of code is executed in order, demonstrating the synchronous nature of JavaScript, where each operation completes before the next one begins.

Asynchronous Javascript

Asynchronous JavaScript means the code doesn't run line by line in order. It lets the code continue without waiting for previous tasks to finish. This non-blocking nature is useful for time-consuming tasks like network requests or file operations, keeping the program responsive and efficient. Asynchronous behavior is usually handled with callbacks, promises, or async/await syntax.

console.log("going to download");

function fetchData() {
    setTimeout(() => {
        console.log("downloaded");
    }, 2000);
}

fetchData();
console.log("watching the downloaded movie");
Output:

going to download
watching the downloaded movie
downloaded
  • console.log("going to download"); - This line logs "going to download" to the console immediately.

  • The fetchData function is defined. Inside this function, setTimeout is used to simulate an asynchronous operation. It schedules the function to log "downloaded" to the console after a 2000-millisecond (2-second) delay.

  • fetchData(); - This line calls the fetchData function, initiating the asynchronous operation.

  • console.log("watching the downloaded movie"); - This line logs "watching the downloaded movie" to the console immediately after the fetchData function is called, without waiting for the 2-second delay to complete.

  • After the 2-second delay, the message "downloaded" is logged to the console.

This example demonstrates asynchronous behavior, where the setTimeout function allows the rest of the code to continue executing while waiting for the specified delay to complete.

Now, I believe we have a basic understanding of async JavaScript, so we can dive into the topics.

We will learn about these in our blog.

  • What is a callback in JavaScript

  • How callbacks work and their use cases

  • Disadvantages of callbacks

  • How can we solve issues

  • What is a promise in JavaScript

  • A promise comes as a solution

  • Consume a promise and create a promise

  • Promise chaining

  • Promise API

Let's dive into the topics and explore the depths of callbacks and promises in the JavaScript ocean. We'll start with understanding what callbacks are, how they work, and their advantages and disadvantages. Then, we'll move on to promises, how they provide solutions to callback issues, and how to effectively use them, including promise chaining and the Promise API. This comprehensive journey will enhance your understanding and mastery of asynchronous programming in JavaScript.

Synchronous JavaScript

By default, JavaScript is a synchronous, single-threaded language, meaning the JavaScript engine executes code line by line from top to bottom. While executing, it waits for the current line of code to complete before moving to the next line. This ensures that the code runs in a specific sequence, which can be predictable but may lead to inefficiencies when dealing with tasks that take time, such as network requests or file operations.

// example for synchronous code
console.log("hi geek")
console.log("welcome to my blog")
function welcome(name){
console.log("come in ",name)
}
function farewell(name) {
    logMessage(`goodbye, ${name}`);
}

welcome("ganesh")
farewell("ganesh")
//hi geek
//welcome to my blog
//come in ganesh
//goodbye, ganesh
  • The first line console.log("hi geek"); outputs "hi geek" to the console.

  • The second line console.log("welcome to my blog"); outputs "welcome to my blog" to the console.

  • The welcome function is called with the argument "ganesh", which outputs "come in ganesh" to the console.

  • The farewell function is called with the argument "ganesh", which outputs "goodbye, ganesh" to the console.

Each line of code is executed in order, demonstrating the synchronous nature of JavaScript, where each operation completes before the next one begins.

What is a callback in JavaScript

A callback is a function that is passed as an argument to another function and is executed after the outer function has completed its operation

// Sample callback function
function main(number1, number2, callback) {
  console.log("hi iam main function")
  let result = callback(number1, number2);
  console.log(result);
}

function add(number1, number2) {
  let addedNumber = number1 + number2;
  return addedNumber;
}
main(3, 4, add); // This will log 7 to the console
  • The main function has three parameters: number1, number2, and callback. It logs the message "hi iam main function" and then logs the result obtained from the callback function.

  • The callback function add takes the values from the parameters, adds them, and returns the sum.

  • When we call the main function with the arguments 3, 4, and add, it executes the code within the main function, logs the message "hi iam main function," then calls the add function as the callback. The result is obtained and logged.

  • This demonstrates how we can control the sequence of execution by running the add function after the main function.

How callbacks work and their use cases

How Callbacks Work:

  1. Passing Functions as Arguments: A callback function is passed as an argument to another function. This allows the outer function to execute the callback at a specific point in its execution.

  2. Execution After Completion: The callback is typically executed after the outer function has completed its main task, making it ideal for operations that take time, such as data fetching or file reading.

Use Cases for Callbacks:

  1. Asynchronous Programming: Callbacks are essential for managing asynchronous tasks like API calls, where you want to perform an action once the data is retrieved.

     function dataFetching(callback){
     let fetchedData = fetch("www.exampleapi.com")
     callback(fetchedData)
     }
    
     function processData(data){
     console.log("processing data")
     }
    
  2. Event Handling: In event-driven programming, callbacks are used to define what should happen when an event occurs, such as a user clicking a button.

    we can use the callback function to perform actions based on events like above

  3. Timers: Functions like setTimeout and setInterval use callbacks to execute code after a specified delay or at regular intervals.

     function timer(){
         setTimeout(() => {
             console.log("this callback executes after 2 seconds")
    
         }, 2000);
     }
     timer()
    

    logs message this callback executes after 2 seconds after 2 seconds by using callback function

  4. Array Methods: Methods like map, filter, and forEach use callbacks to apply a function to each element of an array, allowing for concise and expressive data manipulation.

     let sampleArray = [1, 2, 3, 4, 5];
     let mutliplyTwo = sampleArray.map((number) => {
       return number * 2;
     });
     console.log(mutliplyTwo);
    
     //output
     [ 2, 4, 6, 8, 10 ]
    

By using callbacks, JavaScript can efficiently handle operations that would otherwise block the execution of code, enhancing performance and responsiveness.

// Simulate an asynchronous operation using setTimeout
function fetchData(callback) {
  console.log("Fetching data...");

  // Simulate a delay of 2 seconds
  setTimeout(() => {
    const data = "Sample data";
    callback(data);
  }, 2000);
}

// Define a callback function to handle the fetched data
function processData(data) {
  console.log(`Processing: ${data}`);
}

// Call fetchData with processData as the callback
fetchData(processData);
  • The fetchData function simulates an asynchronous operation using setTimeout. It logs "Fetching data..." to the console and then waits for 2 seconds before executing the callback function.

  • The processData function is a callback that takes the fetched data as an argument and logs it to the console.

  • When fetchData is called with processData as the callback, it simulates fetching data and then processes it after the delay, demonstrating how asynchronous callbacks work. This pattern is commonly used in JavaScript for handling operations like network requests, where you need to wait for a response before proceeding.

Disadvantages of Callbacks

  1. Callback Hell: When multiple asynchronous operations are nested, it can lead to "callback hell," where the code becomes difficult to read and maintain due to deep nesting and complex logic.

    
     function firstTask(callback) {
       setTimeout(() => {
         console.log("First task complete");
         callback();
       }, 1000);
     }
    
     function secondTask(callback) {
       setTimeout(() => {
         console.log("Second task complete");
         callback();
       }, 1000);
     }
    
     function thirdTask(callback) {
       setTimeout(() => {
         console.log("Third task complete");
         callback();
       }, 1000);
     }
    
     // Nested callbacks leading to callback hell
     firstTask(() => {
       secondTask(() => {
         thirdTask(() => {
           console.log("All tasks complete");
         });
       });
     });
    
  2. Error Handling: Managing errors in callback-based code can be challenging, as you need to ensure that errors are properly propagated and handled at each level of the callback chain.

  3. Inversion of Control: Callbacks can lead to an inversion of control, where the flow of the program is determined by external functions, making it harder to follow the logic and debug.

     // Function that simulates an asynchronous operation
     function fetchData(url, callback) {
       console.log(`Fetching data from ${url}...`);
    
       // Simulate a delay with setTimeout
       setTimeout(() => {
         const data = `Data from ${url}`;
         // Call the callback function with the fetched data
         callback(data);
       }, 2000);
     }
    
     // Callback function to process the data
     function processData(data) {
       console.log("Processing:", data);
     }
    
     // Inversion of control: fetchData decides when to call processData
     fetchData("https://api.example.com", processData);
    
  4. Complexity: As the number of callbacks increases, the complexity of the code can grow, making it harder to understand and maintain, especially for developers new to the codebase.

    How can we solve Issues

    1. Promises: Promises offer a cleaner and more manageable way to handle asynchronous operations. They allow you to chain operations and handle errors more gracefully.

    2. Async/Await: Built on top of promises, async/await syntax makes asynchronous code look and behave more like synchronous code, improving readability and maintainability.

    3. Modular Code: Breaking down complex operations into smaller, reusable functions can help manage the complexity of nested callbacks.

    4. Error-First Callbacks: Using error-first callback patterns ensures that errors are handled consistently and propagated correctly through the callback chain.

       function fetchData(callback) {
         // Simulate an asynchronous operation using setTimeout
         setTimeout(() => {
           const error = null; // or new Error('Something went wrong');
           const data = { id: 1, name: 'Sample Data' };
      
           // Call the callback with error as the first argument
           callback(error, data);
         }, 1000);
       }
      
       function processData(error, data) {
         if (error) {
           console.error('Error:', error);
           return;
         }
         console.log('Data:', data);
       }
      
       // Call fetchData with processData as the callback
       fetchData(processData);
      

What is a Promise in JavaScript

A Promise is an object represents the eventual completion or failure of asynchronous operation

  • States of a Promise:

    • Pending: The initial state, neither fulfilled nor rejected.

    • Fulfilled: The operation completed successfully, and the promise has a resulting value.

    • Rejected: The operation failed, and the promise has a reason for the failure.

  • Methods:

    • then(): Used to specify what to do when the promise is fulfilled.

    • catch(): Used to handle errors or rejections.

    • finally(): Executes code after the promise is settled, regardless of its outcome.

Promises allow for chaining, where you can perform a series of asynchronous operations in sequence, making the code more readable and easier to manage.

A Promise Comes as a Solution

Promises provide a more structured and manageable way to handle asynchronous operations compared to callbacks. They help avoid callback hell by allowing you to chain operations and handle errors more gracefully.

Consume a Promise and Create a Promise:

  • Creating a Promise: You create a promise using the Promise constructor, which takes a function with two parameters: resolve and reject. You call resolve when the operation is successful and reject when it fails.

      const myPromise = new Promise((resolve, reject) => {
        const success = true; // Simulate success or failure
        if (success) {
          resolve("Operation successful");
        } else {
          reject("Operation failed");
        }
      });
    
  • Consuming a Promise: You consume a promise using the then and catch methods. then is called when the promise is resolved, and catch is called when it is rejected.

      myPromise
        .then((message) => {
          console.log(message);
        })
        .catch((error) => {
          console.error(error);
        });
    

Promise Chaining

Promise chaining allows you to perform a series of asynchronous operations in sequence. Each then method returns a new promise, allowing you to chain multiple then calls.

myPromise
  .then((message) => {
    console.log(message);
    return "Next operation";
  })
  .then((nextMessage) => {
    console.log(nextMessage);
  })
  .catch((error) => {
    console.error(error);
  })
  .finally(()=>{
    console.log("whatever happens i will execute after settled")
  })

In this example, the second then is executed after the first one, allowing for sequential operations.

Promise API

The Promise API provides several static methods to work with promises:

  • Promise.all(): Takes an array of promises and returns a single promise that resolves when all the promises have resolved or rejects if any promise is rejected.

      Promise.all([promise1, promise2])
        .then((results) => {
          console.log(results);
        })
        .catch((error) => {
          console.error(error);
        });
    
  • Promise.race(): Returns a promise that resolves or rejects as soon as one of the promises in the array resolves or rejects.

      const promise1 = new Promise((resolve, reject) => {
        setTimeout(resolve, 500, 'First');
      });
    
      const promise2 = new Promise((resolve, reject) => {
        setTimeout(resolve, 100, 'Second');
      });
    
      Promise.race([promise1, promise2]).then((value) => {
        console.log(value); // Output: 'Second'
      });
    
  • Promise.allSettled(): Returns a promise that resolves after all of the given promises have either resolved or rejected, with an array of objects describing the outcome of each promise.

      const promise1 = Promise.resolve('Resolved');
      const promise2 = Promise.reject('Rejected');
      const promise3 = Promise.resolve('Another Resolved');
    
      Promise.allSettled([promise1, promise2, promise3]).then((results) => {
        console.log(results);
        // Output: [
        //   { status: 'fulfilled', value: 'Resolved' },
        //   { status: 'rejected', reason: 'Rejected' },
        //   { status: 'fulfilled', value: 'Another Resolved' }
        // ]
      });
    
  • Promise.any(): Returns a promise that resolves as soon as any of the promises in the array resolves, or rejects if all of them reject.

      const promise1 = Promise.reject('Error 1');
      const promise2 = Promise.reject('Error 2');
      const promise3 = Promise.resolve('Success');
    
      Promise.any([promise1, promise2, promise3]).then((value) => {
        console.log(value); // Output: 'Success'
      }).catch((error) => {
        console.error(error);
      });
    

    Here's a comparison table for callbacks and promises

    | Feature | Callbacks | Promises | | --- | --- | --- | | Syntax | Function passed as an argument | Object with then, catch, and finally methods | | Readability | Can lead to callback hell with nested functions | More readable with chaining | | Error Handling | Requires manual error handling | Built-in error handling with catch | | Control Flow | Inversion of control | More structured control flow | | Flexibility | Less flexible, harder to manage | More flexible, easier to manage | | Asynchronous Handling | Basic asynchronous handling | Advanced asynchronous handling with chaining |

    Conclusion:

    Callbacks are useful for simple asynchronous tasks but can lead to complex code, while promises offer a more readable, structured, and flexible approach with built-in error handling, making them preferable for complex operations in JavaScript.

0
Subscribe to my newsletter

Read articles from ganesh v directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

ganesh v
ganesh v