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
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
Troubleshooting
- Race conditions appear under load: protect shared state with locks, atomics, or message passing. Reproduce with targeted stress tests.
- Deadlock between workers: establish a consistent lock acquisition order and keep critical sections short.
- Thread pool saturation: monitor queue length and rejection policy. Increase pool size only if CPU and memory allow.
- Actor mailbox grows unbounded: apply backpressure, bounded queues, and load shedding.
- Async task never completes: check for unhandled promise rejections, forgotten awaits, and infinite loops in cooperative scheduling.
Further Reading
- Official documentation: check the current reference for the framework or tool used.
- Related guides: explore the race-condition and concurrency guides for deeper coverage.
- Complementary patterns: review design patterns applicable to your technology stack.
- Public postmortems: study real incidents from teams that faced similar production issues.
Production Notes
- Deploy gradually using canary or blue-green to catch regressions early.
- Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
- Document the rollback in the runbook; test the procedure in staging at least once per quarter.
- Review structured logs with correlation IDs to trace requests end-to-end during incidents.
Key Takeaways
- Apply prevent race conditions in javascript async code when you need a practical solution for your use case.
- Monitor performance after implementation; measure latency, errors, and resource usage before and after.
- Check the Troubleshooting section for common failures; most have documented root causes with fixes.
- Keep dependencies updated and run tests in CI to prevent production regressions.
Common Production Pitfalls
- Copying the example without adapting it to real data volumes and failure modes.
- Skipping load and error-injection tests before the first production deployment.
- Hard-coding values that should be configurable per environment.
- Forgetting to add logging and monitoring at each step.
- Deploying without a rollback plan or a tested backup strategy.
- Assuming the minimal example will scale without adding caching or batching.
- Not documenting the version and configuration used in production.
- Letting the recipe sit unchanged when dependencies or scale evolve.
Frequently Asked Questions
How is this different from a deadlock?
Race conditions produce incorrect results from concurrent access. Deadlocks occur when threads block each other indefinitely waiting for resources.
Do I need locks in single-threaded JavaScript?
JavaScript is single-threaded but async operations interleave. State can still be corrupted between await points.
How do I test for race conditions?
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.
What is the difference between optimistic concurrency and pessimistic locking?
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.
Can I use Promise.all safely with shared state?
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.
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.
RecipeThread-Safe Collections: Blocking Queues and Concurrent Maps
How to safely share collections between threads using concurrent data structures—blocking queues, maps, lists, and atomic counters—in Java, Python, and C++.