Asynchronous JavaScript: Handling Time-Sensitive Code

JavaScript is single-threaded, meaning it executes one task at a time. However, many real-world applications require handling time-sensitive operations like fetching data, processing user inputs, and managing UI updates without blocking the execution of other tasks. This is where asynchronous JavaScript comes into play.In this blog, we’ll explore how JavaScript handles asynchronous tasks and how to efficiently manage time-sensitive code using callbacks, promises, and async/await.

Introduction to asynchronous programming

Asynchronous programming is a technique that enables your program to start a potentially long-running task and still be able to be responsive to other events while that task runs, rather than having to wait until that task has finished. Once that task has finished, your program is presented with the result. Synchronous code runs sequentially, meaning each task must complete before the next one starts. Asynchronous code, on the other hand, allows tasks to execute independently without waiting for the previous one to finish.

Synchronous programming in JS

When we write this code:

console.log("hello");
console.log('greetings');
console.log('bye')
// Output
hello
greetings
bye

We should note here that the browser effectively steps through the program one line at a time, in the order we wrote it. At each point, the browser waits for the line to finish its work before going on to the next line.That makes this a synchronous program. However, it has some drawbacks, especially when dealing with time-consuming tasks like fetching data from a server or reading a large file. If such a task is included in the sequence, it will block the execution of the rest of the code until it’s finished, leading to potential delays and a bad user experience. Here comes the use of aynchronous programming.

Asynchronous Nature in JS

Asynchronous programming, allows multiple tasks to run independently of each other. In asynchronous code, a task can be initiated, and while waiting for it to complete, other tasks can proceed. This non-blocking nature helps improve performance and responsiveness, especially in web applications.

console.log("hello");
setTimeout(()=> console.log('greetings'),5000)
console.log('bye')
//Output
hello
bye
greetings

In the above output we can clearly see bye is printed before greetings. This is due to setTimeout() which introduced async nature in JS by running the console.log() after 5 seconds without blocking the console.log() of the bye . This is the non-blocking nature of asynchronous code.

We can achieve asynchronous nature in JavaScript in 3 ways:

  1. Using callbacks (used when promises were not available)

  2. Using Promises

  3. Using Async/Await

Callbacks

A callback function is a piece of code that is passed as an argument to another function. It is like a musical cue, telling the program what to do when a specific task is completed. Callback functions excel in asynchronous scenarios, where time-consuming tasks need to be handled without disrupting the overall program flow. Instead of waiting for a task to complete before moving on, callback functions allow the program to continue its execution and perform other operations. Once the task finishes, the callback function is called, and it gracefully takes over, guiding the program towards the next steps.

Let’s consider the following code:

console.log("hello")
function fun1(val){
    return val+1
}
function fun2(val){
    return val*10
}
function fun3(val){
    return val-5
}

function operations(val)
{
    const val1= fun1(val)
    const val2=fun2(val1)
    const val3=fun3(val2)

    console.log(val3)
}
operations(5) // 55

console.log("bye")

The above code operates output and gives output:

// output
hello
55
bye
// Online Javascript Editor for free
// Write, Edit and Run your Javascript code using JS Online Compiler

console.log("hello")
function func1(val,fn){
    val+=1
    fn(val)
}

function  func2(val,fn)
{
    val*=10
    fn(val)
}

function  func3(val,fn)
{
    val-=5
    setTimeout(()=>{
        fn(val)
    },3000)
}

function operations(val){
    func1(val,(val1)=>{
        func2(val1,(val2)=>{
            func3(val2,(val3)=>{
                console.log(val3)
            })
        })
    })
}

operations(5)

console.log("bye")

// output
hello
bye
55

The above code is asynchronous as we can see in the output. But the above call back chaining is called “call-back hell“ or ”pyramid of doom”. This gives rise to Promises in JS for better readability.

Promises

The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

A Promise is in one of these states:

  • pending: initial state, neither fulfilled nor rejected.

  • fulfilled: meaning that the operation was completed successfully.

  • rejected: meaning that the operation failed.

1. resolve(value): Marks the promise as fulfilled and provides a result.

2. reject(error): Marks the promise as rejected with an error.

Chaining Promises

Chaining Promises involves attaching callbacks to handle the fulfillment or rejection of the Promise. These callbacks, known as then and catch, allow you to gracefully respond to the Promise's outcome. The then callback is executed when the Promise is fulfilled, enabling you to access the resolved value and perform further actions. On the other hand, the catch callback is triggered when the Promise is rejected, giving you the opportunity to handle errors and take appropriate measures.

Let’s see how we can resolve promise:

console.log("Welcome!")

const myPromise = new Promise((resolve,reject)=>{
    setTimeout(()=>{
        resolve("promise resolved")
    },3000)
})


myPromise.then((message)=>{
    console.log(`Yay! ${message}`)
}).catch((err)=>{
    console.log(`Alas! ${err}`)
})

console.log("Bye!")

// Output
Welcome!
Bye!
Yay! promise resolved

However if we reject the promise let’s see the output:

console.log("Welcome!")

const myPromise = new Promise((resolve,reject)=>{
    setTimeout(()=>{
        reject("promise rejected")
    },3000)
})


myPromise.then((message)=>{
    console.log(`Yay! ${message}`)
}).catch((err)=>{
    console.log(`Alas! ${err}`)
})

console.log("Bye!")

// output
Welcome!
Bye!
Alas! promise rejected

As we can see clearly the reject() callback calls the .catch() where as resolve() is handled by .then(). There’s another method called .finally() which will execute irrespective of whether the promise is resolved or rejected.

Flowchart showing how the Promise state transitions between pending, fulfilled, and rejected via then/catch handlers. A pending promise can become either fulfilled or rejected. If fulfilled, the "on fulfillment" handler, or first parameter of the then() method, is executed and carries out further asynchronous actions. If rejected, the error handler, either passed as the second parameter of the then() method or as the sole parameter of the catch() method, gets executed.

To make our code more readable we use modern JS syntax of Async/Await which are also used to perform asynchronous operations.

Async/Await

There’s a special syntax to work with promises in a more comfortable fashion, called “async/await”. It’s surprisingly easy to understand and use. Let’s start with the async keyword. The word “async” before a function means one simple thing: a function always returns a promise. Other values are wrapped in a resolved promise automatically.

async function func()
{
    return 1;
}

func().then((val)=>{
    console.log(`Promise returned ${val}`)
})

// output
1

As we can see async returns a promise and we can use .then with it.

The keyword await makes JavaScript wait until that promise settles and returns its result. Remember await is always used inside an async function.

async function getBooks(){
    const options = {method: 'GET', headers: {accept: 'application/json'}};
    const response = await fetch('https://api.freeapi.app/api/v1/public/books',options)

    const books = await response.json()
    for(let i=0;i<5;i++)
    {
        console.log(`Book ${i+1}: ${books.data.data[i].volumeInfo.title}`)
    }


}

getBooks()

console.log("Loading books...")

// output
Top 5 books
Loading books...
Book 1: Practices of an Agile Developer
Book 2: Developer Testing
Book 3: Java Programming for Android Developers For Dummies
Book 4: Ask Your Developer
Book 5: AWS Certified Developer Official Study Guide

But one thing that comes to our mind is that we use .catch() in promises for error handling then how do we handle error in async/await. It’s simple we will simply use try…catch…finally inside async function to make it possible. The final two code of our blogs are as follows:

// If our api call is correct
console.log("Top 5 books")

async function getBooks(){
   try {
    const options = {method: 'GET', headers: {accept: 'application/json'}};
    const response = await fetch('https://api.freeapi.app/api/v1/public/books',options)

    const books = await response.json()
    for(let i=0;i<5;i++)
    {
        console.log(`Book ${i+1}: ${books.data.data[i].volumeInfo.title}`)
    }



   } catch (error) {
    console.log(`There is an error in fetching API`);

   }
   finally{
    console.log("I will always execute");

   }
}

getBooks()

console.log("Loading books...")

// Output:
Top 5 books
Loading books...
Book 1: Practices of an Agile Developer
Book 2: Developer Testing
Book 3: Java Programming for Android Developers For Dummies
Book 4: Ask Your Developer
Book 5: AWS Certified Developer Official Study Guide       
I will always execute

If there’s an error in API fetching:

console.log("Top 5 books")

async function getBooks(){
   try {
    const options = {method: 'GET', headers: {accept: 'application/json'}};
    const response = await fetch('https://ap44i.freeapi.app/api/v1/public/books',options)

    const books = await response.json()
    for(let i=0;i<5;i++)
    {
        console.log(`Book ${i+1}: ${books.data.data[i].volumeInfo.title}`)
    }



   } catch (error) {
    console.log(`There is an error in fetching API`);

   }
   finally{
    console.log("I will always execute");

   }
}

getBooks()

console.log("Loading books...")

// output
Top 5 books
Loading books...
There is an error in fetching API
I will always execute

As we can see when there’s an error the catch block gets executed and finally block is called always irrespective of success or failure.

Conclusion

Handling time-sensitive asynchronous operations in JavaScript is crucial for building responsive applications. While callbacks were an early solution, promises and async/await have made managing asynchronous tasks more efficient and readable. Techniques like timeouts and intervals help enforce execution constraints, ensuring optimal performance in real-time applications.

11
Subscribe to my newsletter

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

Written by

Koustav Chatterjee
Koustav Chatterjee

I am a developer from India with a passion for exploring tech, and have a keen interest on reading books.