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

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:
Always use transactions for multi-step operations
Choose the right strategy based on your use case
Test concurrent scenarios explicitly—they will happen in production
Keep locks short to avoid performance bottlenecks
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.
