Asynchronous Javascript

In the field of web development, asynchronous JavaScript (JS) is the cornerstone of building responsive and efficient applications. Understanding the concept of asynchronous programming is crucial for developers to leverage the full potential of JS. In this technical blog post, we will understand the inner workings of asynchronous JS, covering topics from execution models to promises and the event loop.

How does JavaScript execute the code?

JavaScript is a single-threaded programming language, meaning it has only one call stack. This means it can do one thing at a time or execute a piece of code line by line. The call stack is a data structure that keeps track of the function calls in your program. When a script calls a function, the interpreter adds it to the call stack and then starts carrying out the function. Any functions that are called by that function are added to the call stack further up, and run where their calls are reached. When the current function is finished, the interpreter takes it off the stack and resumes execution where it left off in the last code listing.

Here's how the execution context works using a simple example:

function multiply(x, y) {
    return x * y;
}
function printSquare(x) {
    var sqr = multiply(x, x);
    console.log(sqr);
}
printSquare(5);
  1. When printSquare(5) is called, JavaScript puts printSquare function on the call stack.

  2. Inside printSquare, multiply(x, x) is called, placing multiply on the call stack, on top of printSquare.

  3. JavaScript now executes multiply, and after finding the result, it removes multiply from the call stack, leaving printSquare at the top.

  4. The result of multiply is passed back to printSquare, and the console.log statement is executed.

  5. After logging, JavaScript removes printSquare from the call stack, and since the stack is empty, the program finishes execution.

Difference between Sync and Async.

Synchronous operations block the execution until the current task is completed. Subsequent tasks must wait for the preceding ones to finish. Where, Asynchronous operations, on the other hand, allow the execution to continue without waiting for the current task to complete. This is achieved using callbacks, promises, async/await, etc.

//Synchronous Code
console.log('Start');
function doSomething() {
    console.log('Doing something synchronously');
}
doSomething(); // This is a synchronous call
console.log('End');

//Output
/*
Start
Doing something synchronously
End
*/

//Async Code
console.log('Start');
function doSomethingAsync() {
    setTimeout(() => {
        console.log('Doing something asynchronously');
    }, 1000);
}
doSomethingAsync(); // This is an asynchronous call
console.log('End');

//Output
/*
Start
End
Doing something asynchronously
*/

Convert Sync code to Async

To make synchronous code asynchronous, developers can utilize techniques such as callbacks, promises, or async/await syntax.

console.log('Start');
function blockTimeAsync() {
    setTimeout(() => {
        console.log('Block Time Finished (Asynchronously)');
    }, 2000);
}
blockTimeAsync(); // This does not block the execution
console.log('End');

//output
/*
Start
End
Block Time Finished (Asynchronously)
*/

Callback function

A callback function is a function passed into another function as an argument, which is then invoked inside the outer function to complete some kind of routine or action.

function simulateAsyncOperation(message, callback) {
    setTimeout(() => {
        console.log(message);
        callback();
    }, 1000);
}

simulateAsyncOperation('Async operation completed', function() {
    console.log('Callback function called.');
});
//Output
//Async operation completed
//Callback function called.

Drawbacks of callback

  • Inversion of control

  • Callback Hell.

Callback Hell

Callback hell, also known as "Pyramid of Doom," refers to a situation where callbacks are nested within other callbacks several levels deep. This makes the code hard to read, maintain, and debug. It often occurs in asynchronous programming, especially when handling multiple sequential asynchronous operations.

setTimeout(() => {
    console.log('First operation completed.');
    setTimeout(() => {
        console.log('Second operation completed.');
        setTimeout(() => {
            console.log('Third operation completed.');
            setTimeout(() => {
                console.log('Fourth operation completed.');
                // This nesting can go on, making the code harder to read and maintain.
            }, 1000);
        }, 1000);
    }, 1000);
}, 1000);

Inversion Of Control

Inversion of Control (IoC) is a design principle in software engineering where the control over the flow of a program is taken away from the program itself and given to a framework or container. This principle is often used to increase modularity of the program and make it more extensible.

How promises solves the Inversion of control?

Promises provide a cleaner and more structured approach to asynchronous programming, solving the problem of inversion of control. A promise represents the eventual completion or failure of an asynchronous operation, allowing developers to chain multiple asynchronous tasks together and handle errors more effectively.

function fetchData() {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve("Data received");
    }, 5000);
  });
}

fetchData()
  .then(function (data) {
    console.log(data);
  })
  .catch(function (error) {
    console.error(error);
  });

Event loop

The event loop continuously checks the call stack and determines if there's work to be done in the task queue or microtask queue. If the call stack is empty and there are tasks in the queue, it moves them to the call stack, one at a time, for execution.

Different functions in promises

Promise.resolve()

Promise.resolve() returns a Promise that is resolved with the given value. If the value is a promise, it returns the promise itself.

Example:

Promise.resolve('Success').then((value) => {
  console.log(value); // Output: Success
});

Promise.reject()

Promise.reject() returns a Promise that is rejected with the given reason.

Example:

Promise.reject('Failure').catch((reason) => {
  console.error(reason); // Output: Failure
});

Promise.all()

Promise.all() takes an iterable of promises and returns a single Promise that resolves when all of the promises in the iterable have resolved or when the iterable contains no promises. It rejects with the reason of the first promise that rejects.

Example:

Promise.all([
  Promise.resolve('Hello'),
  Promise.resolve('World')
]).then((results) => {
  console.log(results); // Output: ['Hello', 'World']
});

Promise.allSettled()

Promise.allSettled() takes an iterable of promises and returns a promise that resolves after all the given promises have either resolved or rejected, with an array of objects that each describes the outcome of each promise.

Example:

Promise.allSettled([
  Promise.resolve('Success'),
  Promise.reject('Error')
]).then((results) => {
  console.log(results);
  // Output: [{status: 'fulfilled', value: 'Success'}, {status: 'rejected', reason: 'Error'}]
});

Promise.any()

Promise.any() takes an iterable of Promise objects and, as soon as one of the promises in the iterable fulfills, returns a single promise that resolves with the value from that promise. If no promises in the iterable fulfill (if all of the given promises are rejected), then the returned promise is rejected with an AggregateError, a new subclass of Error that groups together individual errors.

Example:

Promise.any([
  Promise.reject('Error1'),
  Promise.resolve('Success'),
  Promise.reject('Error2')
]).then((result) => {
  console.log(result); // Output: Success
}).catch((error) => {
  console.log(error);
});

Promise.race()

Promise.race() takes an iterable of promises and returns a promise that resolves or rejects as soon as one of the promises in the iterable resolves or rejects, with the value or reason from that promise.

Example:

Promise.race([
  new Promise((resolve) => setTimeout(resolve, 500, 'Slow')),
  new Promise((resolve) => setTimeout(resolve, 100, 'Fast'))
]).then((value) => {
  console.log(value); // Output: Fast
});

Conclusion

Mastering asynchronous JavaScript is essential for building high-performance and responsive web applications. By understanding the execution model, differences between synchronous and asynchronous code, and leveraging the power of promises, developers can create robust and efficient applications that provide exceptional user experiences.