Topic 13.2
Quotas and Budgets
In one line
A quota stops one user from spending the whole budget. It is counted and enforced on the server, before the model is called, and the screen shows how much is left, warns near the limit and explains the wait at the limit with a 429 and a retry-after header.
Think of it like this
A canteen coupon book with three coupons a day. The cashier tears one coupon before the cook starts, not after, and keeps the book at the counter. You can see how many are left, and the last one is printed in red. If you draw extra coupons yourself, the cashier does not accept them. The count belongs to the cashier, and the customer only gets to look at it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Quota
- A limit per user over a period, such as 20 questions per day.
- Budget
- The total money or tokens a feature may spend. Quotas protect it.
- 429 Too Many Requests
- The HTTP status that means "slow down, you hit a limit".
- retry-after
- A response header saying how long to wait: seconds (
3600) or an HTTP date. - Atomic
- Done as one indivisible step, so no other request can slip in between the check and the update.
- Race condition
- A bug where the result depends on which of two things happens first. Two requests both pass a check before either updates the count.
- Refund
- Giving the question back (decrementing the count) when the model failed and the user got nothing.
- Rolling window
- A limit over "the last 24 hours" instead of "today". Fairer, and harder to count.
Step by step
01The route decides
The route is the only place that knows the truth. It reads the user, the count and the limit, and it either refuses (429) or counts and calls the model. Everything the page shows comes back from this route in headers.
02Three states on the screen
The first live example runs the whole story. After question 1 the meter says "2 of 3 questions left today". After question 2 it adds "Last question for today." After question 3 the button is disabled and the message says "Try again in 1 hour."
Then it shows a second user, Ravi, whose three questions were used on another device. His page starts with no knowledge: "Questions today: ready". His first click is refused with a 429, the server logs the refusal, and the page jumps to the at-limit state with the wait read from retry-after.
03How the page reads headers
useChat does not hand you response headers, so the example gives DefaultChatTransport a small fetch wrapper. It looks at the headers of every response on the way past (x-quota-used, x-quota-limit, and for a 429 the retry-after) and stores them in state. The real response is returned unchanged.
04Races, forged headers and refunds
The second example fires five requests at once with a limit of 2. Version A (check, wait, count) lets all five through and counts 5. Version B (count at once) answers 200 200 429 429 429 and counts 2.
It then sends a forged x-quota-used: 0 header: the server ignores it and still answers 429 with retry-after: 3600, because the page never decides. With the model down, the route answers 502 and gives the question back (counted 0).
05Reading retry-after safely
The last lines parse the header's two formats against a fixed "now": 3600 gives 3600, an HTTP date five minutes ahead gives 300, a date in the past gives 0, and text like soon or a missing header gives null. The page can then choose its words: "Try again in 5 minutes" or just "Try again later".
Try it yourself
- 1
Raise the limit
In the first example change
LIMITto5. Press Run. The meter and the near-limit warning follow the number, because the page reads everything from the server's headers. Nothing else changes. - 2
Make the page lie
In the first example's
App, change the 429 branch tosetQuota({ used: 0, limit: LIMIT }). Ravi's page now claims he has 3 left, he clicks again, and the server refuses again. The server is the truth, and the page can only look. - 3
Count tokens, not requests
In the second example's version B, set
LIMITto300, replacecount + 1withcount + 120(imagine 120 is the estimated cost of one question), and change the refund line to subtract120. Run it: the five requests answer200 200 200 429 429and the counter ends at 360, over the budget of 300. The third request saw 240 used, which is under 300, so it was let in. Now change the check toif (count + 120 > LIMIT): the answers become200 200 429 429 429and the counter ends at 240. A token budget must check count plus this request's cost, not just the count. Reserve the estimate first, and correct it with the real usage when the answer ends.
Code & diagrams
The stand-in model answers, the counter and the 429 are real code. The limit of 3 is made up. A real app would also reset the counter at the end of the day.
Preview
Console
Nothing logged yet.Expected console output
after question 1 | 2 of 3 questions left today | near: - | limit: - | button disabled: false
after question 2 | 1 of 3 questions left today | near: Last question for today. | limit: - | button disabled: false
after question 3 | No questions left today | near: - | limit: You have used all 3 questions for today. Try again in 1 hour. | button disabled: true
ravi, before asking | Questions today: ready | near: - | limit: - | button disabled: false
(server) refused ravi at 3 of 3
ravi, after the server said no | No questions left today | near: - | limit: You have used all 3 questions for today. Try again in 1 hour. | button disabled: trueVersion A is the bug. The counts are exact because the handler runs one step at a time: there is no await between the check and the count in version B.
Preview
Console
Nothing logged yet.Expected console output
A, 5 requests at once, limit 2 → 200 200 200 200 200 | counted: 5
B, 5 requests at once, limit 2 → 200 200 429 429 429 | counted: 2
B, with a forged x-quota-used: 0 → 429 | retry-after: 3600
B, model down → 502 | counted: 0 (not charged)
retry-after "3600" → 3600
retry-after "Wed, 21 Oct 2026 07:05:00 GMT" → 300
retry-after "Wed, 21 Oct 2026 06:00:00 GMT" → 0
retry-after "soon" → null
retry-after null → nullBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Count after the model call
In the second example's version B, delete the line usedB.set(user, count + 1); before the try, and put usedB.set(user, count + 1); right after the line await generateText(...) inside the try.
Break #2
Trust the page and skip the server check
In the first example change if (count >= LIMIT) { to if (false) {.
Myth vs fact
Myth
A 429 means my server is broken.
Fact
A 429 is a normal, deliberate answer: "you are over a limit". It should carry retry-after, and the page should explain it in plain words.
Myth
I can keep the user's count in the browser.
Fact
The browser is controlled by the user. A count in localStorage is reset with one click. The real counter lives on the server.
Myth
Rate limiting and quotas are the same thing.
Fact
A rate limit protects the service from bursts (for example 5 requests per second). A quota protects the budget over a longer period (for example 20 questions per day). A product usually needs both.
Myth
Refused requests still cost money.
Fact
If the route refuses before calling the model, the request costs only a few milliseconds of your own server.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Reset time is a product choice. "Midnight UTC" is easy and confusing, "24 hours after the first question" is fair and needs a timestamp per user. Whatever you choose, send the number of seconds in the response so the page does not guess time zones.
- ▸
Put a separate, small quota on expensive features (long documents, image input, tools that call other services) so one costly feature cannot eat the whole daily allowance.
- ▸
Alert yourself when the total spend per hour passes a threshold, and have a kill switch (Topic 13.3) ready. Per-user quotas do not protect you from a bug that makes every user's requests expensive at once.
- ▸
Decide in advance what happens when the counter store itself is down (Redis unreachable). Fail closed (refuse, with a clear message) protects the budget and makes an outage of the store an outage of the feature. Fail open (allow, and alert) keeps users working and lets a bug or an attacker spend freely. With money at stake, many teams fail closed for anonymous users and open for paying ones, for a short time, with an alarm.
- ▸
Prefer the server's number of seconds over comparing an HTTP-date
retry-afterto the browser clock: a wrong clock on the user's device gives a wrong wait. See/system-designfor token-bucket and sliding-window rate limiting.
Remember this
- 1
The problem this solves: Every question costs money, and one user (or one script) can ask thousands. Without a limit, a single account can empty the month's budget in an afternoon. And a limit that is only drawn on the page can be bypassed with one line of code. This topic puts the limit on the server and shows it honestly on the screen.
- 2
A quota is a limit per user over a period, such as 20 questions per day. A budget is the money (or token) limit for a feature or the whole product. Quotas protect the budget. Both can be counted in requests (simple to explain: "3 questions a day") or in tokens (closer to the real cost, harder to explain). Start with requests. Add a token cap per request (Topic 13.1) so one request cannot be enormous.
- 3
Enforce on the server, before the model is called. The route reads who is asking, looks up their count, and refuses with 429 Too Many Requests when the count is at the limit. A 429 should carry a
retry-afterheader: the number of seconds to wait, or an HTTP date. The model is never called for a refused request, so a refused request costs nothing. (Recall from Topic 3.6: the page side of a 429 is a countdown and a disabled button, and a retry loop must not burn the quota.) In a real app the counter lives in a database or Redis, one row per user per day, not in a JavaScript variable. - 4
Count and check in the same step. The common bug is: check the count,
awaitthe slow model call, then add one. Five requests that arrive together all pass the check before any of them has been counted (second live example, version A: all five get 200). The fix is to increment immediately, with noawaitbetween the check and the increment (or an atomic database operation such as an increment that fails above the limit). If the model then fails, give the question back so the user is not charged for nothing. - 5
Show the limit in three states. Normal: a quiet "2 of 3 questions left today". Near the limit: a clear sign, "Last question for today". At the limit: the input is disabled and a message says what happened and when it ends ("Try again in 1 hour"). The page learns the numbers from response headers (
x-quota-used,x-quota-limit,retry-after) that the server sends on every answer, so the display is always the server's truth. The first example shows a user whose count was used up on another device: the first click gets a 429 and the page catches up. - 6
Anonymous users and shared accounts. A quota needs an identity. A signed-in user id is best. For visitors you can use a cookie or the network address, both weak: cookies are cleared and addresses are shared (an office, a mobile network). Give anonymous users a smaller limit and ask for sign-in to get more. Never put the quota in
localStorageas the real counter. - 7
**
retry-afterhas two formats.** It is either a number of seconds (3600) or an HTTP date. A client that reads only the number breaks on the date form. The second example parses both, and treats a missing or unreadable value as "unknown" so the page can say "try again later" instead of showingNaN.
Explain it without notes
Why must a quota be enforced on the server, and what is the page for?
Describe the race condition in "check, await, count" and how to fix it.
What does a good 429 response contain, and how should the page use it?
How would you give anonymous visitors a limit, and what are its weaknesses?
Practice
In the first example add a thin bar under the meter: its width is quota.used / quota.limit in percent, orange when one question is left, red at zero. Give it role="progressbar" with the right aria-valuenow.
In the second example add a version C route that takes x-tokens (the estimated token cost) and a limit of 300 tokens per user. Return 429 when used + tokens > 300 and otherwise add the tokens at once. Do not trust the browser for the number: compute it on the server.
Trade-offs
- ↔
Counting requests is easy to explain ("3 questions a day") but treats a one-word question and a ten-page paste the same. Counting tokens matches cost better and is harder for users to understand. Many products count requests and cap each request's size.
- ↔
A tight quota protects the budget and frustrates real customers. A loose one is friendly and risky. Start tight for anonymous users and loose for paying ones, and watch the usage table before changing numbers.
Done when you can
I can explain why the server, not the page, owns the quota.
I can write a route that checks and counts in one step and returns 429 with
retry-after.I can show the normal, near-limit and at-limit states on screen from response headers.
I can explain the check-await-count race and fix it.
I can parse both forms of
retry-afterand survive a missing one.