Race conditions on counters and balances
What it looks like
const { data } = await supabase.from('wallets').select('balance').eq('id', walletId).single();
if (data.balance >= price) {
await supabase.from('wallets').update({ balance: data.balance - price }).eq('id', walletId);
await grantItem(walletId, itemId);
}What happens
Users can spend the same credits twice, book the last seat twice, or redeem a coupon more times than allowed, and the stored totals stop matching what was actually given out.
Where it shows up
Descriptive, not a verdict: builders and stacks whose defaults make this reachable, or that a public incident names. Each hub states the tool's own security features.
Check it yourself
Uses your builder's dashboard and your browser. No tools to install.
- Open the app in two browser tabs signed in to the same test account that has a limited balance, seat count or single-use coupon.
- Get both tabs ready on the same action (buy, book, redeem) and click both within a second of each other.
- Check the balance or count in the app and in the table editor, and check what was granted.
- Bad result: the item, seat or coupon was granted twice, the stored balance does not match the sum of what was spent, or the balance went negative.
What we check
We fire many simultaneous requests at each endpoint that changes a balance, counter, stock level or coupon, then compare the stored totals with what the app granted. The report shows each mismatch and the endpoint behind it.
Fix direction
Do the check and the write in one database statement, for example UPDATE wallets SET balance = balance - price WHERE id = $1 AND balance >= price, or lock the row with SELECT ... FOR UPDATE inside a transaction (a group of statements that succeed or fail together). Add a unique constraint wherever a thing should only be granted once.
Mechanism documented in: Explicit Locking (verified 2026-09-21).
Related patterns
- No idempotency on payment handlersPaymentsHigh
A customer is charged twice after a network hiccup, or a replayed webhook tops up an account again and again for a single payment.
- Price or plan taken from the clientPaymentsHigh
A buyer can edit the request in their browser, pay one cent for the top plan, and your app records it as a full purchase.
- No rate limiting on sensitive routesReliabilityHigh
A script can try thousands of passwords against your login form, hammer a form that sends emails or calls a paid API, or knock the app over, and the bill lands on you.
- Deletes with no soft delete or audit trailData integrityHigh
One wrong click, a bug in a filter, or an abused account removes customer data for good, and nobody can say afterwards what was lost or who removed it.
Questions
what is a race condition in a web app
A race condition happens when two requests act on the same data at the same time and each one assumes it is alone. With a balance, both read the same starting value, both pass the check, and both write, so one spend is lost or one grant is doubled. It shows up under real traffic or when someone deliberately sends requests together.
how do i stop double spending credits in my app
Make the check and the deduction a single database operation so the database, not the app, decides who wins. Postgres does this with a conditional update or with SELECT ... FOR UPDATE, which locks the row until the transaction ends so other writers wait. A unique constraint on the grant record is a second line of defence.
supabase update balance safely
Move the logic into a Postgres function that runs as one transaction: lock the wallet row, check the balance, deduct, and insert the grant record. Call that function from the app instead of doing a read followed by a write. Test it by clicking the same action in two tabs at once and confirming only one goes through.
Back to the catalogue, or run the self-check for your stack.