# How to choose between 429 and 503 when you throttle a request

> Return 429 when one caller has spent its quota and 503 when the server is out of capacity, each with Retry-After and a body that names the policy.

Source: https://voxgig.com/howto/choose-429-or-503-when-throttling

- Audience: api-producer
- Level: beginner
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Return 429 Too Many Requests when one caller has spent its own quota, and 503 Service Unavailable when the server is out of capacity whoever is calling. Send Retry-After on both, as seconds rather than a date, with a problem details body that names the policy. Check the quota first and charge it only for admitted requests, so a 503 never costs a caller part of their allowance.

---
## You will need

Node 22 or later, and an HTTP API with at least one endpoint you can throttle. Verified 2026-10-15
against Node 22.22.0. The sample keeps its counters in memory so the decision is visible. Where the
counters live, and how a key and a tenant share a budget, is the subject of rate limiting by API
key and tenant, linked at the end.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [429 Too Many Requests (RFC 6585)](https://www.rfc-editor.org/rfc/rfc6585.html#section-4) | One caller has spent its own quota, and every other caller is unaffected | Outlier detectors count 5xx answers, so a 429 never sheds load from a full server | The server itself is short of capacity |
| [503 Service Unavailable (RFC 9110)](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.6.4) | The server is overloaded or in maintenance, whoever is calling | Load balancers eject on it, and CloudFront caches it for ten seconds by default | Only one caller is over a limit |
| [Retry-After as an HTTP-date](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3) | A refusal with a known end, such as a maintenance window | The client's clock sets the wait, so a skewed clock waits the wrong time | The wait is a duration you computed |
| [Retry-After as delta-seconds](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After) | Any throttling refusal, where the wait is computed per request | Counts from when the response arrives, so time spent in transit is added to it | The refusal ends at a fixed moment on the calendar |

A 429 is addressed to one caller, and the infrastructure between you and that caller lets it
through without drawing conclusions. A 503 is addressed to everyone, including the load balancer,
which is the point when the server is full and the damage when it is not. Seconds survive clock
skew and drift by the transit time. A date survives a slow proxy but fails on any client whose
clock is wrong.

## Decide by whose limit was reached

The status follows from whose limit ran out. The throttle checks the caller's quota first, then the
server's capacity, and charges the quota only once both have passed.

```js title="throttle.mjs"
    if (window.used >= quota.limit) {
      const retryAfter = Math.ceil((window.resetAt - now) / 1000)
      return refuse(429, retryAfter, {
        type: 'https://api.example.com/problems/quota-exceeded',
        title: 'Your key has used its quota for this window',
        policy: 'key-quota',
        detail: `${window.used} of ${quota.limit} requests used in a ${quota.windowSeconds} s window.`,
      })
    }

    if (inFlight >= maxInFlight) {
      // Two to five seconds, spread so refused callers do not all return at once.
      const retryAfter = 2 + Math.floor(random() * 4)
      return refuse(503, retryAfter, {
        type: 'https://api.example.com/problems/server-overloaded',
        title: 'The service is at capacity',
        policy: 'server-capacity',
        detail: 'Retry after the delay in Retry-After. Your quota was not charged.',
      })
    }
```

The order matters. A key that has spent its quota gets 429 even while the server is full, because
the caller would be refused on a quiet day too. The 429's wait is exact, the time until the
window resets. The 503's wait is short and spread, because a fixed value sends every refused
caller back in the same second.

Both bodies follow [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html), the problem details
format, with a `policy` member an agent can branch on. The 503 detail names no queue depth or
limit, since those tell a caller how hard to push. The
[RateLimit header fields draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
can carry the quota on every response as well, which spares a client from discovering the limit by
hitting it.

## Run both scenarios behind a load balancer

The sample has no Envoy or ALB, so `balancer.mjs` models the two ways one judges a backend. The
passive half ejects a host after five consecutive 5xx answers, the default of
[Envoy's outlier detection](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/outlier_detection.proto).
The active half probes `/health` and counts 200 to 399 as success, the rule a
[Kubernetes HTTP probe](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/)
applies. `/health` is outside the quota and fails only when the server is full.

```bash
node demo.mjs
```

```text output
scenario 1: key_a goes past its quota of 5 a minute, key_b stays under it
  key_a  200 200 200 200 200 429 429  Retry-After: 60, 60
  key_b  200 200
  balancer: 0 consecutive 5xx, ejected false; probes 200 200 200, ready true
  {
    "type": "https://api.example.com/problems/quota-exceeded",
    "title": "Your key has used its quota for this window",
    "policy": "key-quota",
    "detail": "5 of 5 requests used in a 60 s window.",
    "status": 429,
    "retry_after": 60
  }

scenario 2: three slow requests fill the server, then key_a and key_b call
  key_a  503 503 503                  Retry-After: 4, 2, 5
  key_b  503 503                      Retry-After: 3, 4
  balancer: 5 consecutive 5xx, ejected true; probes 503 503 503, ready false
  {
    "type": "https://api.example.com/problems/server-overloaded",
    "title": "The service is at capacity",
    "policy": "server-capacity",
    "detail": "Retry after the delay in Retry-After. Your quota was not charged.",
    "status": 503,
    "retry_after": 4
  }
  key_a, after the load clears  200 200 200 200 200
```

In the first scenario only `key_a` is refused, and the balancer keeps the host. In the second,
both keys are refused, the passive detector ejects the host after the fifth 503, and the probe
marks it unready. The last line shows the quota untouched: `key_a` was refused three times, all for
capacity, and still has five requests left.

## Send seconds, not a date

Both forms are legal, and they fail differently. A date is read against the client's clock; seconds
are counted from when the response arrives.

```bash
node demo.mjs
```

```text output
Retry-After for a 60 s wait, read by a client whose clock runs 30 s fast
  seconds    60                             client waits 60 s
  HTTP-date  Thu, 15 Oct 2026 12:01:00 GMT  client waits 30 s
```

The client with a fast clock waits half as long, so it comes back while the window is still
closed and is refused again. Use the date
form for a refusal that ends at a moment you know in advance, such as a maintenance window, and
seconds for everything a throttle computes.[^1]

## Check it worked

The test runs both scenarios. It asserts that only the spent key sees 429, that every refusal
carries `Retry-After` and a `policy`, and that the balancer ejects the host on 503 responses alone.
It also checks that a `Retry-After` in neither form, such as `1.5`, parses as absent rather than as a
wait of zero. Another sends a flood of distinct keys and checks that the key map is walked once per window, not
on every request.

```bash
node --test throttle.test.mjs
```

```text output
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 252.931702
```

Against your own stack, send a burst on one key past its quota and confirm the target health in
your load balancer does not change. Then saturate the service and confirm it does.

## When it goes wrong

An agent hammers the API after its first refusal. The 429 carries no `Retry-After`, and a client
that does what the response says has nothing to wait for.

```bash
node demo.mjs
```

```text output
an agent retrying on its own after its quota is spent, ten attempts at most
  without Retry-After   10 attempts in 0 s, last status 429
  with Retry-After      2 attempts in 60 s, last status 200
```

The agent sent ten requests in no time at all, had every one refused, and gave up a minute before
its quota would have let it through.
[RFC 6585](https://www.rfc-editor.org/rfc/rfc6585.html#section-4) makes the header optional, so a
client that retries at once when it is missing is following the response, not breaking a rule.
Send it every time.

The container restarts under load. The overload check was wired to the liveness probe, and a
Kubernetes liveness failure kills the container, which removes capacity at the moment it is short.
Report overload on readiness only.

Every instance is full and the load balancer sends traffic to all of them anyway. The
[AWS Elastic Load Balancing documentation](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html)
says its load balancer fails open when every target in the group fails its health check.[^2] A
503 can move load only onto instances that have room, so a fleet that is full everywhere needs more
capacity, not a different status code.

## When not to do this

Do not answer 503 for a per-caller limit to sound less blunt. It tells every proxy, balancer, and
CDN that the service is unhealthy.
[CloudFront](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/HTTPStatusCodes.html)
caches a 503 for ten seconds by default and serves it to every caller of that URL. RFC 6585 forbids
caches to store a 429 at all.

Do not answer 429 when the server is overloaded. The caller did nothing wrong, and a balancer that
cannot see the overload keeps sending traffic to the instance that is refusing it.

Do not send 503 without a body or with an HTML error page. An agent needs `policy` and
`retry_after` to choose between waiting and failing, and a gateway's default page carries
neither.[^3]

## Related how-tos

- [Rate limit by API key and tenant, not client IP](/howto/key-rate-limits-by-api-key-and-tenant)

- [Design error responses an autonomous agent can act on](/howto/error-responses-an-agent-can-act-on)

## Last verified

Verified 2026-10-15 against Node 22.22.0. Every output block is what the command preceding it
printed. The load balancer is a model with Envoy's consecutive 5xx rule and a Kubernetes-style
probe, not Envoy or Kubernetes; their behavior is quoted from their documentation.

[^1]: The grammar in [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3) calls
the seconds form `delay-seconds`, and its example of the date form is `Fri, 31 Dec 1999 23:59:59 GMT`. The specification was published in June 2022, so a client that obeys its own example waits
no time at all.

[^2]: The fail-open takes three sentences of the
[health check documentation](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html),
placed between how a health check connection is closed and the fact that health checks do not
support WebSockets. The same page sets the default success code at 200 alone, so a 429 on a
throttled health check path reads as unhealthy as surely as a 503.

[^3]: [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.6.4) notes that the 503
status code does not oblige an overloaded server to use it, and that some servers simply refuse
the connection instead. A refused connection has no status, no body, and no Retry-After, which
makes it the one refusal with less to say than a bare 429.