What is a race condition in a web app and how do I fix it?

Direct answer

It's when two requests arrive close enough together that both pass a check before either applies its change. So a single-use coupon gets redeemed twice, or a balance goes negative. Application-level checks can't prevent it. The fix is a database constraint, a transaction with row locking, or an atomic update.

Muhammad HasanUpdated
// Vulnerable
const coupon = await db.coupons.findOne({ code });
if (coupon.used) throw new Error('Already used');
await db.coupons.update({ code }, { used: true });
await applyDiscount(order);

Read the state, decide, then write. Two requests arriving milliseconds apart both read used: false, both pass the check, and both apply the discount.

This is exploited deliberately. Sending fifty simultaneous requests to a redemption endpoint is trivial, and the attacker only needs one to slip through.

Where it shows up

Coupon and voucher redemption. Withdrawals and transfers going negative. Invite codes used more than once. Rate limits bypassed by parallel requests. Registration creating duplicate accounts. Anywhere with a "one per customer" rule.

Fix 1: let the database enforce it

The most robust option, because the guarantee lives with the data rather than with every code path that touches it.

alter table coupon_redemptions
add constraint one_per_user unique (coupon_id, user_id);

Attempt the insert and handle the constraint violation. The database serialises this for you and it holds regardless of how many application instances are running.

Fix 2: atomic conditional update

Make the check part of the write:

update coupons
set used = true
where code = $1 and used = false
returning *;

Zero rows returned means someone else got there first. One statement, no window between check and act.

Same pattern for balances:

update accounts
set balance = balance - $1
where id = $2 and balance >= $1;

Fix 3: transaction with row locking

Where the logic is too complex for a single statement:

begin;
select * from coupons where code = $1 for update;
-- other transactions block here
update coupons set used = true where code = $1;
commit;

SELECT ... FOR UPDATE holds the row until commit. Keep the transaction short, long-held locks cause their own problems.

Idempotency keys

For payment and order endpoints, have the client send a unique key per logical operation and store it with a unique constraint. A retry with the same key returns the original result instead of creating a second charge.

What doesn't work

Checking in application code and hoping. A mutex in one process, when you run several. Optimistic checks without a version column. Anything that assumes requests arrive one at a time.

What tooling will and won't catch

Hard for automated tools, because the vulnerable code is ordinary and the flaw is in the timing rather than the syntax. Reviewing every check-then-act sequence against shared state is currently a manual job. Look for a read, a conditional, and a write to the same record without a transaction around them.

Go deeper

Fast remediation with Kolega

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free