π¦ JavaScript Deep Dive β Issue #28
JavaScript Race Conditions: When Correct Code Produces the Wrong Result
The bugs that depend on timing β and disappear when you try to debug them
Your JavaScript looks correct.
The API works.
There are no syntax errors.
The application works 99% of the time.
But occasionally:
- the wrong search results appear
- an older request overwrites newer data
- a button submits twice
- state suddenly jumps backward
- two updates overwrite each other
- the bug disappears when DevTools opens
Welcome to one of the most frustrating categories of JavaScript bugs:
Race conditions.
Race conditions happen when the correctness of your application depends on which asynchronous operation finishes first.
And the dangerous part?
The code involved can be completely valid.
Let’s break down why race conditions happen, how to recognize them, and the patterns experienced JavaScript developers use to prevent them.
π§ 1. What Is a Race Condition?
Imagine two asynchronous operations:
loadData("A");
loadData("B");
You started A first.
Then B.
It feels natural to imagine:
A starts
A finishes
B starts
B finishes
But asynchronous operations don’t promise that.
Reality might be:
A starts
β
B starts
β
B finishes
β
A finishes
If both operations modify the same state, the final result depends on timing.
That’s the race.
π¨ 2. The Classic Search Bug
Imagine a live search:
async function search(query) {
const response = await fetch(
`/api/search?q=${query}`
);
const results = await response.json();
displayResults(results);
}
The user types:
j
ja
jav
java
javascript
Your application might launch several requests.
Suppose:
"java" request
β
800ms
"javascript" request
β
200ms
The newer request finishes first.
You display:
javascript results
Great.
Then the older "java" request finally returns.
Your code does:
displayResults(results);
Now the screen displays:
java results
even though the input says:
javascript
Every request worked correctly.
Every callback worked correctly.
The ordering assumption was wrong.
π¨ 3. Async Completion Order Is Not Start Order
This is one of the most important async mental models.
Starting:
operationA();
operationB();
operationC();
does not imply:
A finishes
B finishes
C finishes
You might get:
C
A
B
or:
B
C
A
or:
A
C
B
unless your program explicitly coordinates them.
Think:
Start order and completion order are separate concepts.
π¨ 4. Why Race Conditions Feel Random
They’re usually not truly random.
They’re timing-dependent.
Timing can change because of:
- network latency
- CPU load
- server load
- caching
- browser scheduling
- device performance
- database response time
- user interaction speed
That’s why the same code might:
work locally
fail in production
or:
work 999 times
fail once
The underlying bug was always there.
Production simply exposed the timing required to trigger it.
π¨ 5. The “Latest Request Wins” Pattern
Return to our search example.
One solution is to assign each request an ID.
let latestRequest = 0;
async function search(query) {
const requestId = ++latestRequest;
const response = await fetch(
`/api/search?q=${query}`
);
const results = await response.json();
if (requestId !== latestRequest) {
return;
}
displayResults(results);
}
Now imagine:
Request 1 β "java"
Request 2 β "javascript"
Request 2 finishes.
2 === latestRequest
Display it.
Later Request 1 finishes.
1 !== latestRequest
Ignore it.
The older result cannot overwrite the newer result.
π¨ 6. Better Yet: Cancel Work That No Longer Matters
Ignoring stale results solves correctness.
But the old request still consumes resources.
For browser requests, AbortController can cancel obsolete work.
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
try {
const response = await fetch(
`/api/search?q=${query}`,
{
signal: controller.signal
}
);
const results = await response.json();
displayResults(results);
} catch (error) {
if (error.name !== "AbortError") {
throw error;
}
}
}
Now when another search begins:
controller?.abort();
the previous request is cancelled.
This gives you:
β fewer unnecessary requests
β fewer stale responses
β less wasted processing
β clearer ownership
Cancellation is a powerful concurrency tool.
π¨ 7. Race Conditions Aren’t Just About fetch()
Consider:
let balance = 100;
async function withdraw(amount) {
const current = balance;
await somethingAsync();
balance = current - amount;
}
Now:
withdraw(20);
withdraw(30);
Both operations could read:
balance = 100
Then one calculates:
100 - 20 = 80
and the other:
100 - 30 = 70
Depending on which finishes last, the final balance might be:
80
or:
70
But logically it should be:
50
The problem is that the operation:
READ
MODIFY
WRITE
was interrupted by asynchronous work.
π¨ 8. await Creates Interleaving Opportunities
Look closely:
const current = balance;
await somethingAsync();
balance = current - amount;
Before the await, you read state.
After the await, you assume the state hasn’t changed.
That’s dangerous.
Whenever you see:
readSharedState();
await something();
writeSharedState();
ask:
Could something else modify this state while we’re waiting?
This is one of the best race-condition detection habits you can develop.
π¨ 9. Don’t Hold Assumptions Across await
Suppose:
const user = getCurrentUser();
await loadPermissions();
showDashboard(user);
What if the user logs out during:
await loadPermissions();
Your user variable still references the old state.
The world may have changed while you were waiting.
A safer design might re-check:
await loadPermissions();
const user = getCurrentUser();
if (!user) return;
showDashboard(user);
A useful mental model:
Every
awaitis a point where the world may change.
π¨ 10. Duplicate Submission Is a Race Condition Too
Consider:
button.addEventListener("click", async () => {
await submitOrder();
});
The user double-clicks.
Now:
submitOrder()
submitOrder()
Two orders might be created.
One simple UI-level defense:
button.addEventListener("click", async () => {
if (button.disabled) return;
button.disabled = true;
try {
await submitOrder();
} finally {
button.disabled = false;
}
});
But remember:
Client-side protection isn’t enough for critical operations.
For payments, orders, account actions, and similar operations, the server should also protect against duplicate processing.
π¨ 11. Idempotency Makes Retries Safer
Suppose your application sends:
CREATE ORDER
The server creates the order.
But the network drops before the client receives the response.
The client doesn’t know whether the request succeeded.
Should it retry?
If it does:
CREATE ORDER
CREATE ORDER
you might create two orders.
One solution is an idempotency key:
await fetch("/api/orders", {
method: "POST",
headers: {
"Idempotency-Key": orderRequestId
},
body: JSON.stringify(order)
});
The server can recognize repeated requests with the same key and avoid processing the operation twice.
This is especially important for:
- payments
- purchases
- bookings
- account creation
- external API calls
π¨ 12. Debouncing Reduces Races β But Doesn’t Guarantee Ordering
For search input, you might debounce:
let timer;
input.addEventListener("input", event => {
clearTimeout(timer);
timer = setTimeout(() => {
search(event.target.value);
}, 300);
});
This reduces the number of requests.
Useful.
But don’t confuse:
fewer requests
with:
correct request ordering
Two requests can still overlap.
Debouncing is an optimization.
Cancellation or stale-result protection handles correctness.
π¨ 13. Promise.all() Is Not a Race Condition
Consider:
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts()
]);
The operations may complete in either order.
But Promise.all() preserves the input ordering of its results.
So:
user
corresponds to:
fetchUser()
and:
posts
corresponds to:
fetchPosts()
regardless of which request finishes first.
Concurrency itself isn’t the problem.
The problem is uncoordinated access to shared state or ordering-dependent behavior.
π¨ 14. Promise.race() Is Literally About First Completion
JavaScript also gives us:
Promise.race()
Example:
const result = await Promise.race([
fetchData(),
timeout(5000)
]);
Whichever Promise settles first determines the result.
This can be useful for:
- timeouts
- fallback strategies
- competing data sources
But the losing operations don’t automatically stop.
That’s important.
Promise.race() chooses a winner.
It does not necessarily cancel the losers.
π¨ 15. Shared Mutable State Is the Danger Zone
Consider:
const state = {
user: null,
loading: false,
results: []
};
Now imagine multiple async functions modifying it:
loadUser();
loadResults();
refreshSession();
updatePreferences();
Each function might be individually correct.
But collectively?
They may interact in unexpected ways.
Race conditions become much easier to create when you combine:
shared state
+
mutation
+
async operations
Reducing shared mutable state reduces the number of possible races.
π¨ 16. Serialize Operations When Order Matters
Sometimes operations genuinely must happen one after another.
Example:
await saveDraft();
await publishArticle();
await sendNotification();
Here order is meaningful.
Publishing before saving could be wrong.
Sending the notification before publishing could also be wrong.
Don’t use concurrency simply because you can.
Compare:
await Promise.all([
saveDraft(),
publishArticle(),
sendNotification()
]);
That might be faster.
It might also be logically incorrect.
Performance never overrides correctness.
π¨ 17. A Simple Queue Can Protect Ordering
Suppose tasks must execute sequentially.
One lightweight pattern:
let queue = Promise.resolve();
function enqueue(task) {
queue = queue.then(() => task());
return queue;
}
Then:
enqueue(() => save("A"));
enqueue(() => save("B"));
enqueue(() => save("C"));
The operations execute in sequence.
Conceptually:
A
β
B
β
C
rather than:
A ββ
B ββΌββ competing
C ββ
Sometimes the simplest race-condition fix is:
Don’t allow the operations to race.
π¨ 18. Version State When Old Work Must Become Invalid
Suppose you’re loading data for a selected project.
let version = 0;
async function selectProject(projectId) {
const currentVersion = ++version;
const project = await loadProject(projectId);
if (currentVersion !== version) {
return;
}
renderProject(project);
}
Every selection creates a new version.
Older async work becomes invalid automatically.
This pattern works well for:
- search
- navigation
- filters
- dashboards
- autocomplete
- previews
- live data interfaces
π¨ 19. Race Conditions Often Hide in Error Handling
Consider:
loading = true;
try {
await loadData();
} finally {
loading = false;
}
Looks reasonable.
But suppose two loads overlap:
Request A starts
loading = true
Request B starts
loading = true
Request A finishes
loading = false
Request B still running!
The UI now says loading is finished even though work remains.
A boolean wasn’t enough to represent the state.
One solution is a counter:
let pendingRequests = 0;
async function load() {
pendingRequests++;
try {
await loadData();
} finally {
pendingRequests--;
}
}
Then:
const loading = pendingRequests > 0;
Sometimes race conditions expose a deeper problem:
Your state model cannot represent reality accurately.
π¨ 20. Timestamps Aren’t Always the Best Ordering Mechanism
You might think:
const requestTime = Date.now();
and compare timestamps.
Sometimes that’s fine.
But sequence numbers are often simpler:
let sequence = 0;
const requestId = ++sequence;
Why?
Because your real question often isn’t:
“What exact time did this happen?”
It’s:
“Which operation is newer?”
Use the simplest representation for the actual problem.
π₯ Race Condition Example: Autocomplete Done Better
Let’s combine several ideas.
let controller = null;
let requestId = 0;
async function search(query) {
controller?.abort();
controller = new AbortController();
const currentRequest = ++requestId;
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: controller.signal
}
);
const results = await response.json();
if (currentRequest !== requestId) {
return;
}
renderResults(results);
} catch (error) {
if (error.name !== "AbortError") {
showError(error);
}
}
}
This design gives us several protections.
Cancellation
Old work is aborted:
controller?.abort();
Versioning
Only the latest request can update the UI:
if (currentRequest !== requestId) {
return;
}
Correct error handling
Cancellation isn’t treated like an application failure.
This is a much stronger design than simply:
fetch().then(renderResults);
π§© Race Condition Challenge
Predict what could go wrong before running these examples.
Challenge #1
async function updateUser(id) {
const user = await fetchUser(id);
currentUser = user;
}
updateUser(1);
updateUser(2);
What happens if user 1 loads after user 2?
Challenge #2
let count = 0;
async function increment() {
const current = count;
await delay(100);
count = current + 1;
}
increment();
increment();
What might the final value be?
Challenge #3
async function save() {
button.disabled = true;
await saveData();
button.disabled = false;
}
What happens if save() can be triggered from somewhere other than the button?
Challenge #4
let loading = false;
async function load() {
loading = true;
await fetchData();
loading = false;
}
What happens when two calls overlap?
Challenge #5
await Promise.all([
updateInventory(),
chargeCustomer(),
confirmOrder()
]);
Should these operations really run concurrently?
What dependencies exist between them?
π¦ Senior Race Condition Checklist
When asynchronous code behaves inconsistently, ask:
β Can multiple operations overlap?
β Does completion order matter?
β Is shared state being mutated?
β Am I holding stale assumptions across await?
β Can old results overwrite newer results?
β Can obsolete work be cancelled?
β Should the operation be idempotent?
β Can the same action happen twice?
β Should these operations actually be sequential?
β Does my state model represent multiple simultaneous operations?
And especially:
What happens if these operations finish in the opposite order?
That question catches an enormous number of async bugs.
π Final Thoughts
Race conditions are difficult because nothing necessarily looks wrong.
The syntax is valid.
The functions work.
The API responds.
The tests may even pass.
The bug exists in the relationship between operations over time.
That’s why strong JavaScript developers don’t only ask:
“Does this function work?”
They ask:
“What else could happen while this function is waiting?”
Every await creates an opportunity for the world to change.
Every shared mutable value creates an opportunity for competing updates.
Every asynchronous operation creates uncertainty about completion order.
Once you start designing around those realities, async JavaScript becomes far more predictable.
And those mysterious bugs that “only happen sometimes”?
They become much easier to explain.
Coming next
π Issue #29 β JavaScript API Design: Writing Functions Other Developers Actually Want to Use
We’ll explore predictable interfaces, sensible defaults, options objects, return values, errors, async contracts, immutability, naming, backward compatibility, and what makes a JavaScript API feel obvious instead of frustrating.