← all notes
2026-04-12

Race conditions in page-based subscription billing

How three concurrent requests can drive a balance into the negative — and why the obvious fix is wrong.

postgresconcurrencybillingsaas

BookahTranslate charges users by pages. New users get 5 bonus pages. The deduction logic looks innocent:

python
Naive deduction — has a race
user = User.query.get(user_id)
if user.bonus_pages >= pages_needed:
    # ... run translation ...
    user.bonus_pages -= pages_needed
    db.session.commit()

The read of bonus_pages and the write are separated by however long the translation takes — sometimes minutes for a 200-page PDF. Three parallel requests with bonus_pages=5 and pages_needed=5 all pass the check, all start translating, all commit -5. The balance lands at -10. The user got 15 pages for free.

The obvious fix that doesn't work

First instinct: wrap it in a transaction. But the default transaction isolation in Postgres is READ COMMITTED — concurrent transactions still read the same starting value. Higher isolation (SERIALIZABLE) would work, but you pay for it on every billing operation, and you get retryable serialization errors that have to be handled application-side anyway.

What actually works: lock the row

python
Atomic deduction with row-level lock
sub = (
    db.session.query(UserSubscription)
    .with_for_update()  # SELECT ... FOR UPDATE
    .filter_by(id=subscription_id)
    .first()
)
if sub.pages_remaining < pages_count:
    db.session.rollback()
    return False
sub.pages_remaining -= pages_count
db.session.commit()  # releases lock

with_for_update() makes the SELECT hold a row-level lock until the transaction ends. The second concurrent request blocks at the SELECT, waits for the first to commit, then reads the updated value (pages_remaining=0) and fails the check. No retry, no SERIALIZABLE overhead, no inconsistency.

The lesson

If a balance update depends on the current balance, the read and write must happen under the same lock. Not the same transaction — the same lock. This is true whether the balance lives in Postgres, Redis, or Mongo.