DEV Community

mrnoyy
mrnoyy

Posted on

Handling Rate Limits Properly — Backoff, Retry-After, and the Bugs In Between

Every integration eventually meets a 429. The first version of the fix is usually a sleep(1000) between calls. Here is what actually holds up.

Read the response before you guess

Most APIs tell you what they want:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1643723400
Enter fullscreen mode Exit fullscreen mode

Check those headers on every response, not just on 429s. X-RateLimit-Remaining dropping toward zero is your signal to slow down before you get blocked. Reacting only to failures means you are always one failure behind.

Retry-After has two legal formats

RFC 7231 allows both a delay in seconds and an HTTP-date:

Retry-After: 30
Retry-After: Fri, 31 Jul 2026 07:00:00 GMT
Enter fullscreen mode Exit fullscreen mode

parseInt on the second returns 31, which is not thirty-one seconds — it is the day of the month. Your retry fires early, hits the limit again, and you burn attempts.

function retryAfterMs(header, fallbackMs = 30000) {
  if (!header) return fallbackMs;
  const raw = String(header).trim();

  if (/^\d+$/.test(raw)) return Number(raw) * 1000;

  const when = Date.parse(raw);
  if (!Number.isNaN(when)) {
    const diff = when - Date.now();
    return diff > 0 ? diff : fallbackMs;
  }
  return fallbackMs;
}
Enter fullscreen mode Exit fullscreen mode

Note the diff > 0 check. A clock skew between your machine and the server can produce a date already in the past, and a negative timeout means you retry instantly.

Backoff for everything else

Retry-After is authoritative when present. When it is not — or for 500s and 502s — exponential backoff with a cap:

const waitMs = Math.min(2 ** attempt * 1000, 60000);
Enter fullscreen mode Exit fullscreen mode

Add jitter if more than one worker is retrying. Without it, every client that failed at the same moment retries at the same moment, and you have rebuilt the stampede you were avoiding.

Cap the attempts. An unbounded retry loop against an API that is down is a job that never ends and a bill that keeps growing.

Separate the retryable from the fatal

This is the part people skip, and it is the one that causes real damage:

  • 429, 502, 503, 504 — retry.
  • 401, 403 — do not retry. Your key is wrong or revoked. Retrying will not fix it and some providers count repeated auth failures against you.
  • 400, 422 — do not retry. The payload is malformed. It will be malformed the second time too.

A loop that retries a 422 will send the same broken request five times and then report a rate limit problem you do not have.

Concurrency is the actual cause

Most 429s come from Promise.all over an array. Ten items, ten simultaneous requests, and no limit anywhere:

// this is a rate limit waiting to happen
const results = await Promise.all(ids.map((id) => fetchItem(id)));
Enter fullscreen mode Exit fullscreen mode

Promise.all has no concurrency control. For small batches, a sequential loop with a delay is honest and easy to reason about:

for (const id of ids) {
  await fetchItem(id);
  await sleep(delayMs);
}
Enter fullscreen mode Exit fullscreen mode

For larger ones, a worker pool of three to five is usually plenty. The throughput difference against a rate-limited API is smaller than you expect, because the limit is the ceiling either way.

Do not fetch the same thing twice

The cheapest request is the one you skip. Cache responses that do not change within a run, and keep a local record of what you already did between runs. In my publishing pipeline a content hash decides whether a post needs an update at all — most runs make zero write calls because nothing changed.

Make the waiting visible

console.log(`429 — waiting ${Math.round(waitMs / 1000)}s`);
Enter fullscreen mode Exit fullscreen mode

A CI job that sits silent for ninety seconds looks hung. One line of output turns a support question into a shrug.


None of this is complicated. It is just that the naive version works fine in testing with three items and fails on the first real batch, which is exactly when you are least in the mood to debug it.

Top comments (1)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

The Retry-After HTTP-date trap is real — I've seen exactly that bug where parseInt turns "Fri, 31 Jul..." into 31 seconds and everything retries early. The negative-diff guard for clock skew is a nice touch too, that one bites when the client clock drifts.

One thing worth adding: respect a global rate budget, not just per-request backoff. If you have N workers each doing exponential backoff independently, you still hammer the endpoint together. A small shared limiter (even a token bucket in Redis) fixes it.

How do you handle APIs that return 429 without any Retry-After header at all?