DEV Community

Cover image for How to Debug and Fix Race Conditions in Distributed Node.js Microservices
Muhammad Yasir Amin
Muhammad Yasir Amin

Posted on Originally published at yasiramin.dev

How to Debug and Fix Race Conditions in Distributed Node.js Microservices

How do you recognize race conditions in asynchronous microservices?

Race conditions in distributed Node.js microservices occur when multiple asynchronous operations or independent service instances execute concurrently and attempt to mutate shared state without coordination, leading to data corruption. Recognizing these bugs requires looking for non-deterministic test failures, duplicate processing anomalies, and inventory discrepancies that only appear under high throughput.

Because Node.js is single-threaded per event loop, developers often mistakenly assume that async/await syntax provides thread-safe execution blocks. However, across distributed microservices sharing a database or cache, an asynchronous function yields execution control back to the event loop during I/O operations (such as HTTP requests or database queries). If Service A reads a record, awaits an external API call, and then writes back an updated value, Service B can read the stale pre-update record during that exact window. This classic Read-Modify-Write cycle leads to lost updates and silent data corruption.

How can you reproduce distributed concurrency bugs locally?

Reproducing distributed race conditions locally requires scripting high-concurrency stress tests that fire simultaneous requests against your API endpoints using testing utilities like Artillery, k6, or custom JavaScript harness scripts. Since race conditions are timing-dependent, increasing thread volume or injecting artificial latency inside service methods helps expose the overlap window.

Below is a practical Node.js script using native Promise.all to simulate parallel client requests hitting an inventory checkout microservice simultaneously:

const axios = require('axios');

async function simulateConcurrentCheckout() {
  const productId = 'prod_998877';
  const requests = Array.from({ length: 20 }, () => 
    axios.post('https://api.example.com/checkout', { productId, quantity: 1 })
      .catch(err => ({ error: err.response?.data || err.message }))
  );

  const results = await Promise.all(requests);
  const successes = results.filter(r => r.status === 200);
  console.log(`Successful checkouts: ${successes.length}`);
  console.log(`Failed/Rejected checkouts: ${results.length - successes.length}`);
}

simulateConcurrentCheckout();
Enter fullscreen mode Exit fullscreen mode

How do you implement distributed locks using Redis and Redlock?

A Redis lock can coordinate cooperating workers while a lease remains valid, subject to the algorithm's timing and failure assumptions. It does not guarantee that a paused worker stops writing after its lease expires. The Redlock algorithm acquires a quorum of independent Redis masters and is intended to provide mutual exclusion within its lock-validity and timing assumptions. A successful acquisition is not an unconditional safety guarantee: process pauses, network delays and lease expiry can leave a former holder still running. Protect critical writes with a mechanism enforced by the destination, such as fencing tokens where supported, conditional database updates or idempotent operations. See the Redis distributed-lock guidance and the lease-expiry limitations below.

To implement this in Node.js, the community ioredis client combined with redlock provides locking primitives with automatic expiration (TTL) to prevent deadlocks if a service crashes mid-transaction:

const Redis = require('ioredis');
const Redlock = require('redlock');

const client1 = new Redis('redis://redis-node-1:6379');
const client2 = new Redis('redis://redis-node-2:6379');
const client3 = new Redis('redis://redis-node-3:6379');

const redlock = new Redlock(
  [client1, client2, client3],
  {
    driftFactor: 0.01, 
    retryCount: 10,
    retryDelay: 200, 
    retryJitter: 200
  }
);

async function processOrderWithLock(orderId, taskFn) {
  const resource = `locks:order:${orderId}`;
  let lock;

  try {
    // Acquire lock with a 5000ms TTL
    lock = await redlock.acquire([resource], 5000);

    // Work must complete within the lease; expiry does not cancel taskFn
    await taskFn();

  } catch (error) {
    throw new Error(`Order lock or task failed for ${orderId}: ${error.message}`);
  } finally {
    if (lock) {
      try {
        await lock.release();
      } catch (unlockError) {
        console.error('Failed to release lock:', unlockError);
      }
    }
  }
}

module.exports = { processOrderWithLock };
Enter fullscreen mode Exit fullscreen mode

How do you apply optimistic concurrency control with database versioning?

Optimistic Concurrency Control (OCC) avoids the performance bottlenecks of locking by assuming conflicts are rare; instead, it checks whether data has changed between reading and writing using a monotonically increasing version number or timestamp column. If another transaction modified the record in the interim, the update fails and triggers a retry.

The following example demonstrates how to implement OCC in a Node.js microservice using PostgreSQL and Knex.js:

async function updateAccountBalance(knex, accountId, debitAmount) {
  const maxRetries = 3;
  let attempt = 0;

  while (attempt < maxRetries) {
    attempt++;

    // 1. Read current record state including version
    const account = await knex('accounts')
      .where({ id: accountId })
      .select('balance', 'version')
      .first();

    if (!account) throw new Error('Account not found');
    if (account.balance < debitAmount) throw new Error('Insufficient funds');

    const newBalance = account.balance - debitAmount;
    const nextVersion = account.version + 1;

    // 2. Attempt conditional update matching current version
    const updatedRowsCount = await knex('accounts')
      .where({ id: accountId, version: account.version })
      .update({
        balance: newBalance,
        version: nextVersion,
        updated_at: knex.fn.now()
      });

    if (updatedRowsCount > 0) {
      return { success: true, newBalance, version: nextVersion };
    }

    // If updatedRowsCount is 0, another process modified the row. Retry loop.
    if (attempt === maxRetries) {
      throw new Error('Concurrent modification conflict exceeded max retries.');
    }

    // Exponential backoff before retry
    await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 50));
  }
}
Enter fullscreen mode Exit fullscreen mode

How do you audit transaction logs and trace distributed operations?

Auditing transaction logs and tracing distributed operations requires propagating correlation IDs across all asynchronous microservice boundaries and recording state transition logs with precise timestamps. When a race condition occurs, structured logs enriched with causation IDs allow engineers to reconstruct the exact timeline of interleaved events.

Ensure every incoming HTTP request generates or accepts a X-Correlation-ID header, attach it to your logger (such as Pino or Winston), and record every database state mutation as an immutable event in an audit log table or streaming platform like Apache Kafka.

What the lock example does not protect

The five-second lease is illustrative. If taskFn outlives it, another worker may obtain the lock while the first is still running. Use a library-supported extension strategy and respond to lease loss, but keep the final invariant in the resource that accepts the write: a unique constraint, conditional update or fencing-token check where supported. A timeout does not roll back an external side effect.

The optimistic-concurrency example is illustrative account arithmetic. Use validated integer minor units with safe integer bounds, or an appropriate decimal type; do not introduce floating-point currency calculations. A successful version check prevents a stale overwrite, but a repeated business request still needs its own idempotency key.

Verify the invariant in a controlled test

Use an isolated local endpoint and a fixed inventory fixture. With one unit available and twenty simultaneous requests, assert one accepted reservation and nineteen rejected or conflicting attempts, then inspect the final database quantity. Repeat with delayed writes, process termination and retries. Response counts alone cannot prove that the persisted state is correct.

The sample HTTP URL is a placeholder, not a live test target. These examples explain failure handling; no production throughput or incident result is claimed. Reference: Redis distributed-lock safety assumptions.


Originally published on yasiramin.dev.

Top comments (0)