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. Here is how to 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. See Async Patterns for coordination.
- 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;
}
}
6. Python Asyncio Lock Example
import asyncio
class Account:
def __init__(self, balance: float):
self.balance = balance
async def transfer(from_acc: Account, to_acc: Account, amount: float, lock: asyncio.Lock):
async with lock:
if from_acc.balance >= amount:
from_acc.balance -= amount
to_acc.balance += amount
return True
return False
async def main():
account_a = Account(1000)
account_b = Account(500)
lock = asyncio.Lock()
# Concurrent transfers are serialized by the lock
results = await asyncio.gather(
transfer(account_a, account_b, 200, lock),
transfer(account_a, account_b, 300, lock),
transfer(account_b, account_a, 100, lock),
)
print(f"A: {account_a.balance}, B: {account_b.balance}")
print(f"Results: {results}")
asyncio.run(main())
Without the lock, concurrent transfers could read from_acc.balance before any deduction, causing negative balances. The asyncio.Lock ensures only one transfer executes at a time.
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
startTransitionfor 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
awaitblocks all concurrent code execution - Using
setTimeoutfor ordering instead of proper async sequencing - Forgetting to cancel pending requests when a component unmounts in React
- Modifying shared arrays or objects without synchronization —
pushandspliceare not atomic across await boundaries - Holding locks across network calls, which creates long wait times and potential deadlocks
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.
Q: How do I test for race conditions?
A: Write tests that run concurrent operations and assert final state. Use Promise.all to trigger parallel calls. Inject random delays with await delay(Math.random() * 100) to increase the chance of catching interleaving bugs. Run tests multiple times — race conditions are non-deterministic.
Q: What is the difference between optimistic concurrency and pessimistic locking? A: Optimistic concurrency assumes no conflict and retries on failure (CAS pattern). Pessimistic locking acquires a lock before accessing shared state (Mutex). Use optimistic concurrency when conflicts are rare. Use pessimistic locking when conflicts are frequent or retrying is expensive.
Q: Can I use Promise.all safely with shared state?
A: Only if each promise operates on independent data. If promises read and write the same variable, Promise.all does not serialize them — they interleave at await points. Use a mutex or serialize the operations with a promise chain.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
How do I detect race conditions in production?
Race conditions are hard to detect because they are non-deterministic. Signs include inconsistent state after concurrent operations, intermittent test failures that pass on retry, and data that does not match expected totals. Use structured logging with correlation IDs to trace interleaving operations. Tools like Chrome DevTools Performance tab can show when async tasks interleave.
What tools help find race conditions?
For JavaScript, use console.trace() at critical sections to log call stacks. For Python, threading.get_ident() helps identify which thread accessed shared state. For testing, frameworks like jest with --detectOpenHandles flag can surface async issues. For static analysis, TypeScript strict mode catches many stale-closure bugs that lead to race conditions.
Should I use AbortController or request IDs for cancellation?
Both approaches work but serve different purposes. AbortController actually cancels the network request, saving bandwidth. Request IDs only ignore the response — the request still completes. Use AbortController when you want to save resources. Use request IDs when you cannot cancel the underlying operation (e.g., a computation already running).
What is the difference between a mutex and a semaphore?
A mutex (mutual exclusion) allows only one thread to access a resource at a time. A semaphore allows up to N threads to access a resource concurrently. Use a mutex when only one operation should run at a time. Use a semaphore to limit concurrency to a fixed number of parallel operations (e.g., max 5 concurrent API calls).
Can race conditions happen in single-threaded JavaScript?
Yes. JavaScript is single-threaded but asynchronous. When await yields control, other microtasks can interleave between the check and the update. For example, two async functions reading and writing the same variable can produce a race condition if both read before either writes.
How do I prevent race conditions in databases?
Use transactions with the appropriate isolation level. For read-then-write, use SELECT ... FOR UPDATE (pessimistic locking) or row versions with WHERE version = X (optimistic locking). For atomic increments, use UPDATE accounts SET balance = balance + 100 WHERE id = 1 instead of reading, adding, and writing.
Related Resources
Parallelize CPU and I/O Work with ThreadPoolExecutor
Use Python's ThreadPoolExecutor for parallel I/O operations, thread-safe result collection, Future callbacks, error handling, and mixing threads with asyncio for blocking work.
RecipeConcurrent Async Tasks with asyncio.gather and Task Groups
Execute multiple async operations concurrently in Python using asyncio.gather, asyncio.TaskGroup, error handling with return_exceptions, timeouts, and semaphores for rate limiting.
PatternIdempotent Consumer Pattern
Process messages from a queue exactly once regardless of duplicates by using idempotent operations, unique identifiers, and deduplication strategies at the consumer level.
RecipeJavaScript Event Loop
Understand how the JavaScript event loop works internally and how to write non-blocking code.