Web Development

Handling 429 Rate Limits in Bulk API Requests [2026]

Asep Alazhari

A bulk create flow firing hundreds of requests will hit 429 errors. Here is how I fixed it with Retry-After handling, backoff jitter, and live quota tracking.

Handling 429 Rate Limits in Bulk API Requests [2026]

I built a bulk create feature that let admins add dozens of records in one go instead of clicking through a form one at a time. It worked fine in every test I ran, five records, ten records, twenty records. Then a real admin selected 150 records and hit go. Half of them failed with a 429 status code, and the error message on screen just said something went wrong. I had no idea which records actually made it through, and neither did the admin.

That afternoon turned into a crash course on rate limiting from the client side. Not the part where you configure a rate limiter on your own API, but the harder part, where you are the one calling someone else’s rate limited API in a loop and need to survive it gracefully. This article covers what I built to fix it, and why the fix is less about retrying harder and more about pacing smarter.

Key Takeaways

  • A 429 status code means too many requests, and the fix is not blind retrying, it is reading the response headers the API already gives you.
  • Always check the Retry-After header first. If it is missing, use exponential backoff with random jitter instead of a fixed delay, so many clients do not retry at the exact same moment.
  • Cap retries at 3 to 5 attempts per request, then surface a clear final error instead of retrying forever.
  • Add adaptive pacing between requests in a bulk loop so you slow down before you get rate limited, not only after.
  • Surface remaining quota to the user in real time with a progress and results summary, instead of one generic error at the end.

What Is a 429 Rate Limit Error?

A 429 Too Many Requests status code means the server understood your request but is refusing it because you have sent too many requests in a given time window. It is defined in RFC 6585 as part of the HTTP status code standard. The server is not broken, it is protecting itself from overload, and it expects you to slow down and try again later.

Most well behaved APIs pair a 429 response with extra headers that tell you exactly what to do next. The two that matter most are Retry-After, which tells you how long to wait before trying again, and a rate limit header family such as X-RateLimit-Remaining, which tells you how many requests you have left in the current window.

Why Does Bulk Create Traffic Trigger 429s So Easily?

A single form submission rarely hits a rate limit. A bulk operation does, because it turns one user action into dozens or hundreds of API calls in a short burst. If your bulk create loop fires requests as fast as the network allows, you are effectively load testing the very API you depend on, and rate limits exist specifically to stop that kind of burst.

The failure mode is also sneaky. The first 20 or 30 requests usually succeed, because the rate limit window has not filled up yet. Then requests start failing in the middle of the batch, which is the worst possible place, because now you have a mix of created and failed records and no clean way to tell the user which is which.

How Do You Read the Right Signals From a 429 Response?

You read them the same way you read any other response header, by checking the response object before you decide what to do next. Here is a small helper that extracts the two signals that matter most.

function parseRateLimitInfo(response) {
    const retryAfterHeader = response.headers.get("retry-after");
    const remainingHeader = response.headers.get("x-ratelimit-remaining");

    return {
        status: response.status,
        retryAfterMs: retryAfterHeader ? Number(retryAfterHeader) * 1000 : null,
        remaining: remainingHeader ? Number(remainingHeader) : null,
    };
}

If retryAfterMs comes back as a real number, trust it over any backoff math you write yourself. The server is telling you exactly when its window resets, and guessing a shorter delay just gets you rate limited again.

Building the Retry Layer: Exponential Backoff With Jitter

When Retry-After is missing, the standard 2026 pattern is exponential backoff with jitter. Backoff means each retry waits longer than the last. Jitter means you add a small random offset to that wait, so that if ten clients get rate limited at the same second, they do not all retry at that same exact second and cause a second wave of 429s.

async function requestWithBackoff(makeRequest, maxAttempts = 4) {
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        const response = await makeRequest();
        if (response.status !== 429) {
            return response;
        }

        const { retryAfterMs } = parseRateLimitInfo(response);
        const baseDelay = retryAfterMs ?? 500 * 2 ** attempt;
        const jitter = Math.random() * 250;
        const delay = baseDelay + jitter;

        if (attempt === maxAttempts) {
            throw new Error(`Request failed after ${maxAttempts} attempts due to rate limiting`);
        }

        await new Promise((resolve) => setTimeout(resolve, delay));
    }
}

Capping attempts at 3 to 5 matters as much as the backoff itself. A retry loop with no ceiling can leave a user staring at a spinner for minutes while your code quietly hammers an API that is telling you to stop.

What Is Adaptive Pacing, and Why Add It Before You Even Hit a 429?

Adaptive pacing means spacing out requests in a batch based on the signals the API is already giving you, instead of firing every request back to back and only reacting once a 429 shows up. Retry logic fixes a problem after it happens. Pacing prevents the problem from happening as often in the first place.

A simple version tracks the remaining quota from each response and slows down once it drops below a threshold you choose.

async function runBulkCreate(items, createOne) {
    let delayBetweenRequests = 0;
    const results = [];

    for (const item of items) {
        if (delayBetweenRequests > 0) {
            await new Promise((resolve) => setTimeout(resolve, delayBetweenRequests));
        }

        const response = await requestWithBackoff(() => createOne(item));
        const { remaining } = parseRateLimitInfo(response);

        if (remaining !== null && remaining < 10) {
            delayBetweenRequests = 300;
        }

        results.push({ item, status: response.status });
    }

    return results;
}

This is a deliberately simple example, but the idea scales. Some APIs return a full quota window size, and you can pace requests to spread evenly across that window instead of stepping the delay up in one jump. The core principle stays the same. Read the remaining count, and slow down before the API forces you to.

Naive Retry vs Resilient Bulk Requests

AspectNaive approachResilient approach
Delay sourceFixed guess, for example 1 secondRetry-After header first, backoff with jitter as fallback
Retry limitNone, or retries foreverCapped at 3 to 5 attempts, then a clear failure
Request pacingFires as fast as possibleSlows down when remaining quota drops
User feedbackOne generic error at the endLive progress with per item result
Failure modeSilent partial success, unclear which records failedExplicit results summary, safe to retry only the failed items

Surfacing Quota to Users Instead of Guessing

The last piece is not backend logic at all, it is UI. Once your bulk loop tracks remaining quota and per item status, expose that state instead of hiding it behind a single loading spinner. A results table with one row per item, showing created, retried, or failed, turns a confusing partial failure into something the user can act on directly, including retrying only the failed rows instead of the whole batch.

I covered a related resilience lesson, this time about a dependency going down instead of throttling, in Fixing RabbitMQ 4.x Connection: amqplib to CloudAMQP. The retry and backoff instincts from that fix carried over almost directly into this rate limit problem.

Best Practices and Common Pitfalls

  • Always prefer the Retry-After header over your own backoff math when the API provides it.
  • Add random jitter to every backoff delay, not just the first retry, to avoid synchronized retry waves across multiple users.
  • Cap total retry attempts per item so a rate limited batch fails predictably instead of hanging.
  • Track X-RateLimit-Remaining across the whole batch, not just per request, so pacing reacts to the real trend.
  • Never retry a request that already succeeded. Track per item status so a retry only touches items that actually failed.
  • Log the final outcome per item, not just a batch level success or failure count, so support and debugging are possible later.

A cluster level version of the same lesson, staying resilient when the thing you depend on stops cooperating, is in Kubernetes Cluster Down After Reboot: A Full Postmortem. Different layer of the stack, same underlying discipline of checking signals before assuming the worst.

Frequently Asked Questions

What does a 429 status code mean?

A 429 status code means Too Many Requests. The server understood the request but rejected it because the client has exceeded an allowed request rate, as defined in RFC 6585.

Should I always use exponential backoff for 429 errors?

Use exponential backoff with jitter only when the server does not provide a Retry-After header. When Retry-After is present, trust it first, since it reflects the server’s actual rate limit window instead of a guess.

How many times should I retry a rate limited request?

Cap retries at 3 to 5 attempts per request. Beyond that, surface a clear error to the user instead of retrying silently, since more attempts rarely help once a request has already failed that many times.

What is jitter and why does it matter for retries?

Jitter is a small random delay added on top of a backoff wait time. It prevents many clients that got rate limited at the same moment from retrying at that same exact moment, which would otherwise cause a second wave of 429 errors.

How do I prevent 429 errors in a bulk create flow instead of just handling them?

Add adaptive pacing. Track the remaining quota from each response and slow down the loop once it drops below a safe threshold, instead of firing every request back to back and only reacting after a 429 shows up.

Conclusion

The fix for my 150 record bulk create failure was not a bigger retry count, it was reading signals the API had been sending the whole time. Retry-After told me exactly how long to wait. X-RateLimit-Remaining told me when to slow down before the wall hit. Backoff with jitter kept retries from colliding with each other. A per item results view told the admin exactly what happened instead of one vague error.

If you own a bulk operation that talks to any rate limited API, start with the response headers before you write a single line of retry logic. The API is usually already telling you what to do.

Back to Blog

Related Posts

View All Posts »
React Query Stale Data: Why It Shows Old Data & How to Fix It [2026]
Web Development

React Query Stale Data: Why It Shows Old Data & How to Fix It [2026]

React Query showing stale or outdated data on first render, but refreshing correctly after navigation? This guide covers query key issues, staleTime misconfiguration, and placeholder data pitfalls — with code fixes for TanStack Query v4 and v5.