April 2025 · 8 min read
Every production API integration eventually hits a rate limit. The 429 Too Many Requests response is a normal part of working with external APIs at scale — but how you handle it determines whether your application degrades gracefully or fails completely. This guide covers the most effective patterns for handling rate limits in production Node.js and Python applications.
Most APIs include rate limit information in response headers. Check for X-RateLimit-Limit (your total quota), X-RateLimit-Remaining (calls left in the current window), and X-RateLimit-Reset (Unix timestamp when the window resets). Reading these headers proactively allows you to slow down before hitting the limit rather than reacting to 429 errors after the fact.
async function callWithRateCheck(url, options) {
const resp = await fetch(url, options);
const remaining = parseInt(resp.headers.get('X-RateLimit-Remaining') || '100');
const reset = parseInt(resp.headers.get('X-RateLimit-Reset') || '0');
if (remaining < 10) {
const msUntilReset = (reset * 1000) - Date.now();
if (msUntilReset > 0) {
console.log(`Rate limit low — waiting ${msUntilReset}ms`);
await sleep(msUntilReset);
}
}
return resp;
}
When you receive a 429, the naive response is to wait a fixed interval and retry. The problem is that if many clients all retry at the same time after the same fixed delay, they hit the server in synchronized bursts — triggering rate limits again immediately. Exponential backoff with jitter spreads retries out randomly, reducing synchronized retry storms.
async function withRetry(fn, maxAttempts = 4) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (err.status === 429 && attempt < maxAttempts - 1) {
const base = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
const jitter = Math.random() * 1000; // 0-1s random
await sleep(base + jitter);
continue;
}
throw err;
}
}
}
A token bucket is a simple client-side rate limiter. You have a bucket that holds N tokens and refills at a fixed rate. Each API call consumes one token. If the bucket is empty, the call waits until a token is available. This prevents ever sending requests faster than your rate limit allows — no 429s, no retries needed.
class TokenBucket {
constructor(capacity, refillPerSecond) {
this.capacity = capacity;
this.tokens = capacity;
this.refillRate = refillPerSecond;
this.lastRefill = Date.now();
}
async consume() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
if (this.tokens < 1) {
const waitMs = ((1 - this.tokens) / this.refillRate) * 1000;
await sleep(waitMs);
this.tokens = 0;
} else {
this.tokens--;
}
}
}
// Usage: 10 req/s sustained, burst up to 20
const bucket = new TokenBucket(20, 10);
for (const url of urls) {
await bucket.consume();
await callApi(url);
}For batch API calls, a queue with a concurrency limit is often more practical than a token bucket. The p-limit npm package provides exactly this — set a maximum concurrency of N and it handles queuing automatically.
import pLimit from 'p-limit';
const limit = pLimit(5); // Max 5 concurrent API calls
const results = await Promise.all(
urls.map(url =>
limit(() => callSnapApi(url))
)
);
console.log(`Captured ${results.length} screenshots`);Combine p-limit for concurrency control with the exponential backoff retry wrapper for robust batch processing. The concurrency limit prevents burst overload; the retry wrapper handles the occasional 429 that slips through during traffic spikes.
A circuit breaker prevents your application from hammering a rate-limited or unavailable API with repeated failing requests. After N consecutive failures, the circuit opens — all requests fail fast for a cooldown period. After the cooldown, the circuit enters half-open state and allows one test request. If it succeeds, the circuit closes and normal operation resumes.
class CircuitBreaker {
constructor(threshold = 5, cooldownMs = 30000) {
this.threshold = threshold;
this.cooldown = cooldownMs;
this.failures = 0;
this.state = 'closed';
this.nextAttempt = 0;
}
async call(fn) {
if (this.state === 'open') {
if (Date.now() < this.nextAttempt) throw new Error('Circuit open');
this.state = 'half-open';
}
try {
const result = await fn();
this.failures = 0;
this.state = 'closed';
return result;
} catch (err) {
this.failures++;
if (this.failures >= this.threshold) {
this.state = 'open';
this.nextAttempt = Date.now() + this.cooldown;
}
throw err;
}
}
}In Python, the tenacity library provides declarative retry logic with a clean decorator API. Configure stop conditions, wait strategies, and retry predicates in a single decorator.
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
import requests
def is_rate_limited(exc):
return isinstance(exc, requests.HTTPError) and exc.response.status_code == 429
@retry(
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=1, min=2, max=30),
retry=retry_if_exception(is_rate_limited)
)
def call_snapapi(url):
resp = requests.post(
"https://api.snapapi.pics/v1/screenshot",
headers={"X-Api-Key": os.environ["SNAP_API_KEY"]},
json={"url": url, "full_page": True}
)
resp.raise_for_status()
return resp.json()The @retry decorator retries on HTTP 429 with exponential backoff between 2 and 30 seconds, stopping after 4 total attempts. Other exceptions propagate immediately without retrying. For async Python, tenacity provides the same decorator interface with AsyncRetrying for use with asyncio and httpx.
When your application approaches its rate limit, not all requests are equal. User-triggered captures (a user clicking "download PDF") should take priority over background batch jobs. Implement a two-tier queue: a high-priority queue for synchronous user requests and a low-priority queue for async batch work. The low-priority queue only processes requests when the high-priority queue is empty and rate limit headroom exists.
With BullMQ in Node.js, implement priority queuing by using different job priorities — lower numbers process first. User-triggered jobs get priority 1; background batch jobs get priority 10. The worker processes the highest-priority jobs first, naturally deprioritizing batch work when user traffic spikes.
Track your API quota consumption in your own metrics system. Log the X-RateLimit-Remaining value from each response and alert when it drops below 20% of your limit. This gives you visibility into usage spikes before they cause 429 errors in production.
SnapAPI's dashboard shows your monthly usage, request history, and remaining quota in real time. For programmatic access, call the /usage endpoint with your API key to retrieve current quota and usage statistics in JSON format. Sign up at snapapi.pics to get your free API key and 200 monthly captures to start.
SnapAPI returns X-RateLimit-Remaining and X-RateLimit-Reset headers on every response. The rate limits depend on your plan: free tier allows 10 requests per minute, Starter allows 60, Pro allows 300, and Business allows 1,000. Monthly quota limits apply separately — the per-minute rate limit prevents burst overload, while the monthly quota limits total consumption.
For most integrations, a p-limit concurrency of 5 concurrent requests combined with exponential backoff retry on 429 responses is sufficient. For batch scraping jobs that run hundreds of URLs, add a token bucket to ensure you never exceed 60 requests per minute on the Starter plan — this keeps the job running smoothly without triggering rate limits.
The patterns in this guide apply to any external API, not just SnapAPI. Exponential backoff with jitter, token buckets, p-limit concurrency control, and circuit breakers are standard building blocks of resilient API integrations. Adding them to your integration stack takes a few hours and prevents an entire category of production incidents.
When building a production API integration, work through this checklist to ensure you handle rate limits gracefully in all scenarios.
Read rate limit headers on every response and log them for monitoring.
Implement exponential backoff with jitter for retry logic on 429 and 5xx responses.
Use a concurrency limiter (p-limit or semaphore) to cap parallel requests.
Add a circuit breaker to fail fast during extended outages.
Prioritize user-triggered requests over background batch jobs.
Alert when rate limit remaining drops below 20% of your quota.
Test retry and rate limit handling in your staging environment before production deploy.
These patterns keep your application resilient under load and prevent a rate limit event from cascading into a user-visible outage. SnapAPI provides generous rate limits at every plan tier and transparent usage reporting via the dashboard and API headers, making it straightforward to monitor and stay within your quota. Sign up at snapapi.pics for a free account to get started.
For most browser automation and screenshot API integrations, the token bucket algorithm offers the best trade-off between simplicity and flexibility. It naturally accommodates short bursts while enforcing a long-run average, which mirrors how most real workloads actually arrive: a batch of twenty screenshots fired in parallel, then nothing for ten seconds. Fixed windows are easier to implement but create thundering herd problems at window roll-over boundaries. Sliding windows are the most accurate but require a sorted set in Redis, adding latency on high-throughput paths.
When integrating with SnapAPI, always read the X-RateLimit-Remaining and Retry-After response headers. On a 429 response, wait the exact duration specified in the Retry-After header rather than using a fixed backoff interval, which can still collide with other clients sharing the same plan quota. For large batch workloads, pre-calculate your sustainable request rate from your monthly quota and throttle on the client side before hitting the API. This prevents cascading retries and keeps your data pipeline running smoothly without gaps.
Well-designed rate limiting protects your API budget, keeps integrations stable under load, and ensures fair access across all consumers of your service. Start with exponential backoff as a baseline, graduate to a token bucket when burst tolerance becomes important, and always observe the response headers returned by every API you call. These small habits compound into systems that handle traffic spikes gracefully rather than falling over at the worst possible moment.