Two Requests Walk Into a Bar: Race Conditions in Ordinary Web Code

The coupon was single-use. It got used 217 times in one weekend. On check-then-act bugs, why your code has more concurrency than you think, and the fixes that actually hold.

t = 0t = 0same row
Bob
Senior Backend Engineer
Apr 29, 2026
6 min read

We shipped a promo once — first 500 customers get 40% off, one redemption per account. The code checked whether you'd already redeemed before applying the discount. Simple. Reviewed. Tested.

Monday morning, finance asked why one account had redeemed the coupon 217 times.

The account belonged to someone whose checkout button was slow, so they did what every user does: clicked it a lot. Each click became a request. Each request checked "has this account redeemed?" — and since none of them had finished yet, every one of them saw "no." Then they all proceeded. Our single-use coupon had a very generous definition of "single."

No exotic threading, no async wizardry. Just a web app doing what web apps do: handling requests concurrently. If your code can be executed by two requests at once — and it can — you have a concurrent system, whether you signed up for one or not.

The bug pattern with a thousand faces

Almost every race condition in web code is the same two-step, which the literature calls check-then-act:

# Step 1: check
if current_user.redemptions.where(coupon: coupon).none?
  # ...a gap of a few milliseconds lives here...
  # Step 2: act
  current_user.redemptions.create!(coupon: coupon)
  apply_discount!
end

Between the check and the act there's a gap, and in that gap, another request can run the same check and get the same answer. The pattern looks airtight when you read it sequentially — that's exactly why it survives review. You have to read it as two interleaved copies to see the hole.

Once you know the shape, you see it everywhere:

  • find_or_create_by — despite the friendly name, it's a SELECT then an INSERT. Two concurrent calls both find nothing, both insert. The Rails docs themselves warn it isn't atomic.
  • Balance checks — "if balance >= amount, deduct" is the textbook double-spend. Two withdrawals both read $100, both approve, account ends at -$50.
  • Inventory — "if stock > 0, decrement" oversells the last unit to everyone who asked at the same moment.
  • Read-modify-writeuser.points += 10; user.save! loads a value, does math in Ruby, writes it back. Two concurrent updates and one of the +10s silently vanishes.
  • Slug/username claims — check availability, then insert. Two people both become @dave.

And here's the trap that makes these so durable: they're invisible in tests and rare in staging. Sequential test suites can't produce the interleaving, and low-traffic environments almost never do. The bug ships silently and waits for traffic — or for one impatient user with a slow button.

Fixes that hold (and one that doesn't)

The instinctive fix is to check harder — check twice, check closer to the act, add a boolean flag. None of it works, because any check performed in application code still leaves a gap. The fixes that hold all share one idea: push the decision into the database, the only participant that actually sees every request.

Unique constraints: the workhorse

For "at most one of these may exist," a unique index is the whole answer:

add_index :redemptions, [:user_id, :coupon_id], unique: true

Now the database physically cannot store the 217th redemption. The app-level check remains as UX (a friendly error beats a 500), but it's no longer load-bearing — rescue ActiveRecord::RecordNotUnique and treat it as "already redeemed." For the find_or_create_by race, this plus a retry (or create_or_find_by, which inverts the order and leans on the constraint) closes the hole completely. A validation like validates :coupon_id, uniqueness: ... alone does not — it's just check-then-act wearing a validation costume. It runs a SELECT.

Atomic updates: do the math in the database

For counters and balances, stop computing in Ruby. One statement, no gap:

# instead of: read, add in Ruby, write back
user.increment!(:points, 10)   # UPDATE ... SET points = points + 1 * 10

# conditional decrement that can't oversell:
rows = Item.where(id: item.id).where("stock > 0")
           .update_all("stock = stock - 1")
raise OutOfStock if rows.zero?

That second pattern is quietly powerful: the WHERE clause is the check, the UPDATE is the act, and the database executes them as one atomic operation. The return value tells you whether you won.

Locks: when the critical section is genuinely bigger

Sometimes the work between check and act is too rich for a single statement — read a row, run business rules, update three tables. Then you make the gap exclusive: with_lock wraps a SELECT ... FOR UPDATE in a transaction, so the second request blocks until the first finishes and then sees fresh data. It works, with taxes: lock as little as possible, never make external calls while holding a lock, and touch multiple rows in a consistent order unless you enjoy deadlocks. For cross-process work that isn't tied to one row, advisory locks fill the same role.

Pick the lightest tool that closes the gap: constraint beats atomic update beats lock, in that order of preference.

Reviewing for races

You can't test these in by accident, so they have to be caught by reading. What I look for in a diff:

  • Any if whose condition queries the database, followed by a write that the condition was guarding. That's the signature.
  • find_or_create_by without a backing unique index.
  • += or any Ruby-side arithmetic on a persisted value.
  • Uniqueness enforced only by a validation.

I'll add the now-standard observation: code assistants produce check-then-act constantly, because it's the most natural rendering of the requirement. "Ensure a user can only redeem once" becomes an if ... none? guard — readable, plausible, and racy. The model writes for one request at a time; production runs many. When a generated diff enforces any at-most-once rule, my first review question is "where's the constraint?" — and "the if handles it" is not an accepted answer.

The checklist I actually use

  1. Assume every request runs at least twice, concurrently. Impatient users with slow buttons are a load-testing service you don't have to pay for.
  2. Every "at most one" rule gets a unique index. Validations are UX; constraints are law.
  3. Counters and balances change via atomic SQL, never read-modify-write in Ruby. Conditional update_all with the guard in the WHERE is your friend.
  4. Reach for with_lock only when a single statement can't express the operation — and hold it briefly, with no external calls inside.
  5. In review, hunt the check-then-act shape, especially in generated code. Read every guard as if two copies of it are running.

Concurrency bugs feel like advanced material, but the fix rarely is: it's usually one index or one WHERE clause, placed by someone who read the code as two interleaved requests instead of one. Be that someone. Your finance team will never know what you saved them from — which is precisely the point.

concurrencyrace-conditionsdatabaselockingrails
Written by
Bob
Senior Backend Engineer · Firetrail review team
More Backend