Rate Limiting and ISR: How to Avoid 429 Errors with External APIs in Next.js
Comprehensive solution to API rate limit issues in Next.js: rate limiter with queue, retry with exponential backoff, ISR, and graceful degradation. Real team experience.
The Problem
You've integrated an external API into your Next.js application. Everything works perfectly locally. You deploy to production, and suddenly:
Error: API error: 429 Too Many Requests
Too many requests, limit exceeded: 20/Second
The error repeats dozens of times. Users see blank pages. The team is panicking, searching for the cause.
Sound familiar? Let's break down why this happens and how to solve it systematically.
Our Case Study
Recently, our team encountered a similar situation while developing an e-commerce platform:
Architecture:
- Next.js 16 with App Router
- External API for product catalog (limit: 20 requests/second)
- SSR for all product and category pages
- Two locales (Russian and English)
- Multiple categories with subcategories
What Happened:
After deployment and the first request to a category page:
GET /shop/category-1 → Next.js starts rendering
→ Request main category products
→ Request 8 subcategories products in PARALLEL
→ Multiply by 2 locales
→ = ~20 requests in fractions of a second
→ 429 Error! ❌
The first user saw errors. Not a great start for production.
Why Did This Happen?
1. Next.js generates many pages on first request
With SSR and first access, Next.js can simultaneously render:
- Different locales (
/en/shop,/ru/shop) - Related pages (prefetch for navigation)
- Metadata for SEO
2. Parallel API requests
Our code looked like this:
// ❌ Problematic code
const subcategories = await getSubcategories()
const allPromises = subcategories.map(sub =>
fetchProducts(sub.id) // All requests at once!
)
const results = await Promise.all(allPromises)
// 10 subcategories = 10 parallel requests = limit exceeded
3. No client-side rate limiting
The API had a 20 req/s limit, but we didn't control request frequency.
4. No retry mechanism
When receiving a 429 error, the application crashed immediately instead of retrying.
Comprehensive Solution
We developed a multi-layered protection system:
1. Rate Limiter with Request Queue
Created a class to control request frequency:
/**
* Rate Limiter to limit requests per second
*/
class RateLimiter {
private queue: Array<() => void> = []
private lastRequestTime = 0
private readonly maxRequestsPerSecond: number
private readonly minRequestInterval: number
constructor(maxRequestsPerSecond: number = 15) {
this.maxRequestsPerSecond = maxRequestsPerSecond
// Calculate minimum interval between requests
this.minRequestInterval = 1000 / maxRequestsPerSecond
}
async acquire(): Promise<void> {
return new Promise((resolve) => {
const tryAcquire = () => {
const now = Date.now()
const timeSinceLastRequest = now - this.lastRequestTime
if (timeSinceLastRequest >= this.minRequestInterval) {
this.lastRequestTime = now
resolve()
} else {
// Wait until the next available slot
const delay = this.minRequestInterval - timeSinceLastRequest
setTimeout(tryAcquire, delay)
}
}
this.queue.push(tryAcquire)
if (this.queue.length === 1) {
this.processQueue()
}
})
}
private processQueue() {
if (this.queue.length > 0) {
const next = this.queue.shift()
if (next) next()
}
}
release() {
this.processQueue()
}
}
// Create a single instance for the entire application
const rateLimiter = new RateLimiter(15) // 15 req/s (buffer from 20 limit)
How Rate Limiter Works:
Request 1 → Rate Limiter → ✅ Immediately to API (0ms delay)
Request 2 → Rate Limiter → ⏱️ Wait 66ms → ✅ To API
(15 req/s = ~66ms between requests)
Request 3 → Rate Limiter → 📋 Queued → ⏱️ Wait → ✅ To API
Result: NEVER exceed 15 req/s!
Key Features:
- ✅ Request queue instead of rejection
- ✅ Automatic minimum interval calculation
- ✅ Buffer from API limit (15 instead of 20 req/s)
2. Retry with Exponential Backoff
Added a smart retry mechanism:
/**
* Retry function with exponential backoff for handling temporary errors
*/
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
initialDelay: number = 1000
): Promise<T> {
let lastError: Error | null = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (error: any) {
lastError = error
// If it's 429 (Too Many Requests), retry
if (error.message?.includes('429') && attempt < maxRetries) {
const delay = initialDelay * Math.pow(2, attempt)
console.log(
`[API] Rate limit hit, retrying in ${delay}ms ` +
`(attempt ${attempt + 1}/${maxRetries})`
)
await new Promise(resolve => setTimeout(resolve, delay))
continue
}
// Don't retry for other errors
throw error
}
}
throw lastError
}
Exponential backoff:
- Attempt 1: wait 1 second
- Attempt 2: wait 2 seconds
- Attempt 3: wait 4 seconds
This gives the API time to "cool down" and reduces load.
3. Integration with API Client
Wrapped all API requests:
class APIClient {
async fetchProducts(params: FetchParams) {
return retryWithBackoff(async () => {
await rateLimiter.acquire()
try {
const response = await fetch(this.buildUrl(params), {
next: {
revalidate: 1800, // Next.js cache for 30 minutes
},
})
if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}
return await response.json()
} finally {
rateLimiter.release()
}
}, 3, 2000) // 3 attempts, initial delay 2 seconds
}
}
Multi-layered Protection:
- Rate limiter controls frequency
- Retry handles temporary failures
- Next.js cache reduces request count
finallyguarantees release even on error
4. Sequential Loading Instead of Parallel
Rewrote subcategory loading code:
// ❌ Was: parallel loading
const allPromises = subcategories.map(sub => fetchProducts(sub.id))
const results = await Promise.all(allPromises)
// ✅ Now: sequential loading
const results = []
for (let i = 0; i < subcategories.length; i++) {
try {
const result = await fetchProducts(subcategories[i].id)
results.push(result)
// Small additional delay for smoothness
if (i < subcategories.length - 1) {
await new Promise(resolve => setTimeout(resolve, 100))
}
} catch (error) {
// Graceful degradation: continue even if one subcategory fails
console.error(`Failed to load subcategory ${subcategories[i].id}:`, error)
// Don't break the loop
}
}
Benefits:
- ✅ Controlled API load
- ✅ Graceful degradation (partial data is better than nothing)
- ✅ Works even with temporary individual request failures
5. ISR (Incremental Static Regeneration)
The main weapon against API overload — page-level caching:
// app/shop/[category]/page.tsx
// Enable ISR with revalidate 3600 seconds (1 hour)
export const revalidate = 3600
export const dynamicParams = true
export default async function CategoryPage({ params }) {
// This code runs on the server
// Result is cached for 1 hour
const products = await fetchProducts(params.category)
return <ProductList products={products} />
}
How It Works:
⏰ t = 0 minutes (First Request)
───────────────────────────────
👤 User → Next.js → 🔍 Cache empty
→ 📡 API request
→ 💾 Store for 1 hour
→ ✅ Response (3-5 sec)
⏰ t = 10 minutes (Hot Cache)
───────────────────────────────
👥 Users → Next.js → ✅ Cache hit!
→ ⚡ Instant response (50ms)
→ 🚫 NO API calls
⏰ t = 61 minutes (Stale Cache)
───────────────────────────────
👤 User → Next.js → ⚠️ Cache stale
→ ⚡ Serve stale (instant!)
→ 🔄 Regenerate in background
→ 📡 API request
→ 💾 Update cache
ISR Results:
| Metric | Before ISR | With ISR |
|---|---|---|
| API requests (first load) | 15-20 | 15-20 |
| API requests (subsequent) | 15-20 | 0 ✅ |
| Load time (first) | 3-5 sec | 3-5 sec |
| Load time (subsequent) | 3-5 sec | < 100ms ✅ |
| 429 error risk | High | Minimal ✅ |
6. Different Revalidate for Different Pages
Configured caching time by importance:
// Main shop page - rarely changes
export const revalidate = 3600 // 1 hour
// Category pages - medium change frequency
export const revalidate = 3600 // 1 hour
// Product pages - prices and availability change often
export const revalidate = 1800 // 30 minutes
// Cart page - always fresh data
export const dynamic = 'force-dynamic' // No cache
7. Cache Pre-warming
Added a script to warm up cache after deployment:
#!/bin/bash
# scripts/warmup-cache.sh
echo "🔥 Warming up cache..."
# Main pages
curl -s "https://example.com/en/shop" > /dev/null
curl -s "https://example.com/ru/shop" > /dev/null
# Popular categories
for category in electronics software services; do
curl -s "https://example.com/en/shop/$category" > /dev/null
curl -s "https://example.com/ru/shop/$category" > /dev/null
done
echo "✅ Cache warmed up!"
Integrated into CI/CD:
# .github/workflows/deploy.yml
- name: Deploy to production
run: npm run deploy
- name: Warm up cache
run: ./scripts/warmup-cache.sh
Now first users get already cached pages!
Results
Metrics Before and After Optimization:
| Metric | Before | After | Improvement |
|---|---|---|---|
| 429 errors on deploy | 15-20 | 0 | ✅ 100% |
| Average load time | 3.2 sec | 0.08 sec | ✅ 40x |
| API requests per hour | ~500 | ~15 | ✅ 97% |
| Uptime | 99.2% | 99.9% | ✅ +0.7% |
| User rating | 3.8/5 | 4.7/5 | ✅ +24% |
Best Practices
1. Always Use Buffer from Limit
// ❌ Bad: using full limit
const rateLimiter = new RateLimiter(20) // API limit: 20 req/s
// ✅ Good: leave buffer
const rateLimiter = new RateLimiter(15) // 75% of limit
Why? Network delays, other processes, load spikes.
2. Log Retry Attempts
if (attempt < maxRetries) {
console.log(
`[API] Rate limit hit, retrying in ${delay}ms ` +
`(attempt ${attempt + 1}/${maxRetries})`
)
}
These are not errors — this is normal system operation!
3. Graceful Degradation Everywhere
for (const item of items) {
try {
const result = await processItem(item)
results.push(result)
} catch (error) {
console.error(`Failed to process ${item.id}:`, error)
// Continue with next item
// Partial data is better than a blank page
}
}
4. Different Strategies for Different Data
// Critical data: aggressive retry
const userData = await retryWithBackoff(
() => fetchUserData(),
5, // 5 attempts
500 // start with 500ms
)
// Non-critical data: quick fail
const recommendations = await retryWithBackoff(
() => fetchRecommendations(),
1, // 1 attempt
1000
).catch(() => []) // return empty array on error
Common Mistakes
❌ Mistake 1: Relying Only on Retry
// Not enough!
const data = await retryWithBackoff(() => fetchData())
Problem: If there are many requests, retry will only worsen the situation.
Solution: Rate limiter + Retry + ISR.
❌ Mistake 2: Aggressive Retry
// Bad: too fast attempts
await retryWithBackoff(fn, 10, 100) // 10 attempts with 100ms
Problem: Overloads API even more.
Solution: 3-5 attempts with exponential backoff from 1-2 seconds.
❌ Mistake 3: One Revalidate for Everything
// Suboptimal
export const revalidate = 3600 // for all pages
Problem: Static data updates too often, dynamic data — too rarely.
Solution: Individual revalidate for each page.
Monitoring in Production
Metrics to Track:
// lib/metrics.ts
export const apiMetrics = {
// Rate limiting
rateLimitHits: 0,
rateLimitWaitTime: 0,
// Retry
retryAttempts: 0,
retrySuccesses: 0,
retryFailures: 0,
// Cache
cacheHits: 0,
cacheMisses: 0,
cacheRevalidations: 0,
// API
apiCalls: 0,
apiErrors: 0,
apiLatency: [],
}
Dashboard Example:
📊 API Performance Dashboard
Rate Limiting:
├─ Hits today: 12 (normal)
├─ Avg wait time: 45ms
└─ Queue size: 0
Retry Statistics:
├─ Total attempts: 8
├─ Successes: 8 (100%)
└─ Failures: 0
Cache Performance:
├─ Hit rate: 99.2%
├─ Revalidations/hour: 4
└─ Avg response time: 82ms
API Health:
├─ Calls today: 234
├─ Error rate: 0.0%
└─ Avg latency: 156ms
Implementation Checklist
-
Rate Limiter
- RateLimiter class created
- Limit defined with buffer (75% of API limit)
- All API requests go through limiter
-
Retry Mechanism
- retryWithBackoff implemented
- Exponential backoff configured (1s, 2s, 4s)
- Attempt logging
-
ISR Caching
-
revalidateadded to pages - Revalidate time chosen for each page type
-
dynamicParams = truefor dynamic routes
-
-
Request Optimization
- Parallel requests replaced with sequential where needed
- Delays added between requests
- Graceful degradation implemented
-
Pre-warming
- warmup-cache.sh script created
- Integrated into CI/CD
- Main pages warmed up
-
Monitoring
- Retry attempt logging
- API request metrics
- Alerts for critical errors
-
Testing
- Load testing
- 429 behavior verification
- Load time verification
Conclusion
The rate limiting problem with external APIs is a systemic problem requiring a multi-layered solution:
- Rate Limiter — prevents limit exceeding
- Retry with backoff — handles temporary failures
- ISR — minimizes request count
- Sequential loading — controls load
- Graceful degradation — improves UX on errors
- Pre-warming — speeds up first load
- Monitoring — keeps system under control
Our team implemented these solutions and achieved:
- ✅ 0 429 errors in production
- ✅ 40x improvement in load time
- ✅ 97% reduction in API requests
- ✅ 24% increase in user rating
Main Lesson: Don't rely on one tool. Combine approaches for a reliable and performant system.