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.
BookahTranslate charges users by pages. New users get 5 bonus pages. The deduction logic looks innocent:
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
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.