DEV Community

Meterbase for Meterbase

Posted on

Two requests, one last credit: the race condition in usage-based billing

Here's the most common way to gate paid work:

const credits = await getCredits(customerId)   // 10 left
if (credits < cost) throw new Error("Out of credits")

const result = await runJob(input)              // takes 20 seconds

await deductCredits(customerId, result.cost)
Enter fullscreen mode Exit fullscreen mode

It reads fine and works in testing. In production, a customer opens two tabs, or an agent fires five tool calls in parallel, and every request reads the same balance before any of them writes. They all pass. The customer ends the day with a negative balance, and you paid for the compute.

Why a transaction doesn't fix it

Wrapping the read and the deduct in a transaction doesn't help when the job runs in between, because you can't hold a database transaction open for 20 seconds while a GPU works. And if you deduct up front, you still have to handle jobs that cost less than you estimated, jobs that fail, and workers that crash before refunding.

Step 1: make check-and-deduct atomic

The first fix is to let the database do the comparison:

UPDATE balances
SET credits = credits - $2
WHERE customer_id = $1
  AND credits >= $2
RETURNING credits;
Enter fullscreen mode Exit fullscreen mode

If no row comes back, the customer couldn't afford it. Two concurrent requests can't both take the last credits, because the second update sees the first one's write.

That works when you know the cost up front. For AI work you usually don't: a reply's token count, a render's length or a scrape's page count is only known after the job finishes.

Step 2: hold an estimate, settle the actual

The pattern that handles unknown costs is a hold, like a card hold at a hotel:

  1. Before the work, atomically set aside an estimate. The customer's available balance drops by that amount, so a parallel request can't use it.
  2. Run the work.
  3. Commit the real cost. Whatever's left of the hold goes back.
  4. If the work fails, release the hold and record nothing.
  5. If the process dies and nobody commits or releases, the hold expires on its own and the capacity comes back.

That last step is the one teams forget. Without expiry, every crashed worker permanently eats part of a customer's balance.

A minimal holds table looks something like this:

CREATE TABLE holds (
  id          uuid PRIMARY KEY,
  customer_id text NOT NULL,
  quantity    bigint NOT NULL,
  expires_at  timestamptz NOT NULL,
  closed_at   timestamptz
);
Enter fullscreen mode Exit fullscreen mode

"Available" then becomes the allowance, minus usage, plus unspent top-ups, minus open holds that haven't expired. Every reserve has to compute that and insert the hold atomically for that customer, which in practice means a row lock or serializable isolation per customer.

Step 3: decide which work needs a hold

Holds cost a write and make concurrent requests for the same customer wait their turn. Not everything needs one. A useful rule:

  • Cheap work you can afford to overshoot slightly, like a chat reply against a million-token allowance: check, run, record. Two replies racing past the check overshoot by a few thousand tokens.
  • Expensive work you can't afford to do twice, like image batches, video renders or long agent runs: hold, run, then commit or release.

What this looks like in Meterbase

We built Meterbase so this is a couple of calls. For expensive work:

const hold = await meterbase.reserve({
  customer_id: "acct_1",
  meter_id: "image_generations",
  quantity: 4,
  expires_in_seconds: 120, // longer than the work takes
})
if (!hold.allowed) throw new Error(`Refused: ${hold.reason}`)

const images = await generateImages(prompt, 4).catch(async (error) => {
  await hold.release() // the work failed: record nothing
  throw error
})

await hold.commit(images.length) // the rest of the hold comes back
Enter fullscreen mode Exit fullscreen mode

Two concurrent reserves can't be granted the same capacity, and a hold nobody closes expires after expires_in_seconds (1 to 900, default 60). For cheap work you'd use check and track instead, which hold nothing.

If you're building this yourself, the SQL above is a solid start. If you'd rather not own it, take a look at meterbase.tech. We're happy to help you set it up.

Top comments (0)