Phase 3 · Databases & Persistent DataModule 19~66 min read

Transactions & Concurrency

Preserve multi-step invariants with transactions, isolation, row locks, optimistic concurrency, and correct retry boundaries.

What you'll learn

Transactions make a group of writes succeed or fail as one unit, while concurrency determines what overlapping requests can observe. Define invariants first, use one checked-out client, and choose locking deliberately.

By the end of this lesson, you'll be able to:

  • Wrap multi-step invariants in a transaction
  • Use one client from BEGIN through COMMIT
  • Prevent lost updates
  • Retry only safe transient failures

Core mental model

Node.js becomes easier when you separate the JavaScript language from the runtime and the operating-system capabilities it exposes. Use this table as a decision guide.

ConceptWhat it meansDecision rule
AtomicityAll operations commit or all roll backUse it when partial success violates an invariant
IsolationHow concurrent transactions observe one anotherChoose the weakest level that protects the use case
Optimistic concurrencyA version predicate rejects stale writesUse when conflicts are uncommon and retries are acceptable

Professional workflow

Build and verify Node.js programs from the terminal in small, observable steps.

  1. Define the transaction boundary boundary: inputs, outputs, invariants, ownership, and expected failures.
  2. Design the data or message contract before choosing implementation details.
  3. Implement the smallest correct path with dependencies passed explicitly.
  4. Add validation, failure translation, cleanup, and concurrency behavior.
  5. Verify the boundary with realistic data and at least one adversarial case.
  6. Measure or observe the behavior before optimizing or extracting abstractions.

Keep the feedback loop short

Run the smallest useful command after every meaningful change. Read the complete error message before editing again, and keep inputs and outputs visible while you learn.

Guided code lab

Run work on one client

A transaction belongs to one connection; rollback happens before release.

transaction.js
export async function inTransaction(work) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const value = await work(client);
    await client.query('COMMIT');
    return value;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

Reject a stale update

The version predicate exposes concurrent modification instead of silently overwriting it.

optimistic-update.sql
UPDATE tasks
SET title = $1, version = version + 1
WHERE id = $2 AND owner_id = $3 AND version = $4
RETURNING id, title, version;

Production practice

Contract

The service names the invariant; the transaction owns every read and write needed to preserve it.

Verification

Run two concurrent clients against one row and prove final state, conflict response, rollback, and retry limits.

Operations

Keep transactions short, avoid remote calls inside them, observe deadlocks, and cap retries with jitter.

Common failure mode

Using pool.query() between BEGIN and COMMIT may run statements on different connections, so the code only looks transactional.

Independent workshop

Implement an atomic task transfer with a per-owner quota.

Your finished workshop must include:

  • Explicit invariant
  • Single-client helper
  • Locking or version strategy
  • Conflict response
  • Concurrent test
  • Bounded retry

Definition of done

Run the happy path and at least two edge cases, keep responsibilities separated, and add a short README explaining how to run the program.

Recap & quick check

Key takeaways

  • Transactions protect invariants
  • One transaction uses one connection
  • Isolation is a product decision
  • Conflicts must be visible
  • Retries need safe boundaries

Quick check

1. Where must transaction queries run?

2. What detects a stale optimistic write?

3. What should stay outside a transaction?

Next: Repositories, Migrations & Data Access