I mostly build internal applications. I've rarely had occasion to design and implement a public API myself. As a result, my grasp of rate limiting stopped at "when the limit is hit, you return 429 Too Many Requests." Hazy would be a generous word for it. Reading through the specs of public APIs, however, I noticed that the 429 came bundled with these things called Retry-After and RateLimit-family headers. Looking into them, they promise to make life on the client side a great deal easier. So I'm writing them down here as notes for myself.

What a Bare 429 Doesn't Tell You

Suppose you're calling an API and suddenly get a response like this.

HTTP/1.1 429 Too Many Requests

As HTTP, there's nothing wrong with it. From the client's point of view, though, this alone says nothing about what to do next. How many more seconds should I wait? Is the limit per minute or per hour? Can I retry right away? Did I hit today's cap? Is the limit per API key or per user? It's like being told at a service counter "we're busy, come back later," with no hint of when.

I'm guilty here too. Whenever I called an external API, I'd pore over the documentation first. I'd implement things so as not to exceed the limit, then leave an apologetic comment explaining as much. It's a somewhat embarrassing habit, and I've kept it up with remarkable persistence. Face an uninformative 429 with retries and you end up waiting on hunches and prayer: wait 5 seconds for now, still 429, wait 30 seconds, still 429...

You don't think about this much when building only internal apps. A public API, though, gets used by all sorts of clients: SDKs, CLIs, batch jobs. Seen that way, it matters to tell the client not just the status code but "what to do next" in the response.

Retry-After Tells You How Long to Wait

The most straightforward thing to return with a 429 is Retry-After.
HTTP/1.1 429 Too Many Requests
Retry-After: 60

Here, the client can conclude that it should retry after 60 seconds. At the service counter, this is being handed a ticket that says "60 seconds." The value can be given not only as a number of seconds but also as an HTTP-date.

HTTP/1.1 429 Too Many Requests
Retry-After: Fri, 03 Jul 2026 12:00:00 GMT

For API use, I personally find seconds easier to work with. You read it and sleep for that many seconds.

const retryAfter = Number(response.headers.get("Retry-After") ?? "0");

if (response.status === 429 && retryAfter > 0) {
  await sleep(retryAfter * 1000);
}

That alone spares the client from guessing how long to wait. That said, if the spec makes you wait a full day before retrying, a timestamp might be the more welcome option.

Headers That Tell You What's Left

Retry-After is handy for telling the client when to retry. Still, the client wants to know other things too. What's the overall limit? How many more calls can it make? When does the quota recover? Which limit did it run into? To convey this, public APIs often return rate limit information in headers. A commonly seen format looks like this.
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 300

Roughly, they mean the following.

HeaderMeaning
X-RateLimit-LimitThe maximum number of calls allowed
X-RateLimit-RemainingThe number of calls remaining
X-RateLimit-ResetSeconds until reset, or the reset time itself
However, the meaning of X-RateLimit-* seems to differ subtly between services. Whether Reset is "seconds until reset" or a "UNIX timestamp," for example, varies by API. Don't let the shared name lull you; check that API's documentation before relying on it.

Meanwhile, the more recent spec apparently defines headers like these.

RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=42;t=30
Roughly speaking, RateLimit-Policy describes what the limit rule is, and RateLimit describes how much is currently available. In this example, q=100 with w=60 reads as up to 100 requests in a 60-second window. Then r=42 means 42 more requests are currently available, and t=30 means 30 seconds remain in this window. When designing a new API, it seems wise to look at both the existing conventions and the new spec.

The Shape of a 429 Response

With the split-header approach, a 429 response might look like this.

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 60

{
  "error": "rate_limit_exceeded",
  "message": "Rate limit exceeded.",
  "retryAfter": 60
}
If you use RateLimit / RateLimit-Policy, it looks like this.
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=60

{
  "error": "rate_limit_exceeded",
  "message": "Rate limit exceeded.",
  "retryAfter": 60
}

Headers alone are enough for machine-driven control. But putting the same information into the JSON error body as well should improve the UX further.

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=60

{
  "error": "rate_limit_exceeded",
  "message": "Rate limit exceeded.",
  "retryAfter": 60,
  "rateLimit": {
    "policy": "default",
    "limit": 100,
    "remaining": 0,
    "resetAfter": 60
  }
}

Mind you, if the header and JSON values drift apart, you only sow confusion. It's like the ticket and the display board showing different times, so that's worth watching. Whichever format you choose, what matters is that the client can decide what to do next.

What the Client Can Do With It

Once this information comes back, the client can suddenly do a lot more. First, an SDK can read Retry-After and retry automatically with little fuss. In a JavaScript SDK, it might go something like this.
async function requestWithRetry(input: RequestInfo, init?: RequestInit) {
  const response = await fetch(input, init);

  if (response.status !== 429) {
    return response;
  }

  const retryAfter = Number(response.headers.get("Retry-After") ?? "0");

  if (retryAfter <= 0) {
    throw new Error("Rate limit exceeded.");
  }

  await sleep(retryAfter * 1000);
  return fetch(input, init);
}
A real SDK would also need to handle a maximum retry count, exponential backoff, cancellation, idempotency, and so on. Even so, if the server returns Retry-After, you at least don't have to guess when to retry.

Next, an admin dashboard can show usage along the lines of "API Usage 98 / 100 requests." For users, simply knowing how many calls are left is reassuring in a way that's hard to overstate. The same goes for when a 429 does happen. Saying "You have reached the usage limit. You can try again in about 60 seconds." is far kinder than brushing them off with "An error occurred."

CLI tools can likewise display the remaining requests and time until reset, as in "Remaining requests: 42" and "Reset in: 300s." When only three or so remain, the tool can announce "Slowing down to avoid rate limit..." and ease off — a neat little trick. Adjusting the pace before a 429 stops you cold is genuinely useful. It probably helps CLIs running under CI/CD or cron more than you'd expect, too.

Best of all is batch processing. In batch jobs that fetch large amounts of data, how you handle 429s turns out to matter a great deal. With a 429 that carries no information, you have no idea how long to wait. Know the remaining count and the reset time, however, and you can write a plain, sensible control flow. When the remaining count hits zero, wait until the reset, then resume from where you left off. No endless pointless retries, which makes things easier on both run time and cost.

Real APIs and Decisions to Make

Rate limit headers appear to be quite commonly implemented in public APIs. GitHub API, Stripe API, Cloudflare API, Discord API, Shopify API, X API, freee API — plenty of well-known APIs return rate limit information in their responses. Header names and the meaning of values vary from service to service. Even so, the idea of returning rate limit information in the response is, by now, practically common sense.

Still, once you set out to return them yourself, the decisions beyond header names keep piling up. Is the limit per API key, per user, or per IP? Do limits differ per endpoint? Is Reset seconds or a UNIX timestamp? Which limit do you return when there are several? Do normal, non-429 responses also carry the remaining count? And you have to make sure the values of Retry-After and RateLimit don't contradict each other. Multiple coexisting limits look especially tricky. If there's both "100 per minute" and "10,000 per day," say, the client will be stuck unless you decide which one's remaining count to return. A format that includes the policy name, like RateLimit-Policy / RateLimit, may be better suited to expressing this.

That covers what I've gathered about 429s and rate limit headers. I used to think of rate limiting as nothing more than "return a 429 when the limit is hit." But simply returning the wait time and remaining count lets the client know when to retry, show how much is left, and avoid pointless retries. Explaining things to users gets easier, too. It's an area you rarely have to think about when building only internal apps, but if you're designing a public API, it's worth thinking past the status code. A good error doesn't just shut the door; it hands you a ticket.

References