Contents
You call a third-party API. It hands you back a 429 Too Many Requests. What do you do?
If your reflex is “wait a second, then two, then four, then hope” — same. That was my reflex too. Exponential backoff is the pattern everyone reaches for, it’s in every resilience blog post, and it feels responsible. It’s also, in a lot of cases, just guessing. The API you’re hammering usually tells you exactly how long to wait. You’re choosing to ignore it and roll dice instead.
I learned to stop guessing on a CRM sync. Let me walk through why, and what I replaced it with.
The setup
For about fifteen months I was embedded on an AI voice-and-texting platform for home-services contractors — the kind of product that answers a plumber’s phone, books the job, and follows up afterward. I owned a big chunk of the outbound side, and part of that was the CRM sync: pulling customers, contacts, appointments, and memberships out of the field-service CRMs those contractors run their whole business on, and keeping our copy in step.
Those CRM APIs rate-limit hard. They have to — one is serving thousands of contractors, and a sync job that fans out across a tenant’s entire customer base can hit an endpoint hundreds of times in a burst. The sync ran on durable background jobs, so a single tenant’s refresh was a long-lived function making a lot of calls. Which means: rate limiting wasn’t an edge case. It was the steady state. Every run was going to get throttled somewhere. The only question was how gracefully.
The code I inherited did the textbook thing. Fail, sleep on a doubling curve, retry.
Why blind backoff is actually wrong
Here’s the thing about guessing the wait: you’re wrong in one of two directions, and both cost you.
Guess too long and you burn wall-clock and your quota window. Rate limits usually reset on a fixed window. If the API says “you’re good again in 3 seconds” and your backoff curve has already climbed to 16, you’re sitting idle for 13 seconds you didn’t need to. Multiply that across every throttled call in a fan-out sync and a job that should take two minutes takes twenty. On a per-tenant durable function, that’s the difference between a sync that finishes and one that’s still crawling when the next scheduled run wants to start.
Guess too short and you make it worse. Back off too aggressively — or worse, let a whole pool of workers back off on the same curve — and you march right back into the limit in lockstep. That’s a thundering herd. You’ve now spent retries to earn more 429s.
The frustrating part is that all of this is avoidable, because the server already did the math. A well-behaved rate-limited API answers a 429 with a Retry-After header, and often a set of X-RateLimit-* headers telling you your remaining budget and when the window resets. The information is right there in the response you’re throwing away to go compute a backoff.
The conventional move was exponential backoff. I read the headers instead. Here’s why that’s more than a style preference.
The headers that actually matter
Two things are worth reading off a throttled response.
The first is Retry-After. It comes on 429 and on 503 Service Unavailable, and it has two legal shapes — this is the part people get wrong:
- Delta-seconds:
Retry-After: 120— wait 120 seconds. - An HTTP-date:
Retry-After: Wed, 21 Oct 2026 07:28:00 GMT— wait until that moment.
If you only handle the integer form, an API that sends a date will make Number() return NaN, your parse silently fails, and you fall back to guessing — right when the server was being most explicit with you. So parse both:
// Retry-After is either delta-seconds ("120") or an HTTP-date
// ("Wed, 21 Oct 2026 07:28:00 GMT"). Handle both, return millis.
function parseRetryAfter(header: string | null): number | null {
if (!header) return null;
const seconds = Number(header);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1000);
}
const dateMs = Date.parse(header);
if (!Number.isNaN(dateMs)) {
return Math.max(0, dateMs - Date.now());
}
return null; // header present but unparseable — caller decides
}
The second is the X-RateLimit-* family — usually X-RateLimit-Remaining and X-RateLimit-Reset. These aren’t on the error, they’re on your successful responses, and they let you avoid the 429 entirely. More on that below.
The retry wrapper
Header-first, backoff only as a fallback, and a hard cap so nothing loops forever:
const MAX_RETRIES = 5;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Fallback ONLY when the server didn't tell us. Full jitter so a pool
// of workers doesn't retry in lockstep and re-trigger the limit.
function backoffWithJitter(attempt: number): number {
const capped = Math.min(1000 * 2 ** attempt, 30_000);
return capped / 2 + Math.random() * (capped / 2);
}
async function requestWithRetry(
doRequest: () => Promise<Response>,
attempt = 0,
): Promise<Response> {
const res = await doRequest();
// Only 429 and 503 are worth retrying. A 400/401/404 will never get
// better by waiting — retrying those just burns your quota and delays
// surfacing a real bug.
const retryable = res.status === 429 || res.status === 503;
if (!retryable || attempt >= MAX_RETRIES) {
return res;
}
const headerWait = parseRetryAfter(res.headers.get("retry-after"));
const waitMs = headerWait ?? backoffWithJitter(attempt);
await sleep(waitMs);
return requestWithRetry(doRequest, attempt + 1);
}
Three decisions in there are load-bearing, so let me call them out.
Only retry what’s actually retryable. A 401 isn’t a rate limit, it’s a credentials problem, and a 404 isn’t going to un-404 itself in four seconds. Retrying non-retryable statuses is how you turn one bad request into five and hide the actual failure behind a wall of latency. Retry 429 and 503; let everything else fail fast.
Header first, backoff as the fallback — never the other way around. headerWait ?? backoffWithJitter(attempt) reads the server’s instruction when there is one and only invents a number when there isn’t. The nullish coalescing matters: a Retry-After: 0 is a legitimate “go now,” and ?? preserves it where || would wrongly treat zero as missing.
Jitter on the fallback. When you do have to guess, guess with full jitter. Deterministic backoff across concurrent workers is the thundering herd again. Spreading the retries out is the whole point.
Better: don’t hit the wall in the first place
The retry loop is the safety net. The nicer move is to read your remaining budget off the successful responses and slow down before you get cut off:
const remaining = Number(res.headers.get("x-ratelimit-remaining"));
const resetAt = Number(res.headers.get("x-ratelimit-reset")); // epoch secs
if (Number.isFinite(remaining) && remaining <= 1) {
const waitMs = Math.max(0, resetAt * 1000 - Date.now());
await sleep(waitMs); // pause until the window rolls over
}
On a big fan-out sync this is the difference between gliding right up to the limit and repeatedly slamming into it. You’re pacing yourself to the exact budget the server published instead of discovering the ceiling by bouncing off it.
Why the cap is not optional
MAX_RETRIES looks like a detail. It’s the most important line in the file.
A retry loop with no ceiling is a job that can wait forever. If an upstream misbehaves — sends an absurd Retry-After, flaps between 429 and 503, or gets stuck — an uncapped loop will happily sleep through your entire job budget and never fail. On a durable, long-running function that’s the worst outcome: it doesn’t crash, it doesn’t finish, it just hangs. And a job that’s silently wedged is invisible. Nobody gets paged for a function that’s “still running.”
The cap converts “hang forever” into “fail honestly.” After N honored waits, you give up, return the failure, and let the job mark itself failed — so the sync’s status is true and a human (or the next scheduled run) can act on it. Retries buy resilience; the cap is what keeps resilience from turning into a silent stall.
One more thing that makes all of this safe: the operations behind these calls have to be idempotent. Retrying an upsert is fine. Retrying a “create” that already partially succeeded is a duplicate. If you’re going to retry, make the underlying write safe to run twice — otherwise your resilience layer becomes a data-corruption layer.
What it bought
The rewrite was small and surgical — it landed as part of a broader CRM-sync refactor, not a rewrite-the-world project. But the behavior change was real: syncs stopped idling through backoff curves the server never asked for, stopped stampeding back into limits in lockstep, and — because of the cap — stopped being able to wedge a durable job indefinitely. When a tenant’s sync failed now, it said so, instead of quietly spinning.
The broader lesson stuck with me more than the code did. Resilience patterns get cargo-culted. “Add exponential backoff” is such a reflex that we forget to ask whether the thing we’re backing off from is telling us the answer. A rate-limited API is a cooperative system, not an adversary — it wants you to come back at the right time, and it says so in the headers. Read them. Keep backoff as the fallback for the rare API that stays silent. And always, always cap the loop, because “retry until it works” and “hang until someone notices” are the same code path.
