跪拜 Guibai
← Back to the summary

Race Conditions in JavaScript Are Not About Threads

Some time ago, I wrote an article discussing the differences between concurrency, parallelism, and race conditions. One conclusion was particularly important:

A race condition is not a mode of execution, but a type of bug that can occur during concurrent processes.

But understanding it to this point is still not enough. Because when writing actual business code, we often encounter a question:

JavaScript is clearly single-threaded, so why do "race conditions" still occur?

In this article, we won't talk about too many abstract concepts. We'll start with a very common search box and explain this matter clearly.

1. Starting with a Very Ordinary Search Box

Suppose we write the following code:

async function search(keyword) {
  const res = await fetch(`/api/search?q=${keyword}`)
  const data = await res.json()

  renderList(data)
}

It is called when the user enters a keyword:

input.addEventListener('input', e => {
  search(e.target.value)
})

It looks completely fine.

Now the user first enters:

vue

So request A is sent.

Immediately after, they enter:

react

So request B is sent.

From a business perspective, the result should be very clear:

The user's last input was react, so the page should ultimately display the results for react.

But network requests have one characteristic:

The one sent first is not guaranteed to return first!!

The following situation may occur:

Time ───────────────────────────────→

Request A: vue
├───────────────────────────────● Returns

        Request B: react
        ├────────────● Returns

That is to say:

A is sent first
B is sent later

But:

B returns first
A returns later

So the page undergoes this change:

B returns
↓
Page displays react

A returns
↓
Page is changed back to vue

The final page displays:

vue

But the user's last input was clearly:

react

A bug has appeared.

This is a very typical: Race Condition, $\color{red}{race condition.}$

2. What Exactly is the "Race" in a Race Condition?

Many people, upon first hearing "race condition," easily interpret it as:

Two pieces of code run simultaneously, so a race occurs.

This understanding is not accurate.

In frontend development, a large number of race condition problems do not actually involve "two pieces of JavaScript executing at the same time."

The real problem is:

Multiple tasks all want to modify the same result, and the final outcome depends on an uncontrollable order of completion.

It can be summarized into a very simple formula:

Multiple tasks
   +
Modifying the same state
   +
Uncertain order of completion
   ↓
Race condition risk

Taking the search request just now as an example:

Request A ─┐
        ├──→ Search result list
Request B ─┘

Both A and B possess the power to:

list = data

That's where the problem lies.

3. A Coffee Shop Analogy

Let's switch to an example from daily life.

At 9:00 AM, you say to your colleague Xiao Wang:

Help me buy an Americano.

A minute later, you suddenly change your mind.

So you say to Xiao Li:

Forget it, I want a latte.

Obviously, what you really want to drink now is:

Latte

It turns out the coffee shop Xiao Li went to is closer.

5 minutes later, the latte arrives.

After another 15 minutes, Xiao Wang finally returns.

Then he puts the Americano on your desk.

If the program's rule is:

Whoever returns last gets their way.

Then what you end up with is:

Americano

But the truly reasonable rule should be:

Whoever represents the user's latest intent gets their way.

Here emerges the core statement about race conditions:

Finishing later does not mean it is newer.

Similarly:

The task that finishes last does not necessarily have the right to write the final state.

4. Why Does JavaScript, Being Single-Threaded, Also Have Race Conditions?

This is a question many people have.

The JavaScript main thread can indeed be simply understood as:

Only one piece of JavaScript code executes at a time.

But the problem is: Asynchronous tasks do not need to complete in the order they were initiated.

You can think of the JavaScript main thread as the cashier at a milk tea shop. But this shop has only one cashier.

So:

Customer A
↓
Cashier takes the order

Customer B
↓
Cashier takes the order

Orders are indeed taken one by one.

But when the kitchen makes the drinks:

Order A: Bubble tea, 8 minutes

Order B: Americano, 2 minutes

The result is very likely:

B is made first
A is made later

JavaScript's asynchronous programming is similar.

For example:

const data = await request()

When execution reaches await, it just means:

The current async function pauses and waits.

It does not mean:

The entire JavaScript world pauses.

During its waiting period:

The user can still click

watch can still trigger

New requests can still be sent

Routes can still change

Therefore:

Request A is waiting
      ↓
Request B starts again

Two asynchronous tasks overlap.

So there is one sentence very worth remembering:

JavaScript's single thread limits how code is executed; race conditions discuss the timing relationship between asynchronous tasks.

These two things are not contradictory.

5. Why Can't await Solve Race Conditions?

Many people, when they first start writing async/await, develop an illusion:

const data = await request()

state.value = data

Since await is used, doesn't it look like things are executed "one by one"?

No.

Let's look at a simple example:

async function load(keyword) {
  const data = await request(keyword)

  state.value = data
}

Then call it rapidly:

load('vue')
load('react')

What actually forms might be:

load('vue')
   │
   ├── await ───────────────────┐
   │                            │
load('react')                   │
   │                            │
   ├── await ───────┐           │
                    │           │
                    ● react completes │
                                │
                                ● vue completes

Inside each load(), execution is indeed sequential.

But:

There is no sequential relationship between the two load() calls.

So:

await solves the problem of "how to write asynchronous code to look like synchronous code."

It does not solve:

"Who should win among multiple asynchronous tasks."

This is a crucial step in understanding race conditions.

6. To Judge a Race Condition, Just Ask Three Questions

In the future, when you see asynchronous code, you don't need to immediately think about Event Loop, microtasks, or macrotasks.

First, ask three questions.

I call it:

The Race Condition Triangle

        Multiple tasks
           ▲
          / \
         /   \
        /     \
       /       \
Shared state ───── Uncontrollable completion order

First question: Are there multiple tasks existing simultaneously?

For example:

Request A hasn't finished yet
Request B has already started

If not, there's no talk of competition.

Second question: Will they modify the same state?

For example:

list.value = data

Both requests will eventually write to:

list.value

So shared state exists here.

And shared state is not just API data.

It can also include:

loading
error
currentPage
selected
form
store

Third question: Can the completion order be guaranteed?

If the answer is:

No

Then the race condition triangle is basically formed.

In the future, when troubleshooting asynchronous bugs, you can first apply this model:

Multiple tasks
+
Shared state
+
Uncertain timing
=
Race condition risk

Very useful.

7. Before Solving a Race Condition, First Answer the Question of "Who Should Win"

Many developers, upon seeing a race condition, have the first reaction:

Cancel the previous request.

But this actually skips the most important step.

What you should really ask first is:

If two tasks compete, who should actually win?

For example, a search box:

Search vue
↓
Search react

We hope:

The latest operation wins

This is called:

Latest Wins

But some businesses might be exactly the opposite.

For example, "submitting an order":

Click once
Click twice
Click three times

We might hope:

After the first successful submission
All subsequent operations are ignored

That is:

First Wins

Some businesses require:

A completes
↓
B can then execute
↓
C executes after that

In this case, tasks should be queued.

So:

Solving a race condition is essentially not about choosing an API, but first defining the business rules.

8. How to Solve the Search Scenario? Give Requests a "Number Ticket"

Let's continue looking at our search scenario. Assume the business rule is already determined:

The latest request wins.

Then the simplest approach is to issue a number ticket to each request.

let requestId = 0

async function search(keyword) {
  const currentId = ++requestId

  const res = await fetch(`/api/search?q=${keyword}`)
  const data = await res.json()

  if (currentId !== requestId) {
    return
  }

  renderList(data)
}

First search:

vue

requestId = 1

Request A gets the number:

1

Second search:

react

requestId = 2

Request B gets the number:

2

Suppose B returns first:

currentId = 2
requestId = 2

They match.

This indicates:

I am still the latest request.

So the page can be updated.

Later, A returns:

currentId = 1
requestId = 2

They don't match.

This indicates:

During my waiting period, a newer request has already appeared.

So:

return

Directly discard the result.

The whole process can be drawn as:

Request A #1 ─────────────────────●
                              │
                              └─ 1 !== 2
                                 Discard

       Request B #2 ───────●
                        │
                        └─ 2 === 2
                            Update page

What's truly important here is not the few lines of requestId code.

It's the design philosophy behind it:

After an asynchronous task completes, it should not directly modify the state.

It should first check: Do I currently have the qualification to write?

9. Understanding requestId as a "Version Number"

Actually, requestId has a more general name:

Version number.

For example:

First operation  version = 1

Second operation  version = 2

Third operation  version = 3

When an asynchronous task returns, it just needs to ask:

Am I processing the current version?

If not:

Discard

This philosophy will be encountered in many places later on.

The name might be:

requestId
version
sequence
token
generation

The names are different.

The philosophy is the same:

Old tasks cannot modify new state.

10. What Does AbortController Solve?

The previous version number solution can already guarantee correct results.

But there's still one problem:

Although the old request can no longer update the page, it might still be executing.

If the request is already useless, we can further cancel it.

let controller

async function search(keyword) {
  controller?.abort()

  controller = new AbortController()

  try {
    const res = await fetch(
      `/api/search?q=${keyword}`,
      {
        signal: controller.signal
      }
    )

    const data = await res.json()

    renderList(data)
  } catch (error) {
    if (error.name === 'AbortError') {
      return
    }

    throw error
  }
}

When a new search appears:

Old request
   │
   └── abort()

New request
   │
   └── Start

But here we need to distinguish two concepts.

Version Number

Solves:

Can the old result still write to the state?

AbortController

Solves:

Is there still a need for the old task to continue executing?

So very often:

Version control
+
Canceling old tasks

can be used together.

One is responsible for:

Correctness.

One is responsible for:

Reducing meaningless work.

11. Don't Forget Loading Also Has Race Conditions

There's another place where race conditions are particularly easy to overlook.

Many people only stare at:

data.value

Actually:

loading.value

can also have race conditions.

For example:

async function load() {
  loading.value = true

  try {
    await request()
  } finally {
    loading.value = false
  }
}

If requests A and B exist simultaneously:

A starts
loading = true

B starts
loading = true

A completes
loading = false

B actually hasn't completed yet

At this point, the loading indicator has already disappeared.

So:

Shared state is not just API data.

These things are all worth being vigilant about:

data

loading

error

disabled

selected

page

They can all be competed over by multiple asynchronous tasks.

12. Can Debouncing Solve Race Conditions?

No.

This is a very common misconception.

For example, a search box uses:

debounce(search, 300)

Debouncing solves:

When the user is typing continuously, don't send so many requests.

That is:

Controlling the number of requests

But as long as two requests ultimately still overlap:

A ─────────────────●

    B ───────●

B can still return first.

So:

debounce

solves:

How many times to send

Whereas:

requestId
AbortController
queue

solve:

What to do after overlap has already occurred

These are two different problems.

13. An Important Engineering Habit: Reduce "Anyone Can Write to State"

Many complex projects become hard to maintain not because asynchrony is particularly difficult.

It's because:

Too many people are writing to the same state.

For example:

Request A ─────→ state
Request B ─────→ state
Request C ─────→ state
Request D ─────→ state

It's hard to answer:

Who exactly wrote the current value of state?

A better structure is usually:

Request A ─┐
Request B ─┼──→ Unified judgment ──→ state
Request C ─┘

Let asynchronous tasks be responsible for:

Producing results

Let unified logic be responsible for:

Deciding who is qualified to write the result

The code will be much easier to reason about.

This is also a very important engineering philosophy for race condition control.

14. The Most Annoying Thing About Race Conditions: They Often Cannot Be Reproduced Stably

Ordinary Bug:

Click
↓
Always reproduces

Not hard to solve.

The most annoying thing about race conditions is:

Click ten times
Maybe nine times it's normal

The tenth time it suddenly errors

The reason lies in:

It depends on timing.

For example, under normal circumstances:

A: 100ms
B: 500ms

No problem.

But one time the network suddenly changes:

A: 2000ms
B: 100ms

The bug appears.

So when debugging race conditions, a very useful method is:

Deliberately disrupt the timing.

For example, simulating random delays:

function randomDelay() {
  return new Promise(resolve => {
    setTimeout(
      resolve,
      Math.random() * 3000
    )
  })
}

Or directly enable network throttling in Chrome DevTools' Network tab.

Then quickly:

Enter keywords
Switch Tabs
Switch routes
Modify filter conditions

Many race condition problems that are usually hidden will quickly be exposed.

15. To Truly Understand Race Conditions, Change the Question

Previously, when seeing two requests, we might ask:

Which request was sent first?

After understanding race conditions, we should ask more:

Which request currently still has the qualification to modify the state?

The former focuses on:

Order of execution

The latter focuses on:

Business rules

A reliable program should not depend on:

Hoping A returns first

Hoping the network is normal

Hoping the server is faster

It should achieve:

Even if the task completion order is completely disrupted

The final result is still correct

16. One Layer Deeper: Race Conditions Are Actually "Time Participating in Business Decisions"

Let's go back to the search box.

The business rule we truly want to implement is:

Display the result for the user's last entered keyword

But code with a race condition actually implements:

Display the result of the last returned request

Look closely.

These two sentences are completely different in meaning.

The business rule is:

The last inputter wins

But the program becomes:

The last returner wins

So a factor that shouldn't have decided the outcome:

Network speed

secretly participates in the business decision.

This is a very essential aspect of race conditions:

The program's correctness mistakenly depends on the task completion time.

17. Finally, Remember This Judgment Method

In the future, when you see:

await xxx()

You might as well subconsciously ask yourself a few questions:

While I am waiting,
can this task be triggered again?

        ↓

If yes,
will multiple tasks modify the same state?

        ↓

If yes,
can their completion order be guaranteed?

        ↓

If not,
who should ultimately have the right to write?

Finally, it can be condensed into two formulas.

Judging race conditions:

Race condition risk
=
Multiple tasks
+
Shared state
+
Uncontrollable timing

Solving race conditions:

Race condition control
=
Clarify business rules
+
Control write qualification

18. Summary

If I hope you remember only five points from this article, I hope they are the following.

First, JavaScript being single-threaded does not mean asynchronous tasks complete in the order they were initiated.

Second, await only pauses the current async function, it does not pause the entire world.

Third, the key to a race condition is not "whether there is simultaneous execution," but whether multiple tasks are competing for the same state.

Fourth, before solving a race condition, first determine who should win.

For a search scenario, it might be:

Latest Wins

For duplicate submissions, it might be:

First Wins

Some tasks should:

Execute in a queue

Finally, the most important sentence:

Reliable asynchronous code should not depend on tasks coincidentally completing in the correct order.

Even if the completion order is completely disrupted, the program should still get the correct result.

When you start subconsciously thinking:

"After this asynchronous task returns, does it still have the qualification to modify the state?"

Your understanding of async/await has actually started to move from:

Knowing how to write asynchronous code

to:

Knowing how to control asynchronous state

This is also where the true value of understanding race conditions lies.

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

卷土的土 1 likes

Explaining race conditions in terms of completion order is very intuitive

陆枫Larry

Yes, it's easier to understand :)