Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsSkillsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

  1. Home
  2. /
  3. Blog
  4. /
  5. Race Condition Testing in PostgreSQL: Proving the Lock Works

Iurii Tests: Race Condition Testing in PostgreSQL: Proving the Lock Works

A held transaction is only the first step. The test must also prove the competing operation reached the lock before it can claim the guard worked.

September 30, 2026· 6 min read

Race condition testing with real PostgreSQL: readiness gates, observed lock waits, provider stubs, and the false confidence a timed assertion can give.

Stack

TypeScriptNode.js

Libraries

Drizzle ORM

Databases

PostgreSQL

Services

Mollie

Topics

TestingBillingPaymentsSaaS
Race Condition Testing in PostgreSQL: Proving the Lock Works

An invoice is open. A charging worker checks it and prepares to take payment. At the same time, a write-off operation checks that no payment is in flight and prepares to credit the invoice. Each check can pass before either operation writes its decision.

The result could be money moving against an invoice that has just been closed. This is a failure scenario, not a claim that a customer experienced it. It is the kind of race KRI, the booking SaaS I built for beauty businesses, guards against with a shared per-tenant PostgreSQL advisory lock.

Testing that guard raises a harder question: did the database actually block the competing operation, or did that operation simply start too late to cause trouble?

Three Events a Concurrency Test Must Distinguish

A useful test controls or observes three separate events:

  1. The first transaction has acquired the lock.
  2. The competing operation has reached that same lock and is waiting.
  3. The first transaction changes the state and releases the lock; the competitor resumes and reacts to the new state.

Starting two promises establishes none of those events by itself. Even starting a transaction first does not prove it acquired a connection, began, or reached its lock statement before the next function ran.

KRI’s existing billing suites combine held transactions, provider stubs and short timed waits. Reviewing them exposed two limits: the first lock acquisition needs its own readiness signal, and a timed negative assertion does not establish the second event. The pattern below adds the readiness signal; the following section explains what is still needed for a stronger test. It is an improved test sketch, not a claim that the existing suite already implements every step.

Signal That the Lock Is Held Before Starting the Competitor

A gate is a promise the test resolves itself:

function gate(): { wait: Promise<void>; open: () => void } {
  let open!: () => void;
  const wait = new Promise<void>((resolve) => {
    open = resolve;
  });
  return { wait, open };
}

Use two gates: one reports readiness, the other controls release. The fixture and full charge arguments are omitted here:

const acquired = gate();
const release = gate();
const held = db.transaction(async (tx) => {
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${"billing:tenant:" + tenantId}))`);
  acquired.open();
  await release.wait;
});
 
let charging: ReturnType<typeof chargeInvoice> | undefined;
try {
  // If the transaction fails before acquiring the lock, propagate that error.
  await Promise.race([acquired.wait, held]);
  charging = chargeInvoice(chargeArgs);
 
  // Observe this operation waiting on this lock before changing the fixture.
  // The database observation needed here is described below.
  await waitForChargeToBlock();
  expect(chargeRecurring).not.toHaveBeenCalled();
 
  await db
    .update(billingDocuments)
    .set({ status: "cancelled", settlementState: "hold" })
    .where(eq(billingDocuments.id, documentId));
 
  release.open();
  await held;
  const result = await charging;
  expect(result.kind).toBe("skipped");
  expect(chargeRecurring).not.toHaveBeenCalled();
} finally {
  release.open();
  await Promise.allSettled([held, ...(charging ? [charging] : [])]);
}

The fixture update deliberately uses a separate connection. It simulates the state the competing operation must see after the lock is released; it does not exercise the real credit-note function. That function needs its own tests. The finally block ensures an assertion failure does not leave the held transaction parked indefinitely. Database statement and lock timeouts should also bound failures in the test environment.

Observe Blocking Instead of Assuming Enough Time Passed

waitForChargeToBlock() above is a test-harness requirement, not a built-in Drizzle helper. Its implementation must identify the backend running this particular charge transaction. A test-only hook can report its pg_backend_pid() before lock acquisition; another connection can then inspect that backend.

PostgreSQL reports advisory-lock waits through pg_stat_activity as wait_event_type = 'Lock' and wait_event = 'advisory'. Check the target PID and use pg_blocking_pids(target_pid) to confirm that the holder is the test’s transaction. An unrelated session waiting on some other advisory lock is not evidence. Both facilities are documented in PostgreSQL’s monitoring guide and system information functions.

Poll with a deadline and fail if the expected state never appears. A short delay between polls only limits query frequency; it is the observed database state that satisfies the assertion. Size the pool for the holder, the competing transaction and the observer/update connection. With only two connections, the test can exhaust its own pool before it ever releases the gate.

The existing suite instead waits 400 milliseconds and asserts that the provider has not been called. On a slow runner, that can pass because the charge has not reached the lock yet. Its final skipped result can also pass after the fixture changes, even if the lock was removed. A readiness gate fixes the first ordering problem; observing the blocked backend closes this second gap.

As a sanity check, deliberately remove the guard in a disposable test change: the test should fail because the expected blocking never occurs. A concurrency test that stays green when its protection disappears is testing the wrong thing.

The Reverse Direction: Money May Already Be Moving

Once a charge reaches the provider, crediting the invoice must account for the possibility that payment will succeed. Here a provider stub gives the test a precise event to wait for:

const sent = gate();
const answered = gate();
chargeRecurring.mockImplementation(async () => {
  sent.open();
  await answered.wait;
  return providerOpenPaymentFixture;
});
 
const charging = chargeInvoice(chargeArgs);
try {
  await Promise.race([sent.wait, charging]);
  expect(chargeRecurring).toHaveBeenCalledTimes(1);
  await expect(issueCreditNote(documentId, issuedOn)).rejects.toThrow(/in flight/);
} finally {
  answered.open();
  await charging;
}

KRI records the pending attempt before making that call. The credit-note operation takes the shared lock, reads the attempt and refuses to close the invoice while money may be moving. The stub controls the network boundary while the database operations remain real.

Reconciliation uses another controlled boundary. A stubbed provider-history response can perform a competing settlement write before returning. The test then checks that reconciliation does not overwrite a newly settled attempt with a stale conclusion that no payment was found.

Keep PostgreSQL Real, Keep Stress Tests in Their Place

The lock manager, transaction snapshots and partial unique index that permits one live payment attempt per invoice are PostgreSQL behaviour. A mocked database cannot demonstrate that those protections work together. Stub the provider’s network calls; run the transactions against an isolated real database.

A Promise.allSettled loop is still useful alongside controlled tests. It can check that charging and crediting never both succeed, or that cancellation and renewal produce one allowed outcome. It cannot guarantee that any run reached the dangerous ordering. Ten green runs are evidence about those ten runs, not proof that a narrow race is impossible.

Use this machinery where an ordering mistake has a meaningful consequence: billing, limited inventory or booking slots. A conditional atomic update may remove a particular check-then-write gap, though workflows around it can still race. One administrator or one scheduled job is not proof of serial execution: retries, multiple tabs and overlapping job runs can still produce concurrent requests.

The useful promise of a concurrency test is specific: it establishes the ordering being tested, observes the guard, and checks the result after release. If one of those steps is inferred from elapsed time, say so.


Building a billing or booking feature? Get in touch to scope the failure cases and verification alongside the MVP implementation.

Further reading:

  • KRI: booking websites for beauty businesses
  • Inventory locks under concurrent checkouts
  • Idempotency keys and safe retries
Iurii RoguliaAvailable

MVP Development

If your MVP charges cards or hands out limited slots, its check-then-write paths need tests that force the dangerous order, not hope for it. That’s part of how I build billing and booking systems.

More about this service

Relevant client work

View all projects
kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
September 28, 2026

Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes.

vatnode.dev – EU VAT Validation API
vatnode.dev – EU VAT Validation API
January 19, 2026
vatnode.dev – EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

HTPBE.TECH – Has This PDF Been Edited?
HTPBE.TECH – Has This PDF Been Edited?
September 25, 2024
HTPBE.TECH – Has This PDF Been Edited?

SaaS platform for PDF authenticity verification with a public REST API.

What clients say

“

I had a validated idea and a deadline tied to an accelerator demo day, but no product.

Aino Virtanen 🇫🇮

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS
“

We’re a small SaaS team and had no idea our sitemap only listed half our pages until Iurii’s first monthly report flagged it.

Sarah Mitchell 🇨🇦

Co-founder

Topics

SEOSaaSContent
“

I’d built most of our MVP with Cursor and it looked finished – it compiled, the tests were green, the demo worked. It just wouldn’t survive real users.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture

Related articles

Subscription Billing: The Edge Cases Stripe Docs Skip
July 22, 2026· 17 min
Subscription Billing: The Edge Cases Stripe Docs Skip

Subscription billing edge cases Stripe glosses over: proration, dunning, the state machine, cancellation timing, refunds, VAT, and webhook ordering.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQL

Services

Stripe

Topics

SaaSPaymentsE-commerceBilling
Stripe to Mollie Migration: What Actually Breaks
June 26, 2026· 21 min
Stripe to Mollie Migration: What Actually Breaks

Stripe to Mollie migration in production: mandates instead of Subscriptions, thin webhooks, no proration, no portal, no Tax. What you rebuild yourself.

Stack

Next.jsTypeScriptNode.js

Libraries

Drizzle

Databases

PostgreSQL

Services

StripeMollie

Topics

BillingPaymentsSubscriptionsMigrationVAT
Stripe Webhooks: Idempotency, Retries, and Queue Setup
January 2, 2026· 11 min
Stripe Webhooks: Idempotency, Retries, and Queue Setup

Stripe webhook production architecture: idempotency keys in PostgreSQL and Redis, a BullMQ queue, and signature verification in Next.js App Router.

Stack

Next.jsTypeScriptNode.js

Libraries

BullMQDrizzle ORMioredis

Databases

PostgreSQLRedis

Services

Stripe

Topics

SaaSWebhooksArchitecturePayments
API Key Management for a Public SaaS API
August 5, 2026· 13 min
API Key Management for a Public SaaS API

API key management for a public SaaS: hashing keys at rest, prefix + last-4 display, fail-closed validation, and revocation.

Stack

TypeScriptNode.jsHono

Libraries

Drizzle ORM

Databases

PostgreSQL

Topics

SaaSAPIAuthSecurity
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026· 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS