If you're building an AI app, one of the first real problems you hit is metering. You give people a chat, it calls an LLM, and every message costs you money. So you need credits: each user has a balance, each message spends one, and when they hit zero they have to upgrade. Simple on paper. The trap is concurrency.
The bug that bites everyone
The naive version looks like this:
const user = await db.user.findById(id)
if (user.credits <= 0) throw new Error('no credits')
await generate(message)
await db.user.update(id, { credits: user.credits - 1 })
Read the balance, check it, do the work, write the new balance. It works in testing. Then two requests from the same user land at the same time. Both read credits: 1. Both pass the check. Both generate. Both write 0. The user paid for one message and got two. At scale, people will find this, sometimes on purpose.
Do the whole thing in the database
The fix is to never read-then-write in app code. Let Postgres do the check and the decrement in one atomic statement, with the row locked:
create or replace function spend_credit(p_user uuid)
returns int
language plpgsql
as $$
declare
remaining int;
begin
update users
set credits = credits - 1
where id = p_user and credits > 0
returning credits into remaining;
if remaining is null then
raise exception 'insufficient_credits';
end if;
insert into credit_log (user_id, delta, reason)
values (p_user, -1, 'message');
return remaining;
end;
$$;
Two things matter here:
-
where ... and credits > 0means the decrement only happens if there's something to spend. If the balance is already 0, no row updates,remainingis null, and we raise. The check and the write are the same statement, so there's no gap for a second request to slip through. - The
credit_loginsert gives you an append-only ledger. Every change has a row. When a customer says "I was charged twice," you can actually answer, instead of guessing from a single mutable number.
Call it the same way for both requests and Postgres serializes them on the row lock. One gets remaining = 0, the other gets the exception. Nobody goes negative.
Topping back up
Adding credits (a plan renewal, a pack purchase) is the mirror image, and it also goes through a function with a ledger row. The one extra thing on the top-up side is idempotency: Stripe can send the same webhook more than once, so guard on the event id and skip if you've already recorded it. Otherwise a retried webhook doubles someone's credits.
Why bother
It's a small amount of SQL, but it's the difference between a billing system you trust and one that quietly loses you money. The app code gets simpler too: call one function, catch one exception, show an upgrade prompt. No read-modify-write races to reason about.
I ended up packaging this (plus streaming chat, auth, Stripe subscriptions and credit packs, and the rest of the plumbing) into a Next.js 15 starter so I stop rebuilding it every time. If it helps: live demo at https://ai-saas-starter-ashen.vercel.app and the code is at https://venturionai.gumroad.com/l/ai-saas-starter (40% off the first 10 with LAUNCH40). But the pattern above is the part that matters, use it even if you build the rest yourself.
Top comments (0)