Build retry logic that respects API rate limits by combining exponential backoff, jitter, and idempotency so transient failures do not cascade.
Why Simple Retries Break Production API Workflows
Rate-limit responses (HTTP 429) arrive with varying Retry-After values or no guidance at all. Immediate retries amplify load and can trigger secondary throttling across dependent services.
In webhook-driven flows such as Clover order syncs or Square order ingestion, a single burst of 429s can leave the entire ingestion pipeline stalled until manual intervention.
The correct pattern is exponential backoff with jitter: each failure increases the delay by a factor of two, capped at a maximum, then randomized to prevent synchronized retries across instances.
Core Exponential Backoff Implementation
A minimal retry function tracks attempt count and computes delay as min(base * 2^attempt, maxDelay). Base of 100 ms and maxDelay of 30 s works for most SaaS APIs.
async function withBackoff(fn, maxAttempts = 5) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxAttempts - 1 || err.status !== 429) throw err;
const delay = Math.min(100 * Math.pow(2, attempt), 30000);
await new Promise(r => setTimeout(r, delay));
}
}
}
Adding Full Jitter to Prevent Thundering Herds
Pure exponential backoff still clusters retries when many clients hit the same limit simultaneously. Full jitter randomizes the delay between 0 and the computed backoff value.
const jitter = Math.random() * delay;
await new Promise(r => setTimeout(r, jitter));
This single change dramatically reduces correlated load on the upstream service and is the default recommendation for any Node.js worker processing external APIs.
Wrapping Node Fetch with Rate-Limit Awareness
Production code must inspect response headers (Retry-After, X-RateLimit-Reset) rather than assuming a fixed schedule. The following wrapper reads Retry-After when present and falls back to jittered backoff otherwise.
async function fetchWithRetry(url, opts, max = 5) {
for (let i = 0; i < max; i++) {
const res = await fetch(url, opts);
if (res.status !== 429) return res;
const retryAfter = res.headers.get('Retry-After');
const base = retryAfter ? parseInt(retryAfter, 10) * 1000 : 100 * Math.pow(2, i);
await new Promise(r => setTimeout(r, Math.random() * base));
}
throw new Error('Rate limit exceeded after retries');
}
Pairing Backoff with Idempotency for Webhook Safety
Retries are only safe when the downstream operation is idempotent. Combine the retry wrapper with unique constraints on webhook event IDs as described in the idempotent webhooks guide.
Without this pairing, duplicate processing can occur on the final successful attempt after a 429 window closes.
Observability and Cost at Scale
Log attempt count, final delay, and upstream headers on every 429. Wire these logs into the same tracing stack used for OpenTelemetry in Next.js so rate-limit incidents surface as slow traces rather than silent failures.
At high volume the cumulative delay from backoff becomes a measurable cost; set per-endpoint circuit breakers that temporarily disable syncs when error rates exceed a threshold rather than burning worker time on hopeless retries.
