What this usually means
Race conditions happen when two concurrent operations read a value, make a decision based on it, and then write back — without locking in between. In payments, the pattern is: read balance, check if sufficient, deduct. Between the read and the write, another request can do the same. Both see the old balance, both think there are enough funds, both deduct. The fix is to make the check-and-deduct atomic — either with a database row-level lock, an atomic UPDATE with a WHERE clause, or optimistic locking.
The first ten minutes \u2014 establish facts before touching code.
- 1Check if the balance check and deduction are in the same database transaction. If not, there is a race window.
- 2Check if the deduction uses an atomic UPDATE with a WHERE clause: `UPDATE accounts SET balance = balance - $80 WHERE id = $1 AND balance >= $80`.
- 3Check if the database transaction uses SELECT ... FOR UPDATE to lock the row for the duration of the transaction.
- 4Check if two requests can hit the endpoint simultaneously. Load test with concurrent requests.
- 5Check if the payment provider has its own race condition protection (Stripe idempotency, database constraints).
The specific files, logs, configs, and dashboards that usually own this bug.
- searchPayment handler code — the sequence: read balance, check, deduct, commit
- searchDatabase transaction boundaries — is the full check-and-deduct in one transaction?
- searchDatabase isolation level — READ COMMITTED is not enough for balance checks
- searchRow-level locking — SELECT ... FOR UPDATE or equivalent
- searchDatabase constraints — CHECK constraint that balance cannot go negative
- searchLoad test results — concurrent requests with the same account
Practical causes, not theory. These are the things you will actually find.
- warningCheck and deduction are in separate transactions or no transaction at all
- warningUsing SELECT then UPDATE without FOR UPDATE — two requests can read the same balance
- warningDatabase isolation level is too weak — READ COMMITTED allows non-repeatable reads
- warningNo database CHECK constraint preventing negative balances
- warningBalance check is done in application code instead of in the database query
- warningPayment provider is not receiving an idempotency key
- warningCaching layer returns stale balance data that does not reflect recent deductions
Concrete fix directions. Pick the one that matches your root cause.
- buildUse an atomic UPDATE with a WHERE clause: `UPDATE accounts SET balance = balance - $amount WHERE id = $id AND balance >= $amount`. Check affected rows.
- buildWrap the balance check and deduction in a transaction with SELECT ... FOR UPDATE to lock the row
- buildAdd a database CHECK constraint: `CONSTRAINT balance_non_negative CHECK (balance >= 0)`
- buildUse the payment provider's idempotency key to prevent duplicate charges at the provider level
- buildAdd an application-level mutex or distributed lock for operations on the same account
- buildUse optimistic locking: add a version column, increment it on update, and retry on conflict
A fix you cannot prove is a guess. Close the loop.
- verifiedRun a load test with 100 concurrent requests against the same account. No balance should go negative.
- verifiedCheck the database: the number of successful deductions should not exceed what the balance allows.
- verifiedAfter the fix, run the same test that previously reproduced the race condition. It should pass.
- verifiedAdd an integration test that spawns concurrent deduction requests and asserts the final balance is correct.
- verifiedSet up monitoring that alerts if any balance goes negative.
Things that make this bug worse or harder to find.
- warningNot using database transactions for payment operations
- warningChecking balance in application code instead of in the database query
- warningNot testing payment flows under concurrent load
- warningRelying on application-level checks without database-level constraints as a safety net
- warningNot using idempotency keys with the payment provider