# 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:

```typescript
// ❌ 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.

```typescript
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.

```typescript
@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.

```typescript
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

```plaintext
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:

```typescript
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

```typescript
// 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

```typescript
// 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](https://x.com/njolipatrick)!

## Further Reading

* [TypeORM Transactions](https://typeorm.io/transactions)
    
* [PostgreSQL Explicit Locking](https://www.postgresql.org/docs/current/explicit-locking.html)
    

*Patrick Timothy Njoli is a Mid Level Backend Engineer, working with Node.js, NestJS, and building fintech infrastructure. Connect on* [*LinkedIn*](https://linkedin.com/in/njolipatrick) *or* [*Twitter*](https://x.com/njolipatrick)*.*
