The Billing Bug You Can't Have

Emit bills by the email: you prepay for credits, and every send debits one. Which means somewhere in the system there’s a number going down as mail goes out, and a nasty question attached to it: what happens when two sends debit the same balance at the same instant?
Get this wrong and you either give away free email (annoying) or charge people for mail that never sent (unforgivable). The fix is old, boring database technique, and that’s exactly why I want to write it down.
The race
The naive implementation reads the balance, checks it, and writes it back:
account = get_account(account_id)
if account.balance >= 1:
account.balance -= 1
save(account)
send_email()
Two workers run this concurrently against a balance of 1. Both read 1, both pass the check, both send, and the final balance is 0 after two emails went out. Worse versions of the same race can drive the balance negative. The window between read and write is small, which means it works flawlessly in development and fails only in production, under load, involving money.
Make the database decide
The read-check-write shape is the whole problem, so the fix is to stop doing it. Postgres can evaluate the condition and apply the change in one atomic statement:
UPDATE accounts
SET credit_balance = credit_balance - 1
WHERE id = :account_id
AND credit_balance >= 1
RETURNING credit_balance;
If the row comes back, the debit happened and you may send. If no row comes back, the balance was insufficient at the moment of truth, and you don’t. There’s no window because there’s no gap between check and change; row-level locking makes concurrent updates queue up and each one re-evaluates the condition against the latest value. Two workers racing a balance of 1 get exactly one success between them, every time, with no application locks, no advisory locks, no serializable transactions. The database was always going to be the arbiter of this row, so hand it the whole decision.
The ledger underneath
The balance column is really just a cache. The source of truth is an append-only credit_transactions table: every top-up, every debit, every adjustment is a row that never gets updated or deleted. The conditional update above and the ledger insert happen in the same transaction, so the running balance and the history can’t drift.
An append-only ledger sounds like ceremony for a system this small, and it pays for itself the first time a customer asks “where did my credits go?” The answer is a SELECT, not an archaeology project. It’s double-entry bookkeeping’s core insight applied to a credits table: record movements, derive balances.
The exception that proves the design
One path is allowed to break the floor: chargebacks. If someone disputes a Stripe payment after spending the credits, the clawback debit goes through a separate force-debit path that skips the balance check and can push the account negative. That’s deliberate. The invariant was never “balances are non-negative,” it was “credits are never spent twice.” A negative balance is an accurate record of someone spending money they took back, and accuracy wins.
There’s one more small state machine riding on this: the low-balance warning email arms once, fires once, and only re-arms after a top-up. Without the re-arm rule you either nag someone every hour as their balance hovers near the threshold, or you warn once ever and stay silent the next time it matters months later.
What the same ledger now bills for
Since I wrote this, emit has grown a second thing worth charging for: an LLM relevance filter that judges feed items against a description of what you want. That could easily have become a second balance with its own top-up flow, and I’m glad it didn’t. It debits the same credits through the same conditional update, at a published conversion rate of one credit per 4,000 characters judged.
Two details fell out of reusing this machinery. Metering by characters rather than by items means a credit buys a fixed volume of judged text, so a long changelog costs proportionally more than a headline instead of being subsidized by it. And because filtering can fail in the middle of a batch in a way that sending an email cannot, the debit turns into a reservation that gets released when a pass produces nothing. That’s a different shape than the one-shot debit above, and it gets its own post.
The takeaway
Anywhere you find read-check-write on contested data, you can usually push the whole decision into one conditional statement and let the database serialize it. It’s less code than the broken version, it can’t be misused by a forgetful caller, and it turns your scariest concurrency bug into a query plan. Boring, in the best possible way.
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.