Command Palette

Search for a command to run...

Hectal
PHASE 13Advanced ~30 min· topic 2 of 8

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.

The route decidesdiagram
Rendering diagram…

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. 1

    Raise the limit

    In the first example change LIMIT to 5. 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. 2

    Make the page lie

    In the first example's App, change the 429 branch to setQuota({ 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. 3

    Count tokens, not requests

    In the second example's version B, set LIMIT to 300, replace count + 1 with count + 120 (imagine 120 is the estimated cost of one question), and change the refund line to subtract 120. Run it: the five requests answer 200 200 200 429 429 and 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 to if (count + 120 > LIMIT): the answers become 200 200 429 429 429 and 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

A daily limit: meter, last-question warning, the 429, and a second device

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

Press Run to see the page.

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: true
Server enforcement: a race, a forged header, a refund, and retry-after

Version 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

Press Run to see the page.

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 → null

Break 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.

terminal
$ Console
── what you'll see ──
B, 5 requests at once, limit 2 → 200 200 200 200 200 | counted: 1

Break #2

Trust the page and skip the server check

In the first example change if (count >= LIMIT) { to if (false) {.

terminal
$ Console, last line
── what you'll see ──
ravi, after the server said no | -1 of 3 questions left today | near: - | limit: - | button disabled: 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-after to the browser clock: a wrong clock on the user's device gives a wrong wait. See /system-design for token-bucket and sliding-window rate limiting.

Remember this

  1. 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. 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. 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-after header: 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. 4

    Count and check in the same step. The common bug is: check the count, await the 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 no await between 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. 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. 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 localStorage as the real counter.

  7. 7

    **retry-after has 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 showing NaN.

Explain it without notes

01

Why must a quota be enforced on the server, and what is the page for?

02

Describe the race condition in "check, await, count" and how to fix it.

03

What does a good 429 response contain, and how should the page use it?

04

How would you give anonymous visitors a limit, and what are its weaknesses?

Practice

01

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.

02

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-after and survive a missing one.