All articles🇬🇧 English
11 min

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.

next.jsrate-limitingisrapioptimizationretryperformance

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:

  1. Rate limiter controls frequency
  2. Retry handles temporary failures
  3. Next.js cache reduces request count
  4. finally guarantees 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:

MetricBefore ISRWith ISR
API requests (first load)15-2015-20
API requests (subsequent)15-200
Load time (first)3-5 sec3-5 sec
Load time (subsequent)3-5 sec< 100ms
429 error riskHighMinimal

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:

MetricBeforeAfterImprovement
429 errors on deploy15-200✅ 100%
Average load time3.2 sec0.08 sec✅ 40x
API requests per hour~500~15✅ 97%
Uptime99.2%99.9%✅ +0.7%
User rating3.8/54.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

    • revalidate added to pages
    • Revalidate time chosen for each page type
    • dynamicParams = true for 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:

  1. Rate Limiter — prevents limit exceeding
  2. Retry with backoff — handles temporary failures
  3. ISR — minimizes request count
  4. Sequential loading — controls load
  5. Graceful degradation — improves UX on errors
  6. Pre-warming — speeds up first load
  7. 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.