Skip to main content

Command Palette

Search for a command to run...

Handling Race Conditions in NestJS with TypeORM

Quick practical guide to preventing race conditions in NestJS using pessimistic locking, atomic operations, and optimistic locking. Real-world example

Updated
5 min read
Handling Race Conditions in NestJS with TypeORM

Read Time: 5 minutes

The Problem

Race conditions are sneaky bugs that don't show up in development but can wreak havoc in production. While working on fintech infrastructure, I encountered a bug where users were getting charged twice for the same transaction. The culprit? Concurrent requests reading stale data.

Here's what typically happens:

// ❌ DANGEROUS: Race condition waiting to happen
async transferMoney(fromUserId: string, toUserId: string, amount: number) {
  // Request 1 reads: balance = $100
  const fromUser = await this.userRepository.findOne({ 
    where: { id: fromUserId } 
  });

  // Request 2 also reads: balance = $100 (still!)

  if (fromUser.balance < amount) {
    throw new Error('Insufficient funds');
  }

  // Both requests proceed...
  fromUser.balance -= amount;  // Request 1: $100 - $50 = $50
  await this.userRepository.save(fromUser);

  // Request 2 overwrites with: $100 - $50 = $50 (WRONG!)
  // Should be $0, but Request 2 never saw Request 1's update
}

Result: Lost updates, incorrect balances, or duplicate charges. This isn't theoretical—it happens under load.

Solution 1: Pessimistic Locking

The most reliable solution for critical operations: lock the rows before you work with them.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { User } from './entities/user.entity';

@Injectable()
export class WalletService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
    private dataSource: DataSource,
  ) {}

  async transferMoney(
    fromUserId: string,
    toUserId: string,
    amount: number,
  ): Promise<void> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Lock the rows - other transactions must wait
      const fromUser = await queryRunner.manager.findOne(User, {
        where: { id: fromUserId },
        lock: { mode: 'pessimistic_write' },
      });

      if (!fromUser || fromUser.balance < amount) {
        throw new Error('Insufficient funds');
      }

      const toUser = await queryRunner.manager.findOne(User, {
        where: { id: toUserId },
        lock: { mode: 'pessimistic_write' },
      });

      if (!toUser) {
        throw new Error('Recipient not found');
      }

      // Safe to update - we hold the locks
      fromUser.balance -= amount;
      toUser.balance += amount;

      await queryRunner.manager.save([fromUser, toUser]);
      await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }
}

When to use: Financial transactions, inventory updates, any critical operation where conflicts are likely.

Important: Keep locks as short as possible. Don't make API calls while holding locks.

Solution 2: Atomic Database Operations

For simple operations like incrementing counters, let the database handle it atomically.

@Injectable()
export class WalletService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {}

  // ✅ Atomic: Database does the calculation
  async updateBalance(userId: string, amount: number): Promise<void> {
    const result = await this.userRepository
      .createQueryBuilder()
      .update(User)
      .set({
        balance: () => `balance + ${amount}`,
      })
      .where('id = :id', { id: userId })
      .andWhere('balance + :amount >= 0', { amount }) // Prevent negative
      .execute();

    if (result.affected === 0) {
      throw new Error('Update failed - insufficient funds or user not found');
    }
  }

  // Increment view count
  async incrementViews(postId: string): Promise<void> {
    await this.postRepository
      .createQueryBuilder()
      .update(Post)
      .set({ viewCount: () => 'view_count + 1' })
      .where('id = :id', { id: postId })
      .execute();
  }
}

When to use: Counters, simple calculations, high-throughput operations.

Advantage: No locks, very fast, database-level atomicity.

Solution 3: Optimistic Locking

For scenarios where conflicts are rare, use version columns to detect conflicts instead of preventing them.

import { Entity, Column, VersionColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'decimal' })
  balance: number;

  @VersionColumn()
  version: number; // TypeORM auto-manages this
}

// Service with retry logic
async updateBalanceOptimistic(
  userId: string,
  amount: number,
): Promise<User> {
  const maxRetries = 3;

  for (let i = 0; i < maxRetries; i++) {
    try {
      const user = await this.userRepository.findOne({ 
        where: { id: userId } 
      });

      user.balance += amount;

      // TypeORM checks version automatically
      return await this.userRepository.save(user);
    } catch (error) {
      if (error.name === 'OptimisticLockVersionMismatchError' && i < maxRetries - 1) {
        await new Promise(r => setTimeout(r, 100 * Math.pow(2, i)));
        continue;
      }
      throw error;
    }
  }
}

When to use: Profile updates, settings changes, low-conflict scenarios.

Advantage: No locks held, good for distributed systems.

Quick Decision Guide

Is it a financial transaction or critical operation?
├─ YES → Use Pessimistic Locking
└─ NO
   │
   Is it just a counter or simple calculation?
   ├─ YES → Use Atomic Operations
   └─ NO → Use Optimistic Locking

Testing Race Conditions

Don't skip testing concurrent scenarios:

describe('Race Condition Tests', () => {
  it('handles concurrent withdrawals correctly', async () => {
    const userId = 'test-user';
    await service.createUser(userId, 1000);

    // Simulate 10 concurrent $50 withdrawals
    const withdrawals = Array(10)
      .fill(null)
      .map(() => service.updateBalance(userId, -50));

    await Promise.all(withdrawals);

    const user = await service.getUser(userId);
    expect(user.balance).toBe(500); // Should be exactly 500
  });

  it('prevents double-spending', async () => {
    const userId = 'test-user';
    await service.createUser(userId, 100);

    const results = await Promise.allSettled([
      service.updateBalance(userId, -100),
      service.updateBalance(userId, -100),
    ]);

    const successful = results.filter(r => r.status === 'fulfilled').length;
    expect(successful).toBe(1); // Only one should succeed
  });
});

Common Mistakes to Avoid

❌ Don't: Hold locks during external API calls

// BAD: External call while holding lock
const user = await queryRunner.manager.findOne(User, {
  where: { id: userId },
  lock: { mode: 'pessimistic_write' },
});

await this.externalPaymentApi.charge(user.paymentMethod, 100); // ❌

✅ Do: External calls outside transactions

// GOOD: External call first
const chargeResult = await this.externalPaymentApi.charge(paymentMethod, 100);

// Then quick transaction
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();
try {
  const user = await queryRunner.manager.findOne(User, {
    where: { id: userId },
    lock: { mode: 'pessimistic_write' },
  });

  user.balance -= 100;
  await queryRunner.manager.save(user);
  await queryRunner.commitTransaction();
} catch (error) {
  await queryRunner.rollbackTransaction();
  throw error;
} finally {
  await queryRunner.release();
}

Key Takeaways

What I learned from debugging race conditions in production:

  1. Always use transactions for multi-step operations

  2. Choose the right strategy based on your use case

  3. Test concurrent scenarios explicitly—they will happen in production

  4. Keep locks short to avoid performance bottlenecks

  5. Monitor in production for deadlocks and lock contention

Race conditions are serious, but they're preventable with the right patterns. Start by identifying your critical operations and applying these strategies where they matter most.

Have you dealt with race conditions in your projects? I'd love to hear how you solved them, reach out on Twitter!

Further Reading

Patrick Timothy Njoli is a Mid Level Backend Engineer, working with Node.js, NestJS, and building fintech infrastructure. Connect on LinkedIn or Twitter.