Skip to content
Fixing API errors

HTTP 429 Too Many Requests: exponential backoff with jitter, done right

A 429 means you are sending faster than the API allows. Here is how to read Retry-After, implement backoff that does not stampede, and stop hitting the limit in the first place.

SandyPublished 6 min read
a group of people standing outside a building, illustrating http 429 too many requests: exponential backoff with jitter, done right
Photo by Meizhi Lang on Unsplash.

Your integration worked yesterday. Today it returns this:

{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Try again in 43 seconds."
}

Status 429. Nothing about your code changed. What changed is how often it runs, or how many people are running it.

Short answer

A 429 means you exceeded the API's rate limit. Read the Retry-After header and wait exactly that long. If there is no such header, back off exponentially with random jitter, cap the delay, and give up after a few attempts. Then fix the underlying volume with caching and throttling, because retrying is damage control rather than a solution.

Read the response before you write any retry logic

Most rate-limited APIs tell you precisely what to do. Look at the headers before assuming anything:

curl -s -D - -o /dev/null https://api.example.com/data
HTTP/2 429
retry-after: 43
x-ratelimit-limit: 60
x-ratelimit-remaining: 0
x-ratelimit-reset: 1758124800

Three things worth knowing about these:

Retry-After is authoritative. It comes in one of two forms: a number of seconds, or an HTTP date. When it is present, use it. Computing your own backoff instead means either retrying too early and getting limited again, or waiting longer than necessary.

X-RateLimit-* headers are a convention, not a standard. Most APIs use these names, some use RateLimit-Limit without the prefix, and a few use nothing at all. Read them when they are there, but never assume they will be.

X-RateLimit-Reset is usually a Unix timestamp, occasionally a number of seconds. If you get a wait of 1.7 billion seconds, you have read a timestamp as a duration.

Why naive retries make it worse

The instinct is to retry immediately:

// Do not do this.
async function fetchWithRetry(url) {
  for (let i = 0; i < 5; i++) {
    const response = await fetch(url);
    if (response.ok) return response.json();
  }
  throw new Error('failed');
}

This sends five requests as fast as the network allows, against a server that just told you to slow down. You have turned one rejected request into five, and many APIs escalate: repeated limit violations extend the cooldown, or get the key suspended.

Adding a fixed delay is better, but creates a different problem. If a hundred clients hit the limit at the same moment and all wait exactly one second, a hundred requests arrive simultaneously one second later. This is the thundering herd, and it is why jitter matters.

Backoff with jitter

The delay should grow with each attempt, and it should be randomised so that simultaneous failures do not produce simultaneous retries.

lib/fetch-with-backoff.ts
type Options = {
  maxAttempts?: number;
  baseDelayMs?: number;
  maxDelayMs?: number;
};
 
export async function fetchWithBackoff(
  url: string,
  init: RequestInit = {},
  { maxAttempts = 4, baseDelayMs = 500, maxDelayMs = 30_000 }: Options = {},
): Promise<Response> {
  let lastError: unknown;
 
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const response = await fetch(url, init);
 
      // Success, or a client error that retrying cannot fix.
      if (response.status !== 429 && response.status < 500) return response;
 
      if (attempt === maxAttempts - 1) return response;
 
      await sleep(delayFor(response, attempt, baseDelayMs, maxDelayMs));
    } catch (error) {
      // Network-level failure. Worth retrying, unlike a 400.
      lastError = error;
      if (attempt === maxAttempts - 1) break;
      await sleep(backoffWithJitter(attempt, baseDelayMs, maxDelayMs));
    }
  }
 
  throw lastError ?? new Error(`Request failed after ${maxAttempts} attempts`);
}
 
function delayFor(
  response: Response,
  attempt: number,
  base: number,
  max: number,
): number {
  const retryAfter = response.headers.get('retry-after');
 
  if (retryAfter) {
    // Either a count of seconds, or an HTTP date.
    const seconds = Number(retryAfter);
    const ms = Number.isFinite(seconds)
      ? seconds * 1000
      : new Date(retryAfter).getTime() - Date.now();
 
    if (ms > 0) return Math.min(ms, max);
  }
 
  return backoffWithJitter(attempt, base, max);
}
 
/**
 * Full jitter: pick a random point in [0, exponential delay].
 * Spreads simultaneous retries instead of clustering them.
 */
function backoffWithJitter(attempt: number, base: number, max: number): number {
  const exponential = Math.min(base * 2 ** attempt, max);
  return Math.random() * exponential;
}
 
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

Four decisions in that code are worth spelling out.

Only retry what retrying can fix. A 429 or a 5xx may succeed later. A 400 or a 422 will fail identically every time, and retrying it wastes your quota against a limit you are already near.

Retry-After overrides the calculation. The server knows when its window resets and you do not.

Full jitter, not half. Picking uniformly from [0, delay] spreads retries better than delay/2 + random(delay/2). AWS published measurements on this; full jitter wins on both completion time and total work.

The delay is capped. Without a cap, attempt eight waits over two minutes. Users leave.

Do not exceed the limit in the first place

Backoff handles the limit being hit. Not hitting it is better, and usually straightforward.

Cache anything that is not changing

Most rate limit problems are the same request repeated. Exchange rates published once a day do not need fetching per page view.

app/api/rates/route.ts
export const revalidate = 3600; // one upstream call per hour
 
export async function GET() {
  const response = await fetch('https://api.frankfurter.app/latest', {
    next: { revalidate: 3600 },
  });
  return Response.json(await response.json());
}

Throttle on your side

If an API allows sixty requests a minute, send at most one per second rather than sixty at once and then nothing.

/** Minimum spacing between calls, queued so nothing is dropped. */
function createThrottle(minIntervalMs: number) {
  let previous = Promise.resolve();
 
  return function throttle<T>(task: () => Promise<T>): Promise<T> {
    const result = previous.then(async () => {
      const value = await task();
      await new Promise((r) => setTimeout(r, minIntervalMs));
      return value;
    });
 
    previous = result.then(
      () => undefined,
      () => undefined,
    );
    return result as Promise<T>;
  };
}
 
const throttle = createThrottle(1_000);
const results = await Promise.all(urls.map((url) => throttle(() => fetch(url))));

Batch where the API supports it

Ten requests for ten cities may be one request for ten cities. Check the documentation for a parameter that accepts a list. This is the single largest win when it is available, and it is easy to miss.

Deduplicate concurrent identical requests

If four components mount at once and each asks for the same resource, that is four calls for one answer.

const inFlight = new Map<string, Promise<unknown>>();
 
export function dedupe<T>(key: string, task: () => Promise<T>): Promise<T> {
  const existing = inFlight.get(key);
  if (existing) return existing as Promise<T>;
 
  const promise = task().finally(() => inFlight.delete(key));
  inFlight.set(key, promise);
  return promise;
}

A quick decision table

SymptomLikely causeAction
429 with Retry-AfterNormal limitingWait exactly that long
429 with no headersUndocumented limitBackoff with jitter, cap attempts
429 on the first request of the dayPer-IP limit, shared addressCheck whether the limit is per key or per IP
429 that never clearsKey suspended or daily quota goneRead the account dashboard, not the response
429 only in productionHigher traffic, or pooled serverless IPsAdd caching before adding retries

Why you are rate limited when you thought you were not

A 429 that arrives well below the documented allowance usually has one of five explanations, and knowing them saves a lot of guessing.

The limit is per IP, and you are sharing it. On a serverless platform or behind a NAT gateway, your requests leave from an address other people also use. A keyless API cannot distinguish you from them, so the budget is shared with strangers. This is the most common cause of limits that seem to appear at random.

Several instances of your own code are sharing it. Three server processes each politely staying under the limit collectively exceed it threefold. The limiter has to be shared across instances or it is not enforcing anything real.

Retries are counted. A rejected request still consumed a slot on most implementations, so an aggressive retry loop against a 429 digs the hole deeper. This is the mechanism behind a brief limit turning into a sustained one.

There is more than one limit. Per second, per hour and per day often apply simultaneously, and staying under the per-second figure says nothing about the daily one. A limiter tuned to the wrong dimension passes for hours and then fails all at once.

Different endpoints have different budgets. Search is frequently stricter than a lookup by id, and a single global limiter set to the more generous figure will trip on the stricter endpoint.

The way to tell them apart is to read the headers on the rejection rather than inferring. Most APIs report which limit was hit and when it resets, and that turns five possibilities into one fact.

Making the wait invisible to the user

A 429 handled correctly should rarely reach a person, and where it does the presentation matters.

For anything happening in the background — a sync, a prefetch, a batch job — the user should never learn a limit was hit. Queue it, back off, and complete it late. Surfacing an infrastructure detail they cannot act on is noise.

For something a user triggered, the honest response is to say the system is busy and that you are retrying, rather than presenting a failure. A spinner that keeps spinning through a backoff is better than an error that the user resolves by clicking again, which adds load at the worst moment. Disabling the button during the retry is the small detail that prevents the manual-retry spiral.

Where the wait is genuinely long, Retry-After gives you a number to show. "Try again in about 30 seconds" is actionable in a way that "rate limit exceeded" is not.

And if you are the one being limited repeatedly under normal use, the fix is upstream of the error handling. Caching, batching and a shared limiter are what remove the condition; better messaging only makes it more tolerable.

What to remember

A 429 is the API being explicit about a contract you agreed to. The response almost always contains the answer, so read the headers before writing a single line of retry logic.

Then treat retries as a safety net rather than a strategy. Caching, batching and throttling remove the problem; backoff only makes hitting it survivable.

Common questions

What does HTTP 429 mean?

It means you have sent more requests than the API permits in a given window. The request was rejected, not because it was malformed, but because of its timing. The limit may be per second, per minute, per day, per key or per IP address.

How long should I wait after a 429?

If the response includes a Retry-After header, wait exactly that long. It is the server telling you the answer. Only fall back to exponential backoff when the header is absent.

Why do I need jitter in my backoff?

Without it, every client that got rate limited at the same moment retries at the same moment, recreating the spike that caused the problem. Randomising the delay spreads those retries out.

Should I retry a 429 forever?

No. Cap attempts at three to five and cap the delay at roughly 30 to 60 seconds. If you are still limited after that, the problem is your request volume, not your retry logic.

Can I avoid 429s entirely?

Often, yes. Cache responses that do not change often, batch requests where the API supports it, and throttle on your side so you never exceed the documented rate. Retrying is damage control; not exceeding the limit is the actual fix.

Sources

Written by

Sandy

I build and run this site on my own: the crawler that assembles the catalogue, the checker that probes every listing, and the writing. Before this I built SaveFromInternet and GrabReels, which meant living with other people’s APIs full time — parsers breaking when a platform shipped a change, rate limits arriving without warning, endpoints disappearing overnight. This directory exists because I got tired of free API lists that had never been checked.

APIs mentioned in this article

Frankfurter

Currency Exchange

Exchange rates, currency conversion and time series

No keyCORSHTTPS

Verified 4 days ago: 100% uptime

View Details

RandomUser

Test Data

API for generating random user data like names, emails, addresses, and more. Provides JSON, XML, CSV, or YAML objects.

No keyCORSHTTPS

Verified 4 days ago: 100% uptime

View Details

Read next