JavaScript Deep Dive When Correct Code Produces the Wrong Result

🟦 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 await is 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.