Retry After

Turns a 429/503 into a clear wait: which limit was hit, when it resets, a countdown, and one automatic retry instead of impatient re-clicks.

StopsRetry spam extends the lockout

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/retry-after.json

The files land in your project. They're yours to edit.

Usage

retry-after-usage.tsx
import { RetryAfter, type RateLimitResponse } from "@/components/ui/retry-after"

<RetryAfter defaultResponse={limited(12)} onAction={send} disabled={!message.trim()}>
  Send message
</RetryAfter>

What it catches

  • Retry-After as delay-seconds or an HTTP-date
  • IETF RateLimit / RateLimit-Policy fields and common X-RateLimit headers
  • Repeated clicks during the wait, swallowed with a gentle note
  • Automatic retry with jitter, capped by maxAutoRetries
  • Fallback backoff when the server gives no hint

Props

RetryAfter props
PropTypeDefault
onAction* (required)

Sends the request. Resolve with the Response (or `{ status, headers }`); a 429/503 starts the wait.

() => unknown—
children* (required)

Button label, e.g. "Send message".

React.ReactNode—
defaultResponse

Start already limited, e.g. when the page loaded after a 429.

RateLimitResponse—
autoRetry

Retry by itself when the wait ends (with jitter, never early).

booleantrue
maxAutoRetries

Automatic retries before handing control back to the user.

number3
noun

What is being limited, used when the server doesn't name its policy ("messages").

string"requests"
onRateLimit

Called on every 429/503 with what was parsed.

(info: RateLimitInfo) => void—
waitingLabel

Templates. `{time}` is the countdown, `{at}` when it will send, `{reset}` when the server's limit resets, `{limit}`, `{noun}`, `{per}` describe the limit.

string"Retry in {time}"
pendingLabel

Button label while the request runs.

string"Sending…"
tooManyText

Explains a 429 (too many requests).

string"Too many attempts. You can try again in {time}."
busyText

Explains a 503 (service busy).

string"The service is busy right now. Try again in {time}."
limitText

Names the limit that was hit, when headers say.

string"The limit is {limit} {noun} per {per}. It resets at {res…
keptText

Reassures that the user's input was kept.

string"Nothing you typed is lost."
autoText

Says an automatic retry is scheduled.

string"We’ll send it automatically."
readyText

Announced when the wait is over.

string"You can send again now."
exhaustedText

Shown when automatic retries are used up.

string"Still limited after several tries. Wait a little, then s…
spamText

Shown when the user keeps clicking during the wait.

string"No need to click: extra tries can make the wait longer."
sendingAtText

Says when the retry will be sent.

string"Sending at {at}."
cancelAutoLabel

Label of the button that cancels the automatic retry.

string"Don’t send automatically"
errorText

Shown when the request fails for another reason.

string"That didn’t go through. Try again."
className

Class for the wrapper holding the button and its explanation.

string—

* required. Also accepts the native props of <button> (className, aria-*, data-*…), passed through to it.

Accessibility

  • While waiting the button stays focusable with aria-disabled (not disabled) and is described by the reason
  • aria-busy while the request is pending
  • Wait reason in a polite live region; the spam note is assertive; errors use role=alert
  • Label transition animation is skipped under reduced motion

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

This is a UX guard rail. Enforce on the server: Enforce the rate limit server-side and send Retry-After; the client wait only reduces wasted requests.

  • Client-side wait only; another tab or a direct request is not held back
  • RateLimit fields follow an IETF draft and vendor variants; unusual headers fall back to backoff
  • The countdown uses performance.now(), so it survives clock changes but not a page reload

Rules

Rules last verified:

Retry-After (HTTP-date or delay-seconds) confirmed in RFC 9110. RateLimit/RateLimit-Policy parsing follows an IETF draft and was not re-verified this round. Copied components don't update themselves, so re-check after rule changes (see updating a component).

What it stops

Retry spam extends the lockout

Also known as

429 · rate limit · retry-after · too many requests · throttling · resend otp · backoff · 503

Dependencies

Pairs well with