RA
RenovateAPIEngineering Hub
Backend & Database

Optimistic vs Pessimistic Locking in NestJS: Pick One, Not Both

A practical breakdown of optimistic versioning and SELECT FOR UPDATE in NestJS and PostgreSQL, with real code and a clear recommendation for wallets, inventory, and reservations.

AAbhishek6 min read
Backend & Database6 min read

Optimistic vs Pessimistic Locking in NestJS: Pick One, Not Both

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE

Two requests hit the same wallet row at the same time. Both read a $100 balance. One spends $80, the other spends $90, and now $170 has left an account that only had $100 in it. Every backend engineer runs into this eventually, and the fix isn't complicated — you just have to pick between two fundamentally different ways of thinking about concurrency, and most people pick the wrong one by default.

Why READ COMMITTED alone isn't enough

Postgres's default isolation level protects you from reading uncommitted data, but it does nothing to stop two transactions from reading the same row, both deciding it's safe to write, and both writing. That's the lost update problem, and it's exactly what happens in the wallet example above. Neither request did anything wrong in isolation — the bug only exists because they overlapped.

This shows up constantly: inventory counts on flash-sale items, coupon codes with a usage cap, seat reservations, anywhere two people can act on the same row within milliseconds of each other. You need something that closes that gap, and there are two standard tools for it.

Optimistic locking: bet that conflicts are rare

Optimistic locking assumes collisions are the exception, not the rule, so it skips locking the row up front and instead checks — at write time — whether anyone else touched it first. Add a version integer to the table, and every update has to match the version it read:

CREATE TABLE wallets (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL,
  balance NUMERIC(12, 2) NOT NULL,
  version INT NOT NULL DEFAULT 1
);

The NestJS side reads the balance and version with a plain SELECT, then writes with a WHERE clause pinned to that exact version:

@Injectable()
export class WalletsOptimisticService {
  constructor(private readonly pool: Pool) {}

  async deductBalanceWithRetry(walletId: string, amount: number, maxRetries = 3): Promise<void> {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      const { rows } = await this.pool.query(
        'SELECT balance, version FROM wallets WHERE id = $1',
        [walletId]
      );
      if (rows.length === 0) throw new Error('Wallet not found');

      const { balance, version } = rows[0];
      if (balance < amount) throw new ConflictException('Insufficient funds');

      const updateResult = await this.pool.query(
        `UPDATE wallets 
         SET balance = balance - $1, version = version + 1 
         WHERE id = $2 AND version = $3`,
        [amount, walletId, version]
      );

      if (updateResult.rowCount === 1) return;

      // Someone else won the race — back off with jitter and try again
      await new Promise((resolve) => setTimeout(resolve, Math.random() * 50 * attempt));
    }
    throw new ConflictException('Transaction failed due to high concurrency. Please retry.');
  }
}

If rowCount comes back as 0, another request already bumped the version, and the whole read-check-write cycle runs again. No explicit transaction block is needed here — the conditional UPDATE is a single atomic statement, so there's no gap between checking the version and writing the new one.

This holds no locks and blocks nobody, which is exactly why it's the better default. Most rows in most apps aren't under heavy contention, and paying a lock-and-wait cost on every write to guard against a collision that happens 1% of the time is the wrong trade.

Pessimistic locking: assume contention and lock the row

SELECT FOR UPDATE takes the opposite bet — it assumes contention is common enough that it's cheaper to just lock the row and make everyone else wait their turn:

@Injectable()
export class WalletsPessimisticService {
  constructor(private readonly pool: Pool) {}

  async deductBalancePessimistic(walletId: string, amount: number): Promise<void> {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');

      const { rows } = await client.query(
        'SELECT balance FROM wallets WHERE id = $1 FOR UPDATE',
        [walletId]
      );
      if (rows.length === 0) throw new Error('Wallet not found');

      const currentBalance = rows[0].balance;
      if (currentBalance < amount) {
        throw new BadRequestException('Insufficient wallet balance');
      }

      await client.query(
        'UPDATE wallets SET balance = balance - $1 WHERE id = $2',
        [amount, walletId]
      );
      await client.query('COMMIT');
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  }
}

Every other transaction trying to touch that same row just waits until this one commits or rolls back. No retries, no version columns, no client-side backoff logic — but now you're holding a row lock for the entire transaction lifetime, and if that transaction also touches other locked rows in a different order elsewhere in your codebase, you've got a deadlock waiting to happen. Sort your lock acquisition order consistently across the app if you go this route, or you'll be debugging deadlocks in production at 2am.

The actual decision

Metric Optimistic Locking Pessimistic (FOR UPDATE) Atomic SQL expression
Contention level Low to moderate High / severe High
Lock duration None Entire transaction Single statement
Deadlock risk Zero Moderate — sort lock order to mitigate Zero
Best fit Profile edits, document updates Multi-step financial transactions, flash sales Simple counter/stock decrements

Default to optimistic locking. It's simpler to reason about, it never blocks a reader, and the retry loop is maybe fifteen lines of code. Reach for SELECT FOR UPDATE only when the business logic genuinely spans multiple statements inside one transaction — checkout flows that debit a wallet and decrement stock together, seat holds during a booking flow, anything where you can't just retry the whole operation cleanly if it fails halfway through.

And if what you actually need is a single counter going up or down — page views, stock quantity, a rate limit bucket — skip both patterns entirely and just write UPDATE items SET stock = stock - 1 WHERE id = $1 AND stock >= 1. Postgres already serializes that at the statement level. No version column, no transaction block, no lock to hold. It's the option people forget because it's not exciting enough to reach for by default, but it's usually the right one.

RenovateAPI Engineering Suite

Accelerate your Backend & Database Modernization Roadmap

Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?

Frequently Asked Questions

Does optimistic locking need a transaction wrapper?

No, not for a single conditional UPDATE. The WHERE version = $3 check and the write happen atomically as one statement, so there's no window for another request to sneak in between the check and the write.

Can I mix both strategies in the same app?

Yes, and you probably should. Use optimistic locking as the default for most tables, and reserve SELECT FOR UPDATE for the handful of code paths where you genuinely can't afford a retry — a checkout that debits a wallet and decrements stock in the same transaction, for example.

Weekly Engineering Dispatch

Subscribe to RenovateAPI

Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.

Discussion (2)

A
Alex Rivera
2 hours ago

Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.

S
Sophia Chen
1 day ago

The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.

Suggested Related Articles