BlogMetering

The race condition in "if (usage < limit)" that is costing your AI app money

A user at nine of ten images opens six tabs and clicks generate in all of them. Every tab passes your limit check, every tab gets an image, and you pay for all six. The bug is one read-then-write - and your unit tests will never catch it.

The five lines every AI app ships first

Limit: ten images a month. A user sitting at nine opens your app in six tabs and clicks generate in all of them inside the same second. Every tab gets an image, and you pay your provider for all six, because your limit check looks like the code below: read the user's usage from the database, compare it to the plan limit, call the model, increment the counter on the way out. This is the pattern every AI app ships first, and not because anyone was careless - it is because it looks correct. It compiles, the types check, the unit tests pass, because unit tests run requests one at a time. The bug only exists between two requests, which means it only exists in production, and it costs you provider dollars every time it fires.

// The version every AI app ships first
async function generateImage(userId: string, prompt: string) {
  const row = await db.get(
    'SELECT used FROM usage WHERE user_id = ?', [userId],
  );

  if (row.used >= PLAN_LIMIT) {        // check
    throw new Error('limit_reached');
  }

  const image = await openai.images.generate({ prompt }); // 2-30s

  await db.run(                        // act
    'UPDATE usage SET used = used + 1 WHERE user_id = ?', [userId],
  );
  return image;
}

The interleaving, step by step

Walk it with limit = 10 and used = 9. Request A reads usage and gets 9. Request B arrives a few milliseconds later and reads usage - A has not written anything yet, so B also gets 9. Both compare 9 < 10, both pass the check, both call the model. A finishes and writes used = 10. B finishes and writes used = 11 - or worse, if the write is computed from the value read earlier (SET used = 10), B silently overwrites A and the counter records one event when two happened. This is a textbook TOCTOU race: time-of-check to time-of-use. The check produced a snapshot of the counter, and the snapshot was stale by the time the code acted on it. Re-checking does not help - a second read just before the increment is another snapshot with the same flaw, and shrinking the window only changes how often you lose, not whether you can. The only real fixes make the check and the increment a single indivisible operation.

Why AI apps lose more to this than rate limiters ever did

Classic rate limiting has the same race and mostly nobody cares, because the window between check and increment is microseconds and the cost of an overshoot is a little extra server load. AI inference changes all three variables. First, the window: an LLM stream or an image render takes 2 to 30 seconds, so the vulnerable gap is not a lucky microsecond collision - it is a barn door that any two requests in the same half-minute walk through together. Second, the cost: every request past the limit is a real line on your provider invoice, not amortized CPU. At fifty cents per video render, every lost race is a measurable loss. Third, the incentive: your users benefit from the bug. A free-tier user who notices that parallel tabs beat the limit will use parallel tabs, and the determined ones write a script that fires twenty requests through Promise.all and collects twenty results on a ten-image plan. You are not defending against bad luck; you are defending against arbitrage.

Fix #1: push the check into the database

The database can do the check and the increment in one statement, which closes the race completely. Issue a conditional UPDATE that increments only while the counter is still under quota, then read the affected-row count: one row means the quota was held, zero means the user is at the limit. When two requests race, the database serializes them on the row lock and exactly one wins. In Postgres you can get the same guarantee with SELECT ... FOR UPDATE inside a transaction when you need to read the row before deciding. The trade-offs are real but honest. Every request from the same user now serializes on one hot row, which is fine at human speeds and noticeable for high-frequency API traffic. And you now own the schema around the statement: a counters table keyed by user and period, the rollover logic that decides when a fresh period row starts, the quota lookup that joins the user's current plan, and the migration when pricing changes. The race fix is one line of SQL; the bookkeeping around it is the actual project.

// One statement - the check and the increment cannot be separated
const result = await db.execute({
  sql:
    'UPDATE counters SET used = used + 1 ' +
    'WHERE user_id = ? AND period_start = ? AND used < quota',
  args: [userId, periodStart],
});

if (result.rowsAffected === 0) {
  throw new Error('limit_reached'); // no row matched: at quota
}

const image = await openai.images.generate({ prompt });
// If the call fails, you already counted it. The refund
// decrement in your catch block is your problem now - see fix #3.

Fix #2: Redis atomic increment

Redis INCR is atomic, so the classic move is to increment first and inspect the returned value: if it came back over the limit, decrement and refuse. That works, and a small Lua script makes it tidier by doing the check and the conditional increment in one round trip - Redis executes scripts atomically, so nothing interleaves. The cost is operational rather than logical. You have added a second piece of infrastructure whose durability now matters: with default persistence settings, a restart or an eviction under memory pressure erases counters, and an erased counter means every affected user silently gets a fresh quota. You also still own the period problem - the key needs the billing period baked into it, or a TTL that expires exactly at the cycle boundary, and TTLs that drift from the billing date reintroduce the misalignment you were trying to engineer away. Fast and correct, yes. Free of bookkeeping, no.

If Redis is your only copy of the counters, configure AOF persistence and a noeviction policy for the usage keys. A cache-tuned Redis that quietly drops your counters under memory pressure is a quota giveaway you will not notice for months.
// Lua: check + increment as one atomic unit inside Redis
const GATE =
  'local used = redis.call("INCRBY", KEYS[1], ARGV[1]) ' +
  'if used > tonumber(ARGV[2]) then ' +
  '  redis.call("DECRBY", KEYS[1], ARGV[1]) return 0 ' +
  'end return 1';

const key = 'usage:' + userId + ':2026-06'; // period in the key
const allowed = await redis.eval(GATE, 1, key, '1', '10');
if (allowed === 0) throw new Error('limit_reached');

Fix #3: reserve before the call, settle after

Both fixes above share a quieter flaw: they count the event before the AI call runs, and AI calls fail. Provider timeouts, content-filter refusals, malformed responses - in every case the user paid quota for nothing, and the refund decrement in your catch block is precisely the line that never executes when your process dies mid-call. The structurally correct shape for a slow, expensive, failure-prone operation is a reservation. Reserve atomically checks and holds the quota before the call - the same indivisible check-and-increment as fix #1, so the race stays closed. Then run the AI call. On success, commit the reservation and the hold becomes a permanent count. On failure, release it and the quota returns to the user instantly. If your process crashes between reserve and either verdict, a TTL auto-releases the hold - 60 seconds is a good default, long enough to outlast almost any real inference call, short enough that a crashed worker leaks quota for one minute at most. It is the transaction pattern applied to a counter: hold, do the work, then confirm or roll back. Fixes #1 and #2 close the race; only this one also closes the refund.

The pattern in practice

Here is the reservation flow as application code, using @vevee/sdk, where reserve, commit, and release are single methods backed by a hosted atomic counter. reserve returns an allowed flag and a reservation id; commit confirms the hold after the provider call succeeds; release in the catch block hands the quota straight back. Note what the error path costs the user: nothing. A failed render is a released reservation, not a burned credit - and if the release itself never runs because the worker died, the TTL covers that too.

import { createClient } from '@vevee/sdk';

const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });

export async function generateImage(userId: string, prompt: string) {
  const r = await vevee.reserve(userId, 'image_generation', 1);
  if (!r.allowed) throw new Error('limit_reached'); // show the paywall

  try {
    const image = await openai.images.generate({ prompt });
    await vevee.commit(r.reservationId!);  // hold becomes a count
    return image;
  } catch (e) {
    await vevee.release(r.reservationId!); // quota back, instantly
    throw e;
  }
}

When check-then-act is honestly fine

Not every limit deserves this machinery. If you are gating a cheap text model at a fraction of a cent per call, the worst-case overshoot from the race is a rounding error on your bill. If your free tier allows fifty generations and a racing user lands on fifty-two, nothing meaningful happened - that limit exists to shape behavior, not to enforce a contract to the cent. In those cases a plain check-then-track pair is easier to read, easier to debug, and one fewer failure mode, and the overshoot is bounded by how many requests the user can genuinely have in flight at once. Reserve where the units are expensive - video seconds, premium image models, credit packs the user paid real money for - and where an overshoot is a broken promise rather than noise. Let the dollar value of one duplicate request pick the pattern, not the elegance of the pattern itself.

Decide once, per event type

The decision rule fits in a sentence: if one event past the limit costs real money or breaks a paid promise, use reserve/commit/release; otherwise an atomic conditional increment - in your database or in Redis - is enough. The naive read-compare-act pattern is never the right answer for anything you bill on. Whichever fix you choose, the remaining work is the same and it is most of the work: counters keyed by user and period, rollover at each cycle boundary, quota lookups per plan, and a refund path that survives crashes. Vevee implements reserve/commit/release - along with those counters, periods, and plan definitions - as a drop-in API with a free tier, if you would rather wire two method calls than own that schema.

See your own numbers in two minutes.Add the SDK and your users, costs and revenue fill in on the next request.
Join waitlist

Keep reading

ShareCopy a link to this post.

Set your first limit in about five minutes.

50k operations free every month. No card required.
Join waitlist