{"slug": "credit-billing-without-transactions-4-race-conditions-i-hit-on-serverless", "title": "Credit Billing Without Transactions: 4 Race Conditions I Hit on Serverless Postgres", "summary": "A developer building an AI image generation SaaS on Neon's serverless Postgres HTTP driver, which lacks multi-statement transaction support, encountered four race conditions in credit billing. The developer resolved them by using single-statement atomic operations, such as moving invariants into WHERE clauses and using data-modifying CTEs with partial unique indexes for idempotency. These fixes ensure correctness without transactions, preventing issues like double-spending and duplicate grants.", "body_md": "I run an AI image generation SaaS. Users buy credits, each generation burns some, and failed generations get refunded automatically. Sounds like a solved problem — until you try to build it on a serverless Postgres driver that doesn't support multi-statement transactions.\n\nNeon's HTTP driver is one of those. Every query is a separate round trip. `BEGIN; ... COMMIT;`\n\nisn't available. Which means every billing operation has to be correct as a **single statement**, or not at all.\n\nHere are the four races that actually bit me, and how each got fixed. All four are the kind of bug that doesn't show up in testing and does show up in your support inbox.\n\nThe naive version everyone writes first:\n\n``` js\nconst balance = await getBalance(userId);\nif (balance < cost) throw new Error(\"Insufficient credits\");\nawait setBalance(userId, balance - cost);\n```\n\nTwo concurrent requests both read `balance = 5`\n\n, both pass the check, both write `balance = 4`\n\n. The user got two generations for the price of one.\n\nWith transactions you'd wrap this in `SERIALIZABLE`\n\nand move on. Without them, the fix is to make the check and the write the same statement:\n\n```\nUPDATE credits_balance\nSET balance = balance - $amount\nWHERE user_id = $userId\n  AND balance >= $amount\nRETURNING balance\n```\n\nA single `UPDATE`\n\nis atomic in Postgres. If the balance is insufficient, `WHERE`\n\nmatches nothing, zero rows return, and you know the deduction failed. No transaction needed.\n\n**The general pattern:** move your invariant into the `WHERE`\n\nclause. If the row doesn't match, the write doesn't happen.\n\nStripe retries webhooks. On timeouts, on 500s, on network hiccups. It also occasionally delivers the same event twice under normal operation. If your handler grants credits, \"at least once\" delivery means \"at least once granted.\"\n\nThe usual fix is a `processed_events`\n\ntable plus a transaction:\n\n```\nawait tx.insert(processedEvents).values({ id: event.id });  // throws on duplicate\nawait tx.update(balance).set({ credits: sql`credits + ${amount}` });\n```\n\nNo transactions, no dice — a crash between those two statements either double-grants on retry or loses the grant entirely.\n\nThe fix that works in one statement is a **data-modifying CTE** where the ledger insert acts as a gate:\n\n```\nWITH gate AS (\n  INSERT INTO credits_transactions (user_id, delta, type, ref_id, balance_after)\n  SELECT $userId, $amount, 'pack_purchase', $refId, ...\n  ON CONFLICT (ref_id) WHERE type IN ('plan_grant', 'pack_purchase') DO NOTHING\n  RETURNING id\n)\nINSERT INTO credits_balance (user_id, topup_balance)\nSELECT $userId, $amount WHERE EXISTS (SELECT 1 FROM gate)\nON CONFLICT (user_id) DO UPDATE\n  SET topup_balance = credits_balance.topup_balance + EXCLUDED.topup_balance\n```\n\nBacked by a partial unique index:\n\n```\nCREATE UNIQUE INDEX credits_tx_grant_idem_idx\n  ON credits_transactions (ref_id)\n  WHERE type IN ('plan_grant', 'pack_purchase');\n```\n\n`ref_id`\n\nis the Stripe checkout session or invoice id. On a redelivery the `INSERT`\n\nhits the conflict, `RETURNING`\n\nyields nothing, `EXISTS(gate)`\n\nis false, and the balance update is skipped. Postgres statement atomicity means a crash mid-statement leaves nothing applied — so Stripe's retry completes cleanly.\n\nTwo details worth stealing:\n\n`admin_adjust`\n\ngrants use free-text `ref_id`\n\ns from a CLI script, and signup grants have `ref_id = NULL`\n\n. Both would collide with a naive global unique index. Scoping it to the two Stripe-driven types keeps idempotency where it matters and stays out of the way everywhere else.This one is my favourite, because it's not a concurrency bug at all — it's a modeling bug that only appears once you have two kinds of credit.\n\nMy balance has two buckets:\n\n| Bucket | Source | Expires? |\n|---|---|---|\n`monthly_balance` |\nsubscription grant | yes, at cycle end |\n`topup_balance` |\none-time pack purchase | never |\n\nSpending drains `monthly`\n\nfirst, since it expires anyway. Straightforward.\n\nThen a generation fails and we refund. The original code did this:\n\n```\nawait db.update(creditsBalance)\n  .set({ topupBalance: sql`topup_balance + ${amount}` })\n  .where(eq(creditsBalance.userId, userId));\n```\n\nRefund to `topup`\n\n. Simple, and wrong in a way that costs real money.\n\nIf the charge came out of `monthly`\n\n— credits that were going to expire in nine days — and the refund lands in `topup`\n\n, those credits are now **permanent**. A user can generate, fail, and get refunded into a bucket that never expires. Repeat, and expiring credits quietly convert into a perpetual balance. It's a laundering machine, and every cycle of it is revenue you already recognized and now owe indefinitely.\n\nThe fix has two halves.\n\n**First, record how the charge split** at deduction time. This is where a single statement gets genuinely tricky, because you need the *pre-update* values to compute the split:\n\n```\nWITH pre AS (\n  SELECT\n    CASE WHEN monthly_expires_at IS NOT NULL AND monthly_expires_at <= NOW()\n         THEN 0 ELSE monthly_balance END AS m,\n    topup_balance AS t\n  FROM credits_balance WHERE user_id = $userId\n),\nupd AS (\n  UPDATE credits_balance cb\n  SET monthly_balance = GREATEST(pre.m - $amount, 0),\n      topup_balance   = GREATEST(cb.topup_balance - GREATEST($amount - pre.m, 0), 0)\n  FROM pre\n  WHERE cb.user_id = $userId\n    AND (pre.m + cb.topup_balance) >= $amount\n  RETURNING cb.monthly_balance, cb.topup_balance\n)\nSELECT\n  LEAST($amount, pre.m)                AS spent_monthly,\n  $amount - LEAST($amount, pre.m)      AS spent_topup\nFROM upd, pre\n```\n\nCTEs see the snapshot from the start of the statement, so `pre`\n\nstill holds the old values even though `upd`\n\nhas already written. That's what makes computing the split possible without a second round trip. Persist `spent_monthly`\n\n/ `spent_topup`\n\non the generation row.\n\n**Second, refund each bucket what it gave up** — with one caveat:\n\n```\nmonthlyBalance: sql`monthly_balance + (\n  CASE WHEN monthly_expires_at IS NOT NULL AND monthly_expires_at > NOW()\n       THEN ${refundMonthly} ELSE 0 END\n)`,\ntopupBalance: sql`topup_balance + ${refundTopup}`\n```\n\nIf the monthly window already rolled over, those credits would have expired anyway. Resurrecting them into a fresh cycle is the same laundering bug wearing a different hat. So the monthly portion is dropped when the window has closed; the topup portion always refunds.\n\nThere's a bonus in that `pre`\n\nCTE, by the way: `CASE WHEN monthly_expires_at <= NOW() THEN 0`\n\nmeans an expired bucket can't satisfy the sufficiency check, can't be spent, and gets swept to zero by `GREATEST(pre.m - amount, 0)`\n\n— all in the same atomic statement. No cron job needed to clean up expired balances. The next spend does it lazily.\n\nGeneration takes 60–90 seconds, so the client polls a status endpoint. Open the app in two tabs and you get two pollers hitting the same task. The provider reports failure. Both pollers see it. Both refund.\n\nSame trick as race 1 — put the invariant in the `WHERE`\n\n, and use the status column itself as the claim:\n\n``` js\nconst updated = await db.update(generations)\n  .set({ status: \"failed\", completedAt: new Date() })\n  .where(and(\n    eq(generations.taskId, taskId),\n    eq(generations.status, \"pending\"),   // <-- the claim\n  ))\n  .returning({ creditsCharged, spentMonthly, spentTopup });\n\nif (updated.length === 0) return false;  // someone else already finalized\n\nawait refundCredits(userId, creditsCharged, `gen-failed:${taskId}`, {\n  monthly: spentMonthly,\n  topup: spentTopup,\n});\n```\n\nOnly the poller that successfully flips `pending → failed`\n\ngets rows back, and only that one issues the refund. Everyone else short-circuits.\n\nNote that `returning()`\n\nhands back the split recorded during deduction, so the refund lands in the right buckets — race 3 and race 4 fix each other's blind spots.\n\nFour bugs, one shape:\n\nExpress the precondition as part of the write, then check whether the write happened.\n\n`WHERE balance >= amount`\n\n`ON CONFLICT DO NOTHING`\n\n+ `EXISTS(gate)`\n\n`WHERE status = 'pending'`\n\nRow count becomes your concurrency primitive. You don't need transactions for any of this — you need every operation to be one statement, and every invariant to live inside it.\n\nThree things I'd tell myself at the start:\n\nThe system this came from is [T-Shirt Design AI](https://tshirtdesignai.com), which turns a text prompt into print-ready t-shirt artwork. The billing is the least visible part of it and easily took the most debugging.\n\nIf you're building credit billing on Neon, Supabase, or anything else where transactions are awkward — steal the CTE gate pattern. It's the one that saved me the most grief.", "url": "https://wpnews.pro/news/credit-billing-without-transactions-4-race-conditions-i-hit-on-serverless", "canonical_source": "https://dev.to/xiaojun_mao_c154743594bc9/credit-billing-without-transactions-4-race-conditions-i-hit-on-serverless-postgres-4730", "published_at": "2026-08-03 03:30:24+00:00", "updated_at": "2026-08-03 04:15:53.192618+00:00", "lang": "en", "topics": ["ai-products", "developer-tools", "ai-infrastructure"], "entities": ["Neon", "Stripe", "Postgres"], "alternates": {"html": "https://wpnews.pro/news/credit-billing-without-transactions-4-race-conditions-i-hit-on-serverless", "markdown": "https://wpnews.pro/news/credit-billing-without-transactions-4-race-conditions-i-hit-on-serverless.md", "text": "https://wpnews.pro/news/credit-billing-without-transactions-4-race-conditions-i-hit-on-serverless.txt", "jsonld": "https://wpnews.pro/news/credit-billing-without-transactions-4-race-conditions-i-hit-on-serverless.jsonld"}}