Home > Web Front-end > JS Tutorial > body text

How to Avoid the Single-Threaded Trap in JavaScript

Patricia Arquette
Release: 2024-11-02 17:10:02
Original
909 people have browsed it

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;
    }
  }
}
Copy after login
Copy after login

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;
    }
  }
}
Copy after login
Copy after login

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:

@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;
    }
  }
}
Copy after login
Copy after login

Install the Necessary Dependencies:

@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;
    }
  }
}
Copy after login
Copy after login

Run the Application:

git clone https://github.com/zenstok/nestjs-singlethread-trap.git
Copy after login

Simulate requests:

  • To simulate two requests with the bad version, call:
cd nestjs-singlethread-trap
npm install
Copy after login

To simulate two requests with the good version, call:

npm run start
Copy after login

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!

The above is the detailed content of How to Avoid the Single-Threaded Trap in JavaScript. For more information, please follow other related articles on the PHP Chinese website!

source:dev.to
Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Latest Articles by Author
Popular Tutorials
More>
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template