DEV Community

Cover image for How to Avoid the Single-Threaded Trap in JavaScript
zenstok
zenstok

Posted on

How to Avoid the Single-Threaded Trap in JavaScript

JavaScript is often described as single-threaded, which means it executes one task at a time. But does this imply that every piece of code runs in complete isolation, with no ability to handle other tasks while waiting for asynchronous operations like HTTP responses or database requests? The answer is no! In fact, JavaScript’s event loop and promises allow it to handle asynchronous tasks efficiently while other code continues to run.

The truth is javascript is indeed single-threaded, however, misunderstanding how this works can lead to common pitfalls. One such trap is managing asynchronous operations like API requests, especially when trying to control access to shared resources without causing race conditions. Let's explore a real-world example and see how poor implementation can lead to serious bugs.

I encountered a bug in an application that required logging into a backend service to update data. Upon logging in, the app would receive an access token with a specified expiration date. Once this expiration date passed, we needed to re-authenticate before making any new requests to the update endpoint. The challenge arose because the login endpoint was throttled to a maximum of one request every five minutes, while the update endpoint needed to be called more frequently within that same five-minute window. It was critical for the logic to function correctly, yet the login endpoint was occasionally triggered multiple times within the five-minute interval, leading to the update endpoint failing to work. While there were times when everything functioned as expected, this intermittent bug presented a more serious risk, as it could give a false sense of security at first, making it seem like the system was operating properly._

To illustrate this example, we're using a very basic NestJS app that includes the following services:

  • AppService: Acts as a controller to simulate two variants— the bad version, which sometimes works and sometimes doesn't, and the good version, which is guaranteed to always function properly.
  • BadAuthenticationService: Implementation for the bad version.
  • GoodAuthenticationService: Implementation for the good version.
  • AbstractAuthenticationService: Class responsible for maintaining the shared state between the GoodAuthenticationService and BadAuthenticationService.
  • LoginThrottleService: Class that simulates the throttling mechanism of the login endpoint for the backend service.
  • MockHttpService: Class that helps simulate HTTP requests.
  • MockAwsCloudwatchApiService: Simulates an API call to the AWS CloudWatch logging system.

I won't show the code for all these classes here; you can find it directly in the GitHub repository. Instead, I will focus specifically on the logic and what needs to be changed for it to work correctly.

The Bad Approach:

@Injectable()
export class BadAuthenticationService extends AbstractAuthenticationService {
  async loginToBackendService() {
    this.loginInProgress = true; // this is BAD, we are inside a promise, it's asynchronous. it's not synchronous, javascript can execute it whenever it wants

    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com/login`, {
          password: 'password',
        }),
      );

      return response;
    } finally {
      this.loginInProgress = false;
    }
  }

  async sendProtectedRequest(route: string, data?: unknown) {
    if (!this.accessToken) {
      if (this.loginInProgress) {
        await new Promise((resolve) => setTimeout(resolve, 1000));
        return this.sendProtectedRequest(route, data);
      }

      try {
        await this.awsCloudwatchApiService.logLoginCallAttempt();
        const { data: loginData } = await this.loginToBackendService();
        this.accessToken = loginData.accessToken;
      } catch (e: any) {
        console.error(e?.response?.data);
        throw e;
      }
    }

    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com${route}`, data, {
          headers: {
            Authorization: `Bearer ${this.accessToken}`,
          },
        }),
      );

      return response;
    } catch (e: any) {
      if (e?.response?.data?.statusCode === 401) {
        this.accessToken = null;
        return this.sendProtectedRequest(route, data);
      }
      console.error(e?.response?.data);
      throw e;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why This Is a Bad Approach:

In the BadAuthenticationService, the method loginToBackendService sets this.loginInProgress to true when initiating a login request. However, since this method is asynchronous, it does not guarantee that the login status will be updated immediately. This could lead to multiple concurrent calls to the login endpoint within the throttling limit.
When sendProtectedRequest detects that the access token is absent, it checks if a login is in progress. If it is, the function waits for a second and then retries. However, if another request comes in during this time, it can trigger additional login attempts. This can lead to multiple calls to the login endpoint, which is throttled to allow only one call every minute. As a result, the update endpoint may fail intermittently, causing unpredictable behavior and a false sense of security when the system appears to be functioning properly at times.

In summary, the problem lies in the improper handling of asynchronous operations, which leads to potential race conditions that can break the logic of the application.

The Good Approach:

@Injectable()
export class GoodAuthenticationService extends AbstractAuthenticationService {
  async loginToBackendService() {
    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com/login`, {
          password: 'password',
        }),
      );

      return response;
    } finally {
      this.loginInProgress = false;
    }
  }

  async sendProtectedRequest(route: string, data?: unknown) {
    if (!this.accessToken) {
      if (this.loginInProgress) {
        await new Promise((resolve) => setTimeout(resolve, 1000));
        return this.sendProtectedRequest(route, data);
      }

      // Critical: Set the flag before ANY promise call
      this.loginInProgress = true;

      try {
        await this.awsCloudwatchApiService.logLoginCallAttempt();
        const { data: loginData } = await this.loginToBackendService();
        this.accessToken = loginData.accessToken;
      } catch (e: any) {
        console.error(e?.response?.data);
        throw e;
      }
    }

    try {
      const response = await firstValueFrom(
        this.httpService.post(`https://backend-service.com${route}`, data, {
          headers: {
            Authorization: `Bearer ${this.accessToken}`,
          },
        }),
      );

      return response;
    } catch (e: any) {
      if (e?.response?.data?.statusCode === 401) {
        this.accessToken = null;
        return this.sendProtectedRequest(route, data);
      }
      console.error(e?.response?.data);
      throw e;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why This Is a Good Approach:

In the GoodAuthenticationService, the loginToBackendService method is structured to handle the login logic efficiently. The key improvement is the management of the loginInProgress flag. It is set after confirming that an access token is absent and before any asynchronous operations begin. This ensures that once a login attempt is initiated, no other login calls can be made concurrently, effectively preventing multiple requests to the throttled login endpoint.

Demo Instructions

Clone the Repository:

git clone https://github.com/zenstok/nestjs-singlethread-trap.git
Enter fullscreen mode Exit fullscreen mode

Install the Necessary Dependencies:

cd nestjs-singlethread-trap
npm install
Enter fullscreen mode Exit fullscreen mode

Run the Application:

npm run start
Enter fullscreen mode Exit fullscreen mode

Simulate requests:

  • To simulate two requests with the bad version, call:
http://localhost:3000/simulate-2-requests-bad-version
Enter fullscreen mode Exit fullscreen mode

To simulate two requests with the good version, call:

http://localhost:3000/simulate-2-requests-good-version
Enter fullscreen mode Exit fullscreen mode

Conclusion: Avoiding JavaScript's Single-Threaded Pitfalls

While JavaScript is single-threaded, it can handle asynchronous tasks like HTTP requests efficiently using promises and the event loop. However, improper handling of these promises, particularly in scenarios involving shared resources (like tokens), can lead to race conditions and duplicate actions.
The key takeaway is to synchronize asynchronous actions like logins to avoid such traps. Always ensure that your code is aware of ongoing processes and handles requests in a way that guarantees proper sequencing, even when JavaScript is multitasking behind the scenes.

If you haven't already joined the Rabbit Byte Club, now is your chance to hop into a thriving community of software enthusiasts, tech founders, and non-tech founders. Together, we share knowledge, learn from each other, and prepare to build the next big startup. Join us today and be part of an exciting journey towards innovation and growth!

Top comments (0)