Unlocking the Mystery of Consistent App Performance: Asynchronous JavaScript

As a software developer, I strive to build applications that are both dependable and responsive. Early in my career, the feedback on my apps was a mixed bag. While some received high praise, others were criticized for their inconsistent performance, sometimes becoming unresponsive mid-use. We all know users have little tolerance for unresponsive programs.

The root cause was the use of purely synchronous JavaScript code. While JavaScript offers functions that appear asynchronous, it’s easy to overlook the fact that JavaScript’s runtime operates synchronously by default, a potential trap for developers. My curiosity led me to delve into this programming puzzle.

The Problem: JavaScript Synchronous Blocking

I began my exploration by examining how typical synchronous calls function, focusing on call stacks—last in, first out (LIFO) programming structures.

Call stacks operate similarly across programming languages. We use push to add function calls to the stack and pop to remove them.

Consider this example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function multiply(a, b) {
    return a * b;
}

function square(n) {
    return multiply(n, n);
}

function printSquare(n) {
    const squaredNum = square(n);
    console.log(squaredNum);
}

printSquare(4);

Here, the outermost function, printSquare, calls the square function, which then calls multiply. Functions are added to our call stack in the order they are called. As each method finishes, it’s removed from the end of the call stack (meaning multiply would be removed first).

A column labeled call stack containing cells that are labeled (from bottom to top): printSquare(4), square(4), and multiply(4, 4).
JavaScript Call Stack Example

Given the synchronous nature of the call stack, when one or more functions require significant time to complete, subsequent tasks are held back. This makes our program temporarily unresponsive until the blocking function finishes.

Common function calls that can cause these program delays include:

  • while loops with a large number of iterations (for instance, iterating from one to one trillion).
  • Network requests to external web servers.
  • Events that wait for a timer to finish.
  • Image processing tasks.

For web users, synchronous call blockages mean an inability to interact with page elements. For developers, these stalled calls render the development console unusable, preventing the examination of detailed debugging information.

The Solution: Asynchronous JavaScript Functionality

Asynchronous coding offers a solution. It allows the rest of our code to execute without waiting for an invoked function to return. Upon completion of an asynchronous task, the JavaScript runtime delivers the result to a designated function. This method removes roadblocks for both end users and developers.

JavaScript implements asynchronous functionality using several key architectural components:

An animation showing the interaction and flow between the JavaScript call stack, browser API, and task queue that support asynchronous functions.
JavaScript’s Asynchronous Flow

Any task requiring asynchronous execution (such as a timer or an external API call) is directed to the runtime engine’s browser API (web API). For each operation, the browser API creates a dedicated execution thread.

Each asynchronous JavaScript function call sent to the browser API is paired with a promise. This promise allows handler code to be triggered upon the function’s completion (successful or unsuccessful). When the function finishes—whether or not it returns a value—its associated promise is fulfilled. The function then transitions from the browser API to JavaScript’s task queue.

The linchpin in JavaScript’s asynchronous processing is its event loop. This loop constantly monitors the call stack and task queue, coordinating when completed asynchronous calls should be returned to the main call stack.

Let’s examine JavaScript’s setTimeout method to illustrate how asynchronous method handling works in practice:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function a() {
    b();
}

function b() {
    setTimeout(() => {
        console.log("After 5 secs");
    }, 5000);
}

function c() {
    console.log("Hello World");
}

a();
c();
An animation showing a detailed flow from JavaScript’s call stack into the browser API and task queue for the preceding code example.
How the Browser API Handles the setTimeout’s Function

Here’s a step-by-step breakdown of the code:

  1. a is added to the call stack.
  2. The invocation of b’s setTimeout is moved to the browser API call stack.
  3. c is added to the call stack.
  4. The console.log call within c is pushed onto the call stack.
  5. When the setTimeout method completes, it’s shifted from the browser API to the task queue.
  6. Any functions present in the call stack proceed to completion.
  7. Once the call stack is empty, the event loop moves the function associated with setTimeout from the task queue back onto the call stack.

Software engineers can significantly enhance their development capabilities by incorporating these asynchronous JavaScript methods. Now that we’ve seen how asynchronous methods are handled within the JavaScript runtime, I’ll illustrate their practical application with a brief example.

Real-world Applications: A Chatbot Example

I recently created a web-based chatbot. Synchronous behavior would have been detrimental in this context, leading to a disjointed and sluggish conversation flow. My solution maintains a natural conversation pace by using asynchronous communication with the ChatGPT external API for both sending and receiving messages.

To enable communication with the ChatGPT API, I built a simple Node.js server using OpenAI. I then utilized the asynchronous JavaScript fetch API, which relies on programmatic promises, to handle the access and processing of responses:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  fetch('http://localhost:5000/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      query: 'What is the weather like in Seattle?'
    })
  })
  .then(response => response.json())
  .then(data => {
    console.log(data);
  });

Our streamlined server makes asynchronous calls to the ChatGPT service, facilitating two-way message transmission.

Another asynchronous method I incorporated is commonly use is setInterval(). This function provides a built-in timer that can repeatedly call another function at a specified interval. I used setInterval to introduce a typing effect in the user interface, subtly indicating to the user that the chatbot is composing a response:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Creating loader function for bot
function loader(element) {
    element.textContent = '';

    // 300 ms allows for real-time responsiveness indicating other-party typing
    loadInterval = setInterval(() => {
        element.textContent += '.';

        if (element.textContent === '....') {
            element.textContent = '';
        }
    }, 300);
}

// Creating typing functionality
function typeText(element, text) {
    let index = 0;
    // 20 ms allows for real-time responsiveness to mimic chat typing
    let interval = setInterval(() => {
        if (index < text.length) {
            element.innerHTML += text.charAt(index);
            index++;
        } else {
            clearInterval(interval);
        }
    }, 20);
}

These two asynchronous blocks transform what would otherwise be a disjointed exchange into an engaging conversation for both participants. However, the responsiveness enabled by asynchronous JavaScript can be a less obvious but equally crucial factor in other scenarios.

More Asynchronous JavaScript Examples

In one instance, I was tasked with developing a custom WordPress plugin to allow users to upload large files asynchronously. I used an AJAX library to enable background file uploads, eliminating the need for page reloads. This streamlined the user experience, making the application a resounding success.

In another case, an e-commerce site was grappling with slow loading times due to a large volume of images. To address this, I implemented LazyLoading, an asynchronous JavaScript function, to load images individually rather than all at once. This significantly improved the website’s loading speed.

I also worked on a money transfer application that integrated various crypto and payment APIs. The application needed to fetch data from an external API that had a noticeable response time. To prevent the application from freezing while waiting, I implemented an asynchronous function to maintain application responsiveness during the API call, resulting in a smoother user experience.

Asynchronous methods in JavaScript unlock powerful functionality, minimizing UI slowdowns or freezes for a better end-user experience. This is why asynchronous JavaScript is vital for user retention in applications like Uber (which runs its booking and payment processes in the background), Twitter (which loads the latest tweets in real time), and Dropbox (which keeps users’ files synchronized across devices).

As a developer, you might be concerned that asynchronous JavaScript methods won’t appear on the call stack as you’d expect—rest assured, they do. Feel confident in adding asynchronous functionality to your toolkit for delivering superior user experiences.

The Toptal Engineering Blog extends its appreciation to Muhammad Asim Bilal for reviewing the technical content and code samples presented in this article.

Licensed under CC BY-NC-SA 4.0