Skip to content

CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition') - JavaScript

Overview

JavaScript's single-threaded event loop guarantees that synchronous code runs to completion without interruption, but this does not make async/await code race-free: every await is a suspension point where another callback, timer, or request handler can run and mutate shared state before the current function resumes. The classic pattern is reading a value, awaiting an I/O call (a database query, an external API call), and writing an updated value back - two concurrent invocations can interleave between the read and the write and both act on stale data. For state confined to a single Node.js process, serialize the critical section with a promise-based mutex such as the async-mutex package. For state shared across processes, workers, or server instances - the common case for a web application's database rows - push the atomicity into the datastore instead: an atomic UPDATE ... SET balance = balance - $1 WHERE id = $2 AND balance >= $1, MongoDB's findOneAndUpdate with $inc, or a distributed lock such as Redis SET key value NX.

Common Vulnerable Patterns

Read-Await-Write on Module-Level State

// VULNERABLE - the await between the read and the write is a race window
let requestCount = 0;

async function handleRequest() {
  const current = requestCount;
  await logToExternalService(current); // suspension point: another call can run here
  requestCount = current + 1;
}

// Attack: fire 1,000 concurrent calls to handleRequest().
// Result: requestCount often ends up less than 1,000 - several calls read the
// same `current` value before any of them writes the incremented result back.

Why this is vulnerable: Between reading requestCount and awaiting logToExternalService, the event loop is free to run any other pending callback, including another invocation of handleRequest. Both calls can read the same current value before either writes back, so an increment is lost even though the surrounding code is never literally executing in two places "at once."

Check-Then-Act Across an await

// VULNERABLE - check and update are separated by an await
class Wallet {
  #balance;

  constructor(balance) {
    this.#balance = balance;
  }

  async withdraw(amount) {
    if (this.#balance < amount) {
      throw new Error('insufficient funds');
    }
    await this.#logTransaction(amount); // RACE WINDOW: suspension point
    this.#balance -= amount;
  }

  async #logTransaction(amount) {
    // some awaited I/O, e.g. writing an audit log
  }
}

// Attack: two concurrent withdraw(100) calls on a Wallet with balance = 100.
// Both pass the balance check before either awaited call resumes and
// decrements the balance. Result: balance ends at -100.

Why this is vulnerable: The balance check happens before the await, but the deduction happens after it resumes. Any other async operation - including a second call to withdraw - can run to completion during that suspension, so the check is stale by the time the deduction executes.

Unguarded Promise.all Writes to Shared State

// VULNERABLE - Promise.all runs all operations concurrently with no ordering
// guarantee on the shared object they all write to
async function reserveSeats(cart, seatIds) {
  await Promise.all(
    seatIds.map(async (seatId) => {
      const seat = await db.getSeat(seatId);
      if (seat.reserved) {
        throw new Error(`seat ${seatId} already reserved`);
      }
      await db.updateSeat(seatId, { reserved: true }); // separate read/write pair
    })
  );
}

// Attack: two customers call reserveSeats() for the same seatId at the same time.
// Both getSeat() calls return reserved: false before either updateSeat() runs.
// Result: the seat is sold to both customers.

Why this is vulnerable: Promise.all starts every operation concurrently and each one performs its own unsynchronized read-then-write against the database, so nothing prevents two of those operations from interleaving on the same row.

Secure Patterns

async-mutex for an In-Process Critical Section

// SECURE - async-mutex serializes the read-modify-write across concurrent async calls
const { Mutex } = require('async-mutex');

class Wallet {
  #mutex = new Mutex();
  #balance;

  constructor(balance) {
    this.#balance = balance;
  }

  // exposed so a test can assert on the outcome, not only on the count of
  // callers that succeeded
  get balance() {
    return this.#balance;
  }

  async withdraw(amount) {
    return this.#mutex.runExclusive(async () => {
      if (this.#balance < amount) {
        throw new Error('insufficient funds');
      }
      await this.#logTransaction(amount);
      this.#balance -= amount;
    });
  }

  async #logTransaction(amount) {
    // awaited I/O, e.g. writing an audit log
  }
}

// The test below requires this file as './wallet'
module.exports = { Wallet };

Why this works: runExclusive queues callers so only one at a time executes the passed function, including across any await inside it - a second concurrent call to withdraw waits for the mutex rather than interleaving with the first. That closes the check-to-write race window inside one Node.js process. Measured on Node 24.3 over 200 runs, five concurrent withdraw(30) calls against a balance of 100 produced exactly 3 successes and a final balance of 10 every time; the unguarded version produced 5 successes and -50 every time.

runExclusive also releases on the exception path - the throw inside the callback rejects the returned promise and the mutex is freed - so a refused withdrawal does not wedge every later caller. Worth asserting rather than assuming: a hand-rolled await mutex.acquire() / release() pair without a finally deadlocks the endpoint the first time a withdrawal is refused, and every rejection test still passes.

One mutex per resource, and it must outlive the request. #mutex here is a field of the wallet it guards. A new Mutex() created inside an Express handler, or a Wallet rebuilt per request from the same database row, is a fresh mutex for every caller and excludes nobody - the code reads as locked and behaves as if it were not.

Conditional Atomic UPDATE at the Database

// SECURE - the precondition and the write happen in one atomic SQL statement,
// so no in-process lock is needed and this is correct across every server instance
async function withdraw(pool, accountId, amount) {
  const result = await pool.query(
    'UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1',
    [amount, accountId]
  );

  if (result.rowCount === 0) {
    throw new Error('insufficient funds or account not found');
  }
}

Why this works: There is no separate read at all - the database evaluates balance >= $1 and performs the subtraction as part of the same atomic statement. rowCount === 0 reliably distinguishes "the precondition failed" from "the write succeeded," without depending on any application-level lock, and it stays correct even when multiple Node.js processes or server instances handle requests concurrently.

MongoDB findOneAndUpdate with an Atomic Filter

// SECURE - the read, check, and write happen as one atomic operation on the
// document. Written for the Node driver v6+; see the note below for v5.
async function reserveSeat(db, seatId) {
  const seat = await db.collection('seats').findOneAndUpdate(
    { _id: seatId, reserved: false },
    { $set: { reserved: true } },
    { returnDocument: 'after' }   // default is 'before' - the pre-update copy
  );

  if (seat === null) {
    throw new Error('seat already reserved');
  }
  return seat;
}

Why this works: findOneAndUpdate with reserved: false in the filter only matches and updates a document that is still unreserved at the moment MongoDB executes the operation - the check and the write are the same atomic server-side operation, so two concurrent calls for the same seat cannot both succeed; the second call's filter no longer matches once the first has updated the document.

Two options decide whether the code around it is right, and both have defaults that read as the opposite of what a caller wants:

  • returnDocument defaults to 'before'. Without the option the call resolves to the document as it was before the update - reserved: false - so a caller that returns it to the client reports an unreserved seat it has just reserved.
  • The return shape changed in driver 6.0, where includeResultMetadata began defaulting to false. On v6 the call resolves to the document or to null, which is what the === null check above is written for. On v5 and earlier it resolves to a ModifyResult ({ value, ok, lastErrorObject }) - an object, and therefore always truthy - so the same check never fires and every concurrent caller is told its reservation succeeded. Confirm what your installed driver returns before trusting the guard.

Redis Distributed Lock for Cross-Process Coordination

// SECURE - a Redis-based lock coordinates a critical section across multiple
// Node.js processes or servers, where an in-process mutex cannot reach
const { createClient } = require('redis');
const client = createClient();

async function withLock(lockKey, ttlMs, fn) {
  const token = crypto.randomUUID();
  const acquired = await client.set(lockKey, token, { NX: true, PX: ttlMs });

  if (!acquired) {
    throw new Error('resource is locked, try again');
  }

  try {
    return await fn();
  } finally {
    // only release if this call still holds the lock (avoid releasing another
    // holder's lock after this one's TTL already expired)
    const script = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      else
        return 0
      end
    `;
    await client.eval(script, { keys: [lockKey], arguments: [token] });
  }
}

// usage
await withLock(`lock:account:${accountId}`, 5000, async () => {
  // critical section shared across every process holding this key
});

Why this works: SET key value NX PX ttl atomically sets the key only if it does not already exist, which is how Redis implements mutual exclusion across any number of separate processes - unlike async-mutex, which only coordinates callers inside one process. The TTL (PX) bounds how long a crashed holder can block others, and releasing with a compare-and-delete Lua script prevents one process from accidentally releasing a lock that a different process now holds after the original TTL expired. For production use, prefer a maintained library (such as redlock) over hand-rolling this pattern.

Framework-Specific Guidance

Express / Node.js

// SECURE - do not assume module-level state is safe just because Node.js is
// single-threaded; concurrent requests still interleave across every await
const { Mutex } = require('async-mutex');
const inventoryMutex = new Mutex();
const stock = new Map();

app.post('/reserve/:sku', async (req, res) => {
  const { sku } = req.params;
  const quantity = Number(req.body.quantity);

  const reserved = await inventoryMutex.runExclusive(async () => {
    const available = stock.get(sku) ?? 0;
    if (available < quantity) return false;
    stock.set(sku, available - quantity);
    return true;
  });

  if (!reserved) {
    return res.status(409).json({ error: 'insufficient stock' });
  }
  res.json({ reserved: true });
});

Why this works: Every request handler shares the same stock map across all concurrent requests in the process; wrapping the check-and-decrement in runExclusive ensures no two requests can both observe the same available value before either commits its change. For a multi-instance deployment, this in-memory map would need to move to a shared datastore with the atomic-update pattern shown above, since each Node.js process would otherwise have its own independent copy of stock.

worker_threads

// SECURE - Atomics operations on a SharedArrayBuffer, not a plain shared object
const { Worker, isMainThread, workerData } = require('worker_threads');

const sab = new SharedArrayBuffer(4);
const counter = new Int32Array(sab);

// in each worker:
Atomics.add(counter, 0, 1);

// in the main thread, after workers complete:
const finalCount = Atomics.load(counter, 0);

Why this works: worker_threads run genuinely in parallel (unlike the single-threaded main event loop), so a plain shared JavaScript object is not safe to mutate from multiple workers. Atomics.add/Atomics.load operate on a SharedArrayBuffer-backed typed array with the same hardware-level atomicity guarantees as other languages' atomic primitives, which a regular object or Map shared by reference does not provide.

Considerations

A lock is only as wide as the thing holding it. An in-process lock serialises the callers inside one instance and does nothing about a second instance, so a fix that works on a developer machine can fail the moment the service is scaled out or restarted behind a load balancer. Decide first where the shared state actually lives. If it is a database row, the serialisation has to happen in the database - a row lock, a conditional update, or a version column. If it is genuinely in-process and stays that way, an in-process lock is correct and cheaper.

Not every race is worth fixing. Two requests overwriting a display preference, or a page-view counter losing an increment, is a race with no security consequence and often no user-visible one. The ones that matter change a decision: a balance check, a quota, a one-time token being redeemed, a permission being evaluated. Fixing a benign race costs throughput and adds a failure mode, so say which category the finding is in before reaching for a lock.

Single-threaded does not mean atomic. There is no data race on a JavaScript value, so the shared-memory hazards of other runtimes do not apply - but every await is a yield point, and a read-await-write sequence can interleave with another request just as thoroughly. The window is the await, not the CPU scheduler, which makes these races easy to miss in review and entirely reproducible once you look for them.

Optimistic and pessimistic locking fail in opposite directions. A pessimistic lock makes every caller wait, so it is predictable but caps throughput and can deadlock if two paths take locks in different orders. Optimistic concurrency lets callers proceed and rejects the loser, which is faster when conflicts are rare and degenerates into wasted work and retries when they are common. Pick by how often the same row is genuinely contended, not by which is easier to write.

Retries need a bound and a backoff. A conflict-and-retry loop with neither turns a contended row into a livelock under load - every caller retrying immediately, none making progress. Cap the attempts, back off between them, and decide what the caller sees when the cap is reached. "Try again" is a legitimate answer; silently returning stale data is not.

Testing

A re-scan cannot confirm this fix. The tool sees a mutex where there was none and reports the finding closed; it cannot tell whether the exclusive section covers the whole decision, whether every caller shares the same mutex instance, or whether the guarded version still serves a legitimate request.

This is the one ecosystem where the obvious concurrency test is already deterministic, and it is worth knowing why, because the same test shape is unreliable in every threaded language. await on anything that suspends is a guaranteed yield to the event loop - not a scheduling hint - so five callers launched with Promise.all interleave the same way on every run and on every machine. Measured on Node 24.3 over 200 runs: the unguarded Wallet returned 5 successes and a balance of -50 every single time, and the async-mutex version returned 3 and 10 every single time. No barrier, no repetition, no retries.

The corollary is the trap. If the awaited call does not actually suspend, the race disappears and the test passes against broken code - so check that the await in the vulnerable path reaches something real (a query, a fetch, a timer) rather than an already-resolved promise.

// The mutex-guarded Wallet from the section above, as its own module
const { Wallet } = require('./wallet');

test('concurrent withdrawals never overdraft the account', async () => {
  const wallet = new Wallet(100);

  const attempts = Array.from({ length: 5 }, () =>
    wallet.withdraw(30).then(
      () => true,
      () => false
    )
  );

  const results = await Promise.all(attempts);
  const succeeded = results.filter(Boolean).length;

  // Exactly 3 of the 5 concurrent withdrawals of 30 succeed against 100,
  // and the balance lands on the arithmetic those 3 imply.
  expect(succeeded).toBe(3);
  expect(wallet.balance).toBe(10);
});

Assert these, with the result each should produce:

  • Accept, single call. await wallet.withdraw(30) on a balance of 100 resolves and wallet.balance is exactly 70. A mutex that never releases passes the concurrency assertion above by rejecting everything, and fails this one - or hangs, which the test runner reports as a timeout.
  • Accept, at the boundary. withdraw(100) on 100 resolves and leaves 0.
  • Reject, past the boundary. withdraw(1) on 0 rejects and leaves balance at 0 - unchanged, not merely non-negative.
  • Concurrent. The test above: 3 successes and balance === 10. Assert the balance as well as the count; a lost update can produce the right number of successes and the wrong total.
  • The mutex is released after a rejection. Immediately after a refused withdrawal, a legitimate one on the same wallet still resolves. A release() on the success path only passes every assertion above and deadlocks the next caller.
  • Every caller shares one mutex. Where the guarded object is constructed per request, the concurrency test above constructs one instance and cannot see the bug. Drive the real endpoint (autocannon, k6, or Promise.all over fetch) and assert on the persisted state.

For state shared across processes or instances, none of the above reaches the weakness - async-mutex coordinates one process. Run two Node processes against the same row and assert on the row.

Common Pitfalls

  • Assuming "JavaScript is single-threaded" means no synchronization is needed: True for purely synchronous code, but any await yields control back to the event loop, and another call can fully execute during that suspension - the race is real even without a second OS thread involved.
  • Wrapping only the check in a mutex, awaiting outside it, then writing unprotected: await mutex.runExclusive(() => checkOnly()) followed by a separate, unguarded write still leaves the gap between the check and the write open; the entire read-check-write sequence, including any await inside it, needs to be inside the same runExclusive call.
  • Using an in-process mutex for state that is actually shared across multiple Node.js processes or server instances: async-mutex only coordinates callers within one process; a horizontally scaled deployment needs datastore-level atomicity or a distributed lock (Redis SET NX), not a bigger in-process mutex.
  • A retry loop that does not re-read: A retry around a conditional update is fine, but a loop that reuses the value read before the first attempt - a variable hoisted above the loop, a cached document, a memoised balance - retries with the same stale precondition and reintroduces the exact race the atomic update was meant to close. Every iteration has to re-read the state it decides from.

Dependencies and Installation

async-mutex (npm install async-mutex) provides the promise-based Mutex/Semaphore used for in-process serialization. No additional package is required for atomic SQL updates (works with pg, mysql2, or any SQL driver already in use) or MongoDB's findOneAndUpdate (part of the official mongodb driver). For distributed locking, prefer a maintained client such as redlock over a hand-rolled Redis SET NX implementation in production.

Additional Resources