I maintain a clinician session product on Convex that mixes live chat with async speech-to-text from a vendor STT API. Rate limiting guards two buckets: one per conversation thread and one global cap for the whole deployment. Under load the team kept hitting STT denials that made no sense after the global window reset. A thread that should have been fresh looked permanently exhausted.
In the session UI, STT is a mic action on an active visit. Ops watches vendor quota in one dashboard and Convex rate-limit keys in another. When global refilled but a single thread still read as blocked, support assumed the clinician had spammed the button. They hadn't. The thread bucket had been charged on a denied request and never refunded.
The mutation called @convex-dev/rate-limiter in order: limit on the per-thread bucket, then limit on global. When global was empty, the first call still consumed a thread token. Global denied the request. The thread bucket never got that token back. After global reset, global allowed traffic again but the thread still read as spent. Operators saw "rate limited" on sessions that had barely used STT.
limit is not atomic across two buckets
Each limit call is its own consume. There is no cross-bucket transaction. Checking global after spending thread feels efficient. It leaks quota on the soft-deny path. throws: false makes it worse because you have to remember to refund manually, and Convex mutations do not give you an easy two-phase commit across two limiter keys.
I reproduced it in tests by filling global from other threads, then calling STT on a new thread. Per-thread limit succeeded. Global limit returned false. The thread token stayed gone.
Read the failure as a debit card hold, not a single wallet. You can authorize a small charge on the session card, then get declined on the daily vendor cap. Without a rollback, the session card still shows spent even though no STT audio left the building.
Check both, then limit both, then throw on partial failure
The pattern that held up: probe both buckets with check, only spend when both allow, spend both with limit, and if the second limit fails after the first succeeded, throw so the mutation rolls back the spent token.
const perThread = { name: 'sttPerThread', key: threadId, count: 1 };
const global = { name: 'sttGlobal', key: 'global', count: 1 };
const threadOk = await rateLimiter.check(ctx, perThread);
const globalOk = await rateLimiter.check(ctx, global);
if (!threadOk.ok || !globalOk.ok) {
return { ok: false as const, reason: 'rate_limited' };
}
const threadSpend = await rateLimiter.limit(ctx, perThread, { throws: false });
if (!threadSpend.ok) return { ok: false as const, reason: 'rate_limited' };
const globalSpend = await rateLimiter.limit(ctx, global, { throws: false });
if (!globalSpend.ok) {
throw new Error('Global STT bucket exhausted after thread spend');
}
return { ok: true as const };Throwing on the second failure matters. Convex rolls back the mutation, including the first limit, so the thread token is not stranded. Returning { ok: false } without throw would leave the leak.
Tests now fill global from unrelated threads, assert STT denial does not decrement the target thread's remaining count, reset global in test setup, and confirm the same thread can transcribe again. That sequence caught a refactor where someone swapped check and limit back to limit-first for readability.
Order of checks can follow product priority. I check thread then global because the error message names the session first. Both checks must finish before either limit runs.
Dual buckets are common when you protect a vendor API with a global ceiling and still want fair per-user caps. The mental model is debit-only: never charge the narrow bucket until the wide one agrees, and never swallow a failed second charge.
Happy coding! Sander