Marcio Cunha

Optimistic and Pessimistic Concurrency in Node.js APIs and PostgreSQL

Learn how to manage data conflicts in high-volume systems using Node.js and PostgreSQL. Discover when to apply row-level locks with SELECT FOR UPDATE or version-based control.

Marcio Cunha12 min
Also available in:EspañolPortuguês
Summary
  • High-volume systems face simultaneous disputes over the same record that cause data corruption if left uncontrolled.
  • Pessimistic locking uses SELECT FOR UPDATE to lock database rows until the transaction ends, ensuring total safety under high contention.
  • Optimistic concurrency control assumes conflicts are rare and validates changes via version columns or timestamps to reject stale writes.
  • Deadlocks occur when two transactions lock resources in crossed orders, requiring strict access sorting and failure handling strategies.
  • Choosing the right transaction isolation level, such as Read Committed versus Repeatable Read, balances performance and consistency.

The Concurrency Challenge in High-Load Systems

When thousands of users attempt to update the same record in an API simultaneously, standard web systems collapse without strict concurrency control. In practice, this means two clients could read a bank account balance or a product stock level at the exact same time, updating values based on information that became obsolete seconds later. This phenomenon creates race conditions, data corruption, and severe financial losses for enterprise applications.

To solve this problem in the modern backend ecosystem, developers combine the asynchronous flexibility of Node.js with the transactional robustness of PostgreSQL. However, designing resilient architectures requires deep architectural choices on how to manage simultaneous data access. The core decision revolves around two opposing philosophies of software engineering: assuming conflicts will constantly happen or betting they are rare and easy to fix later.

Pessimistic Locking with SELECT FOR UPDATE

Pessimistic locking starts from the premise that conflicts are inevitable and highly probable in high-traffic systems. When a Node.js application needs to ensure no other process touches critical data, it executes a SQL query using the SELECT ... FOR UPDATE clause. In practice, this instruction tells PostgreSQL to physically lock those specific rows in the table until the current transaction fully finishes with a commit or rollback command.

Implementing this approach in popular frameworks or using the native driver requires extreme care regarding request waiting times. If a transaction takes too long to release the lock, other requests in the queue start piling up, spiking API response times and exhausting the database connection pool. Below is a practical example using the Node.js ecosystem with Prisma:

async function updateStockPessimistic(productId, desiredQuantity) {return await prisma.$transaction(async (tx) => {const [product] = await tx.$queryRawUnsafe(`SELECT id, stock FROM products WHERE id = $1 FOR UPDATE`, productId);if (product.stock < desiredQuantity) {throw new Error('Insufficient stock');}await tx.product.update({where: { id: productId },data: { stock: product.stock - desiredQuantity }});return { success: true };});}

Optimistic Concurrency Control (OCC)

Optimistic concurrency control takes the opposite stance: it assumes multiple users will hardly ever modify the exact same record at the same time. Instead of locking the database preventively—which hurts performance—the system allows reading and processing to happen freely. The moment of truth happens only at write time, where the database checks if the data has been modified by third parties since the initial read.

To make this work in practice, tables gain an extra control column, usually named version or a timestamp like updated_at. When the API attempts to update the record, it passes the version it originally read; if the database finds a different version during the UPDATE, it means concurrency occurred, the change is rejected, and the application must react. Here is how to structure this logic in Node.js:

async function updateBalanceOptimistic(userId, additionalAmount, currentVersion) {const result = await prisma.user.updateMany({where: {id: userId,version: currentVersion},data: {balance: { increment: additionalAmount },version: { increment: 1 }}});if (result.count === 0) {throw new Error('Concurrency conflict detected. The record was modified by another process.');}return { success: true };}

Mitigating Deadlocks in Transactional Systems

A deadlock occurs when two concurrent transactions lock cross-resources and wait indefinitely for each other to release access. Transaction A locks record 1 and wants record 2, while transaction B locks record 2 and wants record 1. PostgreSQL detects this situation after a few seconds and forcibly interrupts one of the transactions, throwing an error that the Node.js application must catch and handle properly.

To mitigate deadlocks in high-volume environments, the golden rule is to standardize the order of resource access across the entire API codebase. If all application routes always update records in the exact same numerical sequence of IDs—regardless of the order the user sent requests—the chance of a deadlock drops drastically. Furthermore, keeping transactions as short as possible reduces the vulnerability window where locks remain active.

The chosen transaction isolation level in PostgreSQL dictates how a transaction views changes made by other transactions in real time. The default level, Read Committed, ensures dirty reads do not happen, but allows the same query to return different values if executed twice within the same transaction, a phenomenon known as non-repeatable read. On the other hand, the Repeatable Read level ensures absolute consistency throughout the transaction, but dramatically increases the risk of serialization failures under heavy load.

In practice, choosing the correct isolation level is a constant balancing act between strict data consistency and API throughput. Financial systems demand higher isolation levels, while social networks tolerate slightly outdated reads in exchange for lower latencies. Understanding these trade-offs prevents the database from becoming the main performance bottleneck as company infrastructure scales.

Retry Strategies and Failure Recovery

When failures occur due to optimistic concurrency or resolved deadlocks, the Node.js API should not simply return a generic server error to the end user. The backend architecture must provide intelligent retry mechanisms, known as retry policies. In practice, this means the persistence layer intercepts the specific database error, waits a few milliseconds with randomized jitter, and attempts to execute the transaction again transparently.

This technique, combined with exponential backoff algorithms, prevents hundreds of failed requests from hitting the database simultaneously in a destructive cascading effect. Implementing a robust retry policy ensures temporary traffic spikes are absorbed by the application without human intervention, raising the operational resilience of the distributed system to professional standards.

Final Considerations

Efficient concurrency management in Node.js APIs connected to PostgreSQL requires much more than just writing correct SQL queries. It involves deeply understanding the physical behavior of the database, anticipating contention scenarios, and consciously choosing between optimistic and pessimistic approaches based on the business domain. By applying consistent locking strategies, version control, and retry policies, engineers can build resilient systems capable of supporting massive loads without compromising data integrity.

The future of high-scale backend engineering depends on the ability to design architectures resilient to transient failures. Mastering the concepts of transactional isolation and deadlock mitigation transforms developers into professionals capable of sustaining the growth of large technology products with safety, predictability, and high operational performance.