Command Palette

Search for a command to run...

Hectal
PHASE 3Intermediate ~29 min· topic 6 of 7

Topic 3.6

Rate Limits in the UI

In one line

A 429 means "too many requests, wait". It is the most common AI error and the one a user can fix by waiting. Read the Retry-After header, show a countdown, lock the button until it ends, and never let a retry loop burn through the allowance.

Think of it like this

A busy bakery gives out numbered tickets and says "come back in ten minutes". If you stand at the counter asking every few seconds, you do not get bread sooner, and you slow down everyone else. A rate limit is that instruction. The kind reply is to leave, come back at the time you were given, and the page should say when that is.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Rate limit
A cap on how many requests (or tokens) may be used in a period of time.
429
The HTTP status "Too Many Requests". The request was refused because a limit was reached.
Retry-After
A response header that says how long to wait before trying again, as seconds or as a date.
Quota
An allowance for a longer period, such as the number of questions per day.
Countdown
A number on screen that falls to zero. Here it shows when asking is possible again.
Queue
A list of requests that wait their turn instead of being sent at once.
Per-user limit
A limit counted for each signed-in user, so one user cannot use up the allowance of all.
Thundering herd
Many clients retrying at the same moment because they were all told the same wait.

Step by step

01Read Retry-After in both forms

The first example parses the header. Seconds are used as they are. A date is turned into seconds from now. A date in the past gives 0. Missing or unreadable values give null, and a huge number is capped at an hour.

It then writes a different sentence for three different 429s: one with a wait, one without, and one that says the daily limit is gone.

02A countdown that locks the button

The second example is Mitra's button. The first press gets a 429 with Retry-After: 2. The button shows "Try again in 2 s", then "1 s", and is enabled again after that. The visitor clicks while it is locked and nothing happens: neither the disabled attribute nor the guard in ask() lets it through.

Each second is its own setTimeout, scheduled by the effect that depends on secondsLeft. When the component leaves the page the cleanup cancels it.

A countdown that locks the buttondiagram
Rendering diagram…

03Polite and impatient retries

The third example lets four users ask at the same moment against a server that allows two requests per window. The impatient loop retries at once and sends 20 requests in total. The polite one waits for Retry-After and sends 6, and all four users are answered.

04Queue or refuse

The fourth example puts a small gate in front of the network. With room for one waiting question, the third and fourth are refused with a clear message. With room for three, everything is answered in order.

Try it yourself

  1. 1

    Remove the guard, keep the disabled button

    In the second example delete || secondsLeft > 0 from the first line of ask. The scripted click at 400 ms uses element.click().

    The output is exactly the same four lines. A disabled button ignores click() itself, so you see no difference here. That is why the guard is still worth having: other code paths (a keyboard shortcut, a form submit) do not go through the disabled button.

  2. 2

    Give the header a date

    In the first example, replace the with-header route with serve("/api/with-header", () => Response.json({ error: { message: "Rate limit reached" } }, { status: 429, headers: { "retry-after": "Tue, 13 Oct 2026 10:00:45 GMT" } }));.

    The with-header line now says Mitra is busy. Try again now.: that date is already in the past, so the parser (correctly) returns 0, and the wait === 0 branch avoids the awkward "in 0 seconds". Delete that branch and you get exactly that sentence. Real servers send a date a few seconds ahead, and the page computes the seconds from the current time on every response. The fixed now in the parsing demo above it exists only to keep that part repeatable.

  3. 3

    Make it queue less

    In the fourth example change 3 to 2 for the queue case (and the label to match).

    Q1 to Q3 are answered and Q4 is refused: the number you choose is the longest wait you are willing to ask of a user.

Code & diagrams

Reading Retry-After and choosing the message

The date example passes a fixed "now" so the result is the same every run; real code uses the current time. The daily case sends a Retry-After too, and the body code decides to ignore it: a countdown of an hour would be a poor message.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

seconds:        30
HTTP date:      45
date in past:   0
missing:        null
garbage:        null
one week:       3600 (capped)
/api/with-header  -> Mitra is busy. Try again in 30 seconds.
/api/no-header    -> Mitra is busy. Please try again in a little while.
/api/daily        -> You have used today's questions. Come back tomorrow.
A countdown that locks the button

The second click at 400 ms lands while the button is locked and is ignored. The third, after the countdown, gets the answer.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

button locked, seconds left: 2
button locked, seconds left: 1
button ready again
answer: Orders ship in 1 to 2 working days.
Four users, one limit: impatient versus polite retries

The window is 0.8 s here so the example is quick. The impatient users sent over three times as many requests and half of them still gave up; the polite ones were all answered. In production the extra refused requests also count against your logs, your limits and sometimes your bill.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

impatient | results: answered, answered, gave up, gave up | requests that reached the server: 20
polite    | results: answered, answered, answered, answered | requests that reached the server: 6
Queue a short burst, refuse a long one

Q1 starts at once, so with room for one waiting question the gate holds Q2 and refuses Q3 and Q4. Whether to queue or refuse is a product choice: the code is the same size.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

refuse early: only 1 question may wait
  Q1: Answer to: Q1
  Q2: Answer to: Q2
  Q3: refused (Too many questions at once. Wait for the current answer.)
  Q4: refused (Too many questions at once. Wait for the current answer.)
queue: up to 3 questions may wait
  Q1: Answer to: Q1
  Q2: Answer to: Q2
  Q3: Answer to: Q3
  Q4: Answer to: Q4

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

Ignore Retry-After in the retry loop

In the third example, change await sleep(Number(res.headers.get("retry-after")) * 1000); to await sleep(0); in polite.

terminal
$ The console, the polite line
── what you'll see ──
polite | results: answered, answered, gave up, gave up | requests that reached the server: 8

Myth vs fact

Myth

A 429 means the server is broken.

Fact

It means the server is working and has refused you on purpose. Waiting fixes it. Treat it differently from a 500.

Myth

Retrying quickly will get through sooner.

Fact

Until the window resets, every try is refused. Quick retries only add load, and may count against your allowance.

Myth

A disabled button is enough protection.

Fact

Other paths reach the same function. Keep the guard in the handler too.

Myth

Every 429 has a Retry-After.

Fact

Many do, some do not, and the value may be a date. Always have a default for a missing or unreadable value.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Providers often return headers that describe the limit (remaining requests, reset time) on every response, not only on 429. They are not standard across providers, so check the documentation of the one you use. When available, you can warn the user before the limit is hit.

  • ▸

    Per-user limits belong on your server: an authenticated identity (a user id) is a better key than an IP address, which is shared by offices and mobile networks. Return the same 429 shape so the page needs one code path, with a body code that separates "slow down" from "quota used up".

  • ▸

    Add jitter to the wait you show to clients when many may be refused together (for example a few seconds more or less). If a thousand users are told "30" at the same moment, they all return at second 30 together.

  • ▸

    Count 429s as a metric by cause (provider, per-user, daily). A growing provider-side count is a sign to raise your account's limits or to add a queue; a per-user count may show a client with a bug that loops.

Remember this

  1. 1

    The problem this solves: Providers and your own server limit how many requests a key or a user may make per minute or per day. When the limit is hit, the answer is HTTP 429 Too Many Requests. If the page shows "Something went wrong" and a Retry button, users press it again and again, each press is refused, and some of those refusals still count against the allowance. This topic turns a 429 into a clear message, a countdown and a safe way to continue.

  2. 2

    Where limits come from. Your provider limits the whole account: requests per minute, tokens per minute, sometimes tokens per day. Your own server should add limits per user, so one person cannot spend everyone's allowance (Topic 1.5 builds your own route, Topic 13.2 covers quotas and budgets, and /system-design explains rate limiting algorithms). Either can answer 429. The text of the error often says which: a user over their daily quota needs a different message than a busy minute.

  3. 3

    **Retry-After.** A well-behaved 429 carries a Retry-After header that says when to try again. It is either a number of seconds (30) or an HTTP date (Tue, 13 Oct 2026 10:00:45 GMT). Read both forms, treat anything else as missing, and cap the number so a header saying 604800 (a week) does not lock the page for a week. When the header is missing, use a default such as 5 to 10 seconds, and say "in a little while" rather than inventing a number.

  4. 4

    Show a countdown. "Try again in 12 s" on the button, plus a sentence in role="status" that does not announce every second (announce the start and the end). Keep the button disabled until the countdown reaches zero, and make the click handler refuse as well: a disabled button is a hint, the guard in the handler is the rule. Schedule the next tick from the effect that depends on the seconds left, and clear it in cleanup, so leaving the screen stops the timer.

  5. 5

    Queue or refuse? If the user types faster than the limit allows, you can queue questions and send them one at a time (the user waits, but nothing is lost) or refuse extra ones with a message (nothing waits, the user decides). Queue short bursts of up to a few items; refuse beyond that, because a long queue makes the user wait for answers to questions they have already forgotten. Show the queue so it is never a mystery.

  6. 6

    Never let a retry loop burn the allowance. A loop that ignores Retry-After multiplies requests while the limit is exceeded, and in many systems rejected requests still count. The example below shows four users with an impatient loop sending 20 requests where a polite one sent 6. Retry once after the stated wait; if it fails again, stop and tell the user. A daily limit is different from a per-minute limit: do not offer a countdown when the answer is "come back tomorrow".

Explain it without notes

01

What does Retry-After look like, and how should the client handle each form?

02

Why does a retry loop that ignores Retry-After make things worse?

03

How would you design the button and the message for a per-minute limit versus a daily limit?

04

When would you queue questions and when would you refuse them?

Practice

01

Make the second example show a "Mitra is busy" message for 5 seconds when the 429 has no Retry-After, and use the capped number when the header is a huge value.

02

Change the countdown so that it re-enables the button at the exact end time, even if the tab was in the background and its timers were slowed.

Trade-offs

  • ↔

    A countdown is friendly and automatic, but it promises that asking will work when it ends. Another user may have used the allowance in the meantime, so the next try can fail again and the page must handle that.

  • ↔

    Locking the whole page during a countdown is simple. Letting the user keep typing while only Send is locked is kinder, and needs the text to stay in the field.

Done when you can

  • I can parse Retry-After as seconds or as a date and cap it.

  • I can show a countdown that locks the button and the handler.

  • I can tell a per-minute limit from a daily quota in the message.

  • I can explain why retries must wait for the time the server named.

  • I can choose between queuing and refusing and say why.