Skip to content
SP StackPractices
intermediate By Mathias Paulenko

Prevent Race Conditions in JavaScript Async Code

Identify and fix race conditions in asynchronous JavaScript using proper sequencing, atomic operations, locks, and Promise patterns for predictable concurrent execution

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Prevent Race Conditions in JavaScript Async Code

Race conditions occur when multiple async operations access shared state without proper coordination, leading to non-deterministic behavior. This recipe covers identifying, preventing, and fixing race conditions in JavaScript using atomic updates, proper Promise sequencing, and lock patterns.

When to Use This

  • Multiple API calls update the same state or DOM elements
  • Cached data becomes stale or inconsistent under concurrent access
  • Debounced inputs trigger overlapping network requests with unpredictable ordering

Problem

A search component fires a new request on every keystroke. If results arrive out of order, the UI shows stale data from a previous query.

Solution

1. Request Cancellation with AbortController

// search/SearchService.ts
class SearchService {
  private abortController: AbortController | null = null;

  async search(query: string): Promise<unknown[]> {
    // Cancel previous request
    this.abortController?.abort();
    this.abortController = new AbortController();

    const response = await fetch(`/api/search?q=${query}`, {
      signal: this.abortController.signal,
    });

    return response.json();
  }
}

2. Atomic State Updates

// counter/AtomicCounter.ts
class AtomicCounter {
  private value = 0;
  private queue = Promise.resolve();

  increment(): Promise<number> {
    this.queue = this.queue.then(async () => {
      // Read current value
      const current = this.value;
      // Simulate async work
      await delay(10);
      // Only update if value hasn't changed
      if (this.value === current) {
        this.value = current + 1;
      }
      return this.value;
    });

    return this.queue;
  }

  getValue(): number {
    return this.value;
  }
}

3. Debounce with Latest-Only Execution

// hooks/useLatestQuery.ts
import { useCallback, useRef } from 'react';

function useLatestQuery<T>() {
  const latestRequest = useRef(0);

  return useCallback(async (query: string, fetcher: (q: string) => Promise<T>): Promise<T> => {
    const requestId = ++latestRequest.current;
    const result = await fetcher(query);

    // Ignore if a newer request was made
    if (requestId !== latestRequest.current) {
      throw new Error('Stale request');
    }

    return result;
  }, []);
}

4. Mutex Lock for Critical Sections

// locks/Mutex.ts
class Mutex {
  private locked = false;
  private queue: Array<() => void> = [];

  async acquire(): Promise<() => void> {
    if (!this.locked) {
      this.locked = true;
      return () => this.release();
    }

    return new Promise((resolve) => {
      this.queue.push(() => resolve(() => this.release()));
    });
  }

  private release(): void {
    if (this.queue.length > 0) {
      const next = this.queue.shift()!;
      next();
    } else {
      this.locked = false;
    }
  }
}

// Usage
const balanceMutex = new Mutex();

async function transfer(from: Account, to: Account, amount: number): Promise<void> {
  const release = await balanceMutex.acquire();
  try {
    if (from.balance >= amount) {
      from.balance -= amount;
      to.balance += amount;
    }
  } finally {
    release();
  }
}

5. Compare-and-Swap Pattern

// storage/CASStore.ts
class CASStore<T> {
  private value: T;

  constructor(initial: T) {
    this.value = initial;
  }

  compareAndSwap(expected: T, newValue: T): boolean {
    if (this.value === expected) {
      this.value = newValue;
      return true;
    }
    return false;
  }

  getValue(): T {
    return this.value;
  }
}

How It Works

  • AbortController cancels in-flight requests when superseded
  • Atomic queues serialize operations on shared state
  • Request IDs ignore responses from outdated calls
  • Mutex locks enforce mutual exclusion in critical sections
  • CAS operations retry updates when concurrent modifications are detected

Production Considerations

  • Use React’s startTransition for non-urgent state updates to avoid UI blocking
  • Implement optimistic updates with rollback on failure for better perceived performance
  • Monitor for race condition symptoms with Sentry or similar error tracking

Common Mistakes

  • Reading state before an async operation and using the stale value after
  • Not cleaning up event listeners or timers that modify shared state
  • Assuming await blocks all concurrent code execution

FAQ

Q: How is this different from a deadlock? A: Race conditions produce incorrect results from concurrent access. Deadlocks occur when threads block each other indefinitely waiting for resources.

Q: Do I need locks in single-threaded JavaScript? A: JavaScript is single-threaded but async operations interleave. State can still be corrupted between await points.