Skip to content
BuildThenTestedSee packages

Race conditions on counters and balances

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. Whether stored data stays correct over time. Check it yourself with the steps below; our test adds what a script and a second account can find.

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);
}
Read the balance, check it, write the new value: three separate requests, so two requests arriving at the same moment both pass the check before either write lands.

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.

  1. 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.
  2. Get both tabs ready on the same action (buy, book, redeem) and click both within a second of each other.
  3. Check the balance or count in the app and in the table editor, and check what was granted.
  4. 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).

Covered by

Full audit ($1,900, 5 business days)

Both include a re-check after your fixes.

Compare packages

Related patterns

  • No idempotency on payment handlersPayments

    A customer is charged twice after a network hiccup, or a replayed webhook tops up an account again and again for a single payment.

    High
  • Price or plan taken from the clientPayments

    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.

    High
  • No rate limiting on sensitive routesReliability

    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.

    High
  • Deletes with no soft delete or audit trailData integrity

    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.

    High

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.