DEV Community

Preecha
Preecha

Posted on

Rate Limit Exceeded: A Complete Guide for API Developers

When an API returns “rate limit exceeded,” your application has sent more requests than the provider allows within a specific time window. To keep integrations reliable, your client should detect the limit, pause requests, retry safely, and reduce unnecessary traffic.

Try Apidog today

This guide explains how rate limits work, how to handle HTTP 429 responses, and how to test rate-limit behavior with tools such as Apidog.

What Does “Rate Limit Exceeded” Mean?

A rate-limit error occurs when a client—such as a web app, backend service, test script, or CLI—exceeds the maximum number of requests allowed during a defined period.

API providers enforce these limits to:

  • Prevent abuse: Stop excessive or malicious traffic from degrading the service.
  • Ensure fairness: Prevent one client from consuming all shared resources.
  • Maintain stability: Control traffic spikes and protect backend infrastructure.

Anatomy of a Rate-Limit Error

Most APIs communicate rate limiting with:

  • HTTP status code 429 Too Many Requests
  • An error object or message such as rate_limit_exceeded
  • Headers that describe the quota or tell the client when to retry

Example response body:

{
  "error": "rate_limit_exceeded",
  "message": "You have exceeded your rate limit. Please try again in 60 seconds."
}
Enter fullscreen mode Exit fullscreen mode

Example response headers:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
Retry-After: 60
Enter fullscreen mode Exit fullscreen mode

Header names vary between APIs, so check the provider’s documentation before implementing your client.

Common Causes of Rate-Limit Errors

1. Burst Traffic

Sending many requests in a short period can exceed a limit even when your total daily usage is low.

Common examples include:

  • Running a large batch job
  • Polling an endpoint too frequently
  • Refreshing data separately for every connected user
  • Starting multiple workers at the same time

2. Unoptimized Request Logic

Inefficient code can generate duplicate or unnecessary traffic. Typical causes include:

  • Requests inside uncontrolled loops
  • Missing response caching
  • Duplicate requests from multiple UI components
  • Retrying immediately after failures
  • Fetching individual records when a batch endpoint is available

3. Multiple Clients Sharing One API Key

Rate limits are often applied per API key or token. If multiple applications, users, or environments share credentials, their combined usage may consume the same quota.

4. Unexpected User Growth

A traffic spike, product launch, or viral feature can quickly increase request volume beyond the expected quota.

Types of API Rate Limits

Before designing retry logic, determine how the API calculates its limits.

Per-user or per-token limits

Each user account or access token receives an independent quota.

Per-IP limits

All requests originating from the same IP address share a limit. This can affect applications that route traffic through a common gateway.

Global application limits

Every request from the application contributes to one shared quota, regardless of user or IP.

Endpoint-specific limits

Resource-intensive endpoints may have stricter limits than the rest of the API.

Time-window limits

Quotas may be measured per second, minute, hour, or day. Some APIs use fixed windows, while others use rolling windows or token-bucket-style controls.

How to Handle HTTP 429 Responses

A rate-limit error should be treated as a recoverable condition. Your application should pause, preserve useful state, and retry only when appropriate.

1. Check the Status Code and Retry-After Header

When the API returns 429, read the Retry-After header before scheduling another request.

A basic JavaScript implementation might look like this:

async function requestWithRateLimitHandling(url, options = {}) {
  const response = await fetch(url, options);

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

  const retryAfter = Number(response.headers.get("Retry-After") ?? 1);
  const delayMs = retryAfter * 1000;

  console.warn(`Rate limit reached. Retrying in ${retryAfter} seconds.`);

  await sleep(delayMs);
  return fetch(url, options);
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
Enter fullscreen mode Exit fullscreen mode

This handles one retry, but production integrations should also cap the number of attempts.

2. Add Exponential Backoff

If the API does not provide a usable Retry-After value, increase the delay after each failed attempt.

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchWithBackoff(
  url,
  options = {},
  {
    maxRetries = 5,
    baseDelayMs = 1000,
  } = {}
) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options);

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

    if (attempt === maxRetries) {
      throw new Error(`Rate limit exceeded after ${maxRetries} retries`);
    }

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

    const delayMs = retryAfter
      ? Number(retryAfter) * 1000
      : baseDelayMs * 2 ** attempt;

    console.warn(
      `Received HTTP 429. Retry ${attempt + 1}/${maxRetries} in ${delayMs}ms`
    );

    await sleep(delayMs);
  }
}
Enter fullscreen mode Exit fullscreen mode

Exponential backoff prevents a client from repeatedly hitting an overloaded API with immediate retries.

3. Add Jitter to Retry Delays

If many clients retry at exactly the same time, they can create another traffic spike. Add a small random delay, known as jitter, to spread retries out.

function calculateBackoff(attempt, baseDelayMs = 1000) {
  const exponentialDelay = baseDelayMs * 2 ** attempt;
  const jitter = Math.random() * 500;

  return exponentialDelay + jitter;
}
Enter fullscreen mode Exit fullscreen mode

Use this value when the API does not provide an explicit retry time:

const delayMs = calculateBackoff(attempt);
await sleep(delayMs);
Enter fullscreen mode Exit fullscreen mode

4. Parse Retry-After Safely

Depending on the API, Retry-After may contain either a number of seconds or an HTTP date.

function parseRetryAfter(value) {
  if (!value) {
    return null;
  }

  const seconds = Number(value);

  if (Number.isFinite(seconds)) {
    return Math.max(0, seconds * 1000);
  }

  const retryDate = Date.parse(value);

  if (!Number.isNaN(retryDate)) {
    return Math.max(0, retryDate - Date.now());
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

You can combine it with exponential backoff:

const retryAfterMs = parseRetryAfter(
  response.headers.get("Retry-After")
);

const delayMs =
  retryAfterMs ?? calculateBackoff(attempt);

await sleep(delayMs);
Enter fullscreen mode Exit fullscreen mode

5. Limit Concurrent Requests

Retries alone do not solve the problem if the application continues creating requests faster than the API accepts them.

A simple worker pattern can limit concurrency:

async function runWithConcurrency(tasks, concurrency = 3) {
  const results = [];
  let nextTaskIndex = 0;

  async function worker() {
    while (nextTaskIndex < tasks.length) {
      const currentIndex = nextTaskIndex++;
      results[currentIndex] = await tasks[currentIndex]();
    }
  }

  const workers = Array.from(
    { length: Math.min(concurrency, tasks.length) },
    () => worker()
  );

  await Promise.all(workers);
  return results;
}
Enter fullscreen mode Exit fullscreen mode

Use it when processing a batch:

const tasks = userIds.map(
  (userId) => () =>
    fetchWithBackoff(`/api/users/${userId}`)
);

const responses = await runWithConcurrency(tasks, 3);
Enter fullscreen mode Exit fullscreen mode

Tune the concurrency value according to the API’s documented policy.

Monitor Rate-Limit Headers

If the API exposes quota headers, record them in your application logs or metrics.

function logRateLimitHeaders(response) {
  const limit = response.headers.get("X-RateLimit-Limit");
  const remaining = response.headers.get("X-RateLimit-Remaining");
  const reset = response.headers.get("X-RateLimit-Reset");

  console.info({
    rateLimit: limit,
    rateLimitRemaining: remaining,
    rateLimitReset: reset,
  });
}
Enter fullscreen mode Exit fullscreen mode

This data can help you:

  • Detect usage spikes
  • Identify clients consuming excessive quota
  • Alert before the remaining quota reaches zero
  • Compare usage across endpoints and environments

Do not assume every API uses the same header names or reset format.

Reduce the Number of API Requests

The most effective fix is often to avoid sending unnecessary requests.

Cache reusable responses

If multiple users or components request the same data, cache it for an appropriate period.

const cache = new Map();

async function fetchWithCache(url, ttlMs = 30_000) {
  const cached = cache.get(url);

  if (cached && cached.expiresAt > Date.now()) {
    return cached.data;
  }

  const response = await fetchWithBackoff(url);

  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}`);
  }

  const data = await response.json();

  cache.set(url, {
    data,
    expiresAt: Date.now() + ttlMs,
  });

  return data;
}
Enter fullscreen mode Exit fullscreen mode

For production systems, consider whether the cache should be shared across processes and how stale data should be handled.

Batch requests when supported

Instead of fetching records one at a time:

await fetch("/api/users/1");
await fetch("/api/users/2");
await fetch("/api/users/3");
Enter fullscreen mode Exit fullscreen mode

Use a batch endpoint if the API provides one:

await fetch("/api/users/batch", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    ids: [1, 2, 3],
  }),
});
Enter fullscreen mode Exit fullscreen mode

Batching depends on API support and should follow the provider’s documented payload limits.

Reduce polling frequency

Avoid polling every second unless the use case and API policy require it.

const POLL_INTERVAL_MS = 30_000;

setInterval(async () => {
  try {
    const response = await fetchWithBackoff("/api/status");
    const status = await response.json();

    updateUI(status);
  } catch (error) {
    console.error("Status polling failed:", error);
  }
}, POLL_INTERVAL_MS);
Enter fullscreen mode Exit fullscreen mode

Where available, webhooks or streaming APIs may reduce the need for repeated polling.

Deduplicate in-flight requests

Multiple UI components may request the same resource simultaneously. Reuse the existing promise instead of creating another API call.

const inFlightRequests = new Map();

async function fetchOnce(url) {
  if (inFlightRequests.has(url)) {
    return inFlightRequests.get(url);
  }

  const request = fetchWithBackoff(url)
    .then((response) => {
      if (!response.ok) {
        throw new Error(`Request failed with status ${response.status}`);
      }

      return response.json();
    })
    .finally(() => {
      inFlightRequests.delete(url);
    });

  inFlightRequests.set(url, request);
  return request;
}
Enter fullscreen mode Exit fullscreen mode

Real-World Examples

Example 1: Social Media Analytics Dashboard

Assume a social platform allows 900 requests per 15 minutes. If a dashboard refreshes every second for every connected user, it can quickly exhaust the shared quota.

A more resilient implementation should:

  1. Fetch analytics on the server rather than from every browser independently.
  2. Cache results for an appropriate period.
  3. Increase the refresh interval.
  4. Display the last successful update time.
  5. Serve stale cached data temporarily after a 429 response.

Example 2: Financial Data Aggregator

A financial application calls an account-balance endpoint that permits five requests per minute. Polling on every page render can quickly exceed the limit.

To address it:

  1. Cache the latest successful balance response.
  2. Prevent duplicate in-flight requests.
  3. Refresh only after the cache expires.
  4. Honor Retry-After when the endpoint returns 429.
  5. Use Apidog to simulate the error response and validate the retry flow before deployment.

Example 3: Multiple Services Sharing Credentials

Several internal services use the same API key, so their combined traffic consumes one shared quota.

Possible actions include:

  1. Measure request volume by service.
  2. Coordinate requests through a shared queue or gateway.
  3. Use individual credentials when the API provider supports them.
  4. Define separate test and production environments.
  5. Test each service’s behavior when the shared quota reaches zero.

Design for Graceful Degradation

Your application should remain usable even when an external API is temporarily unavailable because of rate limiting.

Depending on the product, graceful degradation can include:

  • Showing cached data
  • Displaying when the data was last updated
  • Temporarily disabling refresh controls
  • Queueing non-urgent operations
  • Returning a clear status message
  • Delaying background synchronization

For example:

async function loadDashboardData() {
  try {
    return await fetchWithCache("/api/analytics", 60_000);
  } catch (error) {
    const staleEntry = cache.get("/api/analytics");

    if (staleEntry) {
      return {
        ...staleEntry.data,
        stale: true,
      };
    }

    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

The correct fallback depends on how time-sensitive the data is. Cached financial or security-related data, for example, should be clearly marked as stale.

Prevent Rate-Limit Errors Before Production

1. Read the API’s policy

Confirm:

  • What resource is limited: user, token, IP, endpoint, or application
  • The allowed request volume
  • The time-window model
  • Whether burst limits also apply
  • Which headers are returned
  • Whether retries count against the quota

2. Set an internal request budget

Do not plan to consume the full documented quota continuously. Reserve capacity for:

  • Retries
  • Traffic spikes
  • Background jobs
  • Administrative tasks
  • Other services using the same credentials

3. Add monitoring and alerts

Track at least:

  • Number of 429 responses
  • Requests per endpoint
  • Retry count
  • Retry delay
  • Remaining quota, when available
  • Number of requests abandoned after the retry limit

4. Rate-limit your own clients

If your system calls an external API, add an application-level limiter before requests leave your infrastructure. This protects the upstream API and prevents one internal worker from consuming the full shared quota.

5. Test failure behavior

Verify that your application:

  • Recognizes HTTP 429
  • Reads Retry-After
  • Applies exponential backoff when necessary
  • Stops after a defined number of attempts
  • Avoids retrying non-repeatable operations blindly
  • Preserves useful error information in logs
  • Falls back to cached or degraded behavior

Testing Rate Limits with Apidog

Apidog can help teams model and test rate-limit scenarios throughout API development.

Mock an HTTP 429 response

Configure a mock response with:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 60
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
Enter fullscreen mode Exit fullscreen mode

Use a response body such as:

{
  "error": "rate_limit_exceeded",
  "message": "You have exceeded your rate limit. Please try again in 60 seconds."
}
Enter fullscreen mode Exit fullscreen mode

Then verify that the client:

  1. Detects the 429 status.
  2. Reads the retry header.
  3. Delays the next attempt.
  4. Stops after the configured retry limit.
  5. Shows an appropriate fallback to the user.

Add automated test cases

Create test cases for scenarios such as:

  • A successful response before the quota is exhausted
  • A 429 response with Retry-After
  • A 429 response without Retry-After
  • Several consecutive 429 responses
  • A successful response after one retry
  • Failure after the maximum number of retries

Document rate-limit behavior

Include the following details in the API specification or endpoint documentation:

  • Rate-limit scope
  • Request quota
  • Time window
  • HTTP status code
  • Error schema
  • Relevant response headers
  • Expected client retry behavior

Documenting these rules helps frontend, backend, QA, and integration teams implement consistent handling.

Implementation Checklist

Before shipping an API integration, verify that it:

  • [ ] Detects HTTP 429 responses
  • [ ] Honors the Retry-After header
  • [ ] Uses capped exponential backoff
  • [ ] Adds jitter when retry timing is not provided
  • [ ] Limits concurrent requests
  • [ ] Avoids unlimited retries
  • [ ] Caches reusable responses
  • [ ] Deduplicates simultaneous requests
  • [ ] Batches operations when supported
  • [ ] Logs quota and retry data
  • [ ] Alerts on repeated rate-limit failures
  • [ ] Provides a fallback or degraded experience
  • [ ] Has automated tests for rate-limit scenarios

Conclusion

A “rate limit exceeded” response is not just an API failure; it is a signal that the client must reduce or reschedule its traffic. Reliable integrations detect HTTP 429, honor Retry-After, apply capped backoff, control concurrency, and minimize duplicate requests.

By combining these implementation patterns with mocked responses, automated tests, and documented error behavior in Apidog, you can verify rate-limit handling before it affects production users.

Top comments (0)