I maintain a clinician session product that mixes live chat with async speech-to-text from a vendor STT API. Convex 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.
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.
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