Ajie Logo Ajie.
Navigation

© 2026 Ajie Kusumadhany.

Back to Articles

Why Your API Rate Limiting Strategy Is Probably Wrong Mengapa Strategi Rate Limiting API Anda Kemungkinan Salah

Ajie Ajie Kusumadhany
Jun 30, 2026 9 min read
Why Your API Rate Limiting Strategy Is Probably Wrong Mengapa Strategi Rate Limiting API Anda Kemungkinan Salah

Your API just went down at 3 AM. Again.

The culprit? A single aggressive client made 50,000 requests in two minutes, overwhelming your servers before your rate limiter even noticed.

You thought you had protection. You implemented rate limiting months ago, following a popular tutorial. But here's the uncomfortable truth: most rate limiting implementations are fundamentally broken.

They work perfectly in development. They pass your unit tests. But when real traffic hits—with its messy patterns, distributed attacks, and edge cases—they fail spectacularly.

The Illusion of the Fixed Window

Let's start with the most common mistake: the fixed window counter.

You've probably seen this pattern. It's dead simple: count requests per minute, reset the counter every 60 seconds, block anything over the limit.

Here's what most developers write:

const requestCounts = {};

function checkRateLimit(userId) {
  const currentMinute = Math.floor(Date.now() / 60000);
  const key = `${userId}:${currentMinute}`;
  
  if (!requestCounts[key]) {
    requestCounts[key] = 0;
  }
  
  requestCounts[key]++;
  return requestCounts[key] <= 100; // 100 req/min limit
}

This looks reasonable. It's efficient, easy to understand, and fits in a Reddit comment.

But it has a fatal flaw: the window reset vulnerability.

Imagine a user makes 100 requests at 12:00:59. Your counter allows them. At 12:01:00, the window resets. They immediately make another 100 requests.

That's 200 requests in 2 seconds. Your "100 requests per minute" limit just became completely meaningless.

The Sliding Window Illusion

Smart developers realize the fixed window problem and reach for sliding windows.

The idea sounds better: instead of hard resets, track requests with timestamps and count only those within the last 60 seconds.

const requests = {};

function slidingWindowCheck(userId) {
  const now = Date.now();
  const windowStart = now - 60000;
  
  if (!requests[userId]) {
    requests[userId] = [];
  }
  
  // Remove old requests
  requests[userId] = requests[userId].filter(t => t > windowStart);
  
  if (requests[userId].length >= 100) {
    return false;
  }
  
  requests[userId].push(now);
  return true;
}

This is better. The window reset attack no longer works.

But you've introduced a new problem: memory explosion.

Every request now stores a timestamp. With 10,000 active users making 100 requests per minute each, you're storing 1 million timestamps in memory. That's roughly 16 MB just for rate limiting metadata.

Scale to 100,000 users and you're looking at 160 MB. Your rate limiter has become a memory leak.

The Token Bucket Reality Check

Enter the token bucket algorithm, the darling of distributed systems.

The concept is elegant: imagine a bucket that holds tokens. Tokens regenerate at a fixed rate. Each request consumes a token. No tokens? Request denied.

Algorithm Memory per User Burst Handling Edge Case Vulnerability
Fixed Window 1 integer Poor Window reset attacks
Sliding Window N timestamps Good Memory explosion
Token Bucket 2 integers Excellent Clock drift
Leaky Bucket Queue size Fair Delayed responses

Token bucket handles burst traffic gracefully. If a user hasn't made requests for a while, they accumulate tokens and can burst up to the bucket capacity.

The memory footprint is constant: just two numbers per user (current tokens and last refill time).

class TokenBucket {
  constructor(capacity, refillRate) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillRate = refillRate; // tokens per second
    this.lastRefill = Date.now();
  }
  
  consume(tokens = 1) {
    this.refill();
    
    if (this.tokens >= tokens) {
      this.tokens -= tokens;
      return true;
    }
    return false;
  }
  
  refill() {
    const now = Date.now();
    const timePassed = (now - this.lastRefill) / 1000;
    const tokensToAdd = timePassed * this.refillRate;
    
    this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
    this.lastRefill = now;
  }
}

But even token bucket has a dark side: clock synchronization.

In distributed systems, server clocks drift. One server thinks it's 12:00:00.000, another thinks it's 12:00:00.342. A user hitting both servers can exploit this microsecond difference to bypass rate limits.

The Redis Trap Everyone Falls Into

Eventually, you realize in-memory rate limiting won't scale across multiple servers.

The standard solution: Redis. Store your counters in Redis, and all servers share the same rate limit state.

Here's where developers make the next critical mistake:

async function redisRateLimit(userId) {
  const key = `rate:${userId}`;
  const current = await redis.get(key);
  
  if (current && parseInt(current) >= 100) {
    return false;
  }
  
  await redis.incr(key);
  await redis.expire(key, 60);
  return true;
}

Can you spot the race condition?

Between get and incr, another request might sneak through. Two requests check simultaneously, both see 99, both increment. Your limit of 100 just became 101.

Multiply this across thousands of concurrent requests, and your rate limiter becomes decorative.

The fix requires Lua scripting in Redis to make operations atomic:

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('GET', key)

if current and tonumber(current) >= limit then
  return 0
end

redis.call('INCR', key)

if current == nil then
  redis.call('EXPIRE', key, window)
end

return 1

Now your rate limiting logic is split between application code and database scripts. Debugging becomes a nightmare.

The Hidden Cost of Distributed Rate Limiting

Even with atomic Redis operations, distributed rate limiting introduces latency.

Every single API request now requires a round trip to Redis. If Redis is 2ms away, you've added 2ms to every request. At 1,000 requests per second, that's 2 full seconds of cumulative delay.

Some teams try to optimize with local caches: check Redis occasionally, cache the result locally for a few seconds.

Congratulations, you've just reintroduced the race condition you tried to solve.

What Actually Works in Production

After years of battle scars, here's what works reliably at scale:

Layer Your Rate Limits

Don't rely on a single rate limit strategy. Use multiple layers:

  • Global rate limit: Hard cap on total requests per second across all users (prevents infrastructure overload)
  • Per-user rate limit: Token bucket for normal users (handles legitimate burst traffic)
  • Per-IP rate limit: Aggressive fixed window for anonymous requests (stops DDoS attempts)
  • Endpoint-specific limits: Stricter limits on expensive operations like search or exports

Use Approximate Algorithms

Perfect accuracy isn't necessary. Being 95% accurate with 10x better performance is a worthy tradeoff.

The sliding window log approximation combines fixed window simplicity with sliding window accuracy:

function approximateSlidingWindow(userId) {
  const now = Date.now();
  const currentWindow = Math.floor(now / 60000);
  const previousWindow = currentWindow - 1;
  
  const currentCount = redis.get(`rate:${userId}:${currentWindow}`) || 0;
  const previousCount = redis.get(`rate:${userId}:${previousWindow}`) || 0;
  
  const timeIntoWindow = (now % 60000) / 60000;
  const estimatedCount = previousCount * (1 - timeIntoWindow) + currentCount;
  
  return estimatedCount < 100;
}

You get sliding window behavior with fixed window memory efficiency. The estimation is "wrong" by at most a few percent, which doesn't matter in practice.

Implement Circuit Breakers

Rate limiting isn't just about individual users. It's about protecting your entire system.

When your database starts struggling, your rate limiter should automatically tighten limits across the board. When Redis goes down, your rate limiter should fail open (allow requests) rather than fail closed (block everything).

Monitor and Alert Aggressively

Your rate limiter is only as good as your visibility into it.

Track these metrics religiously:

  • Rate limit rejections per endpoint
  • Top users hitting rate limits
  • Ratio of rejected to accepted requests
  • P95 latency added by rate limiting
  • False positive rate (legitimate users getting blocked)

Pro Tips for Bulletproof Rate Limiting

Return proper headers. Include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset in every response. Well-behaved clients will respect them.

Use 429 status codes. Don't return 403 or 500 when rate limiting. Use the correct HTTP 429 Too Many Requests status.

Implement retry-after. Tell clients exactly when they can retry with the Retry-After header. This prevents thundering herd problems.

Whitelist your own services. Your monitoring, health checks, and internal services shouldn't count against rate limits.

Make limits visible. Document your rate limits clearly. Surprising users with undocumented limits leads to angry support tickets.

Test under load. Your rate limiter works great with 10 requests per second. Does it still work at 10,000? Load test it separately.

Plan for failure modes. What happens when Redis is unreachable? When your rate limiter crashes? Design for graceful degradation, not catastrophic failure.

Rate limiting seems simple until you need it to actually work. The difference between a toy implementation and production-ready rate limiting is understanding where naive approaches break and having the battle-tested patterns to handle real-world chaos.

Your 3 AM pages will thank you.

API Anda baru saja down pukul 3 pagi. Lagi.

Penyebabnya? Satu klien agresif membuat 50.000 request dalam dua menit, membanjiri server Anda sebelum rate limiter sempat menyadarinya.

Anda pikir sudah ada perlindungan. Anda sudah implementasi rate limiting beberapa bulan lalu, mengikuti tutorial populer. Tapi ini fakta yang tidak nyaman: kebanyakan implementasi rate limiting secara fundamental rusak.

Mereka bekerja sempurna di development. Lolos semua unit test. Tapi ketika traffic nyata datang—dengan pola berantakan, serangan terdistribusi, dan edge case—mereka gagal total.

Ilusi Fixed Window

Mari mulai dari kesalahan paling umum: fixed window counter.

Anda mungkin pernah lihat pola ini. Sangat sederhana: hitung request per menit, reset counter setiap 60 detik, blokir apapun yang melebihi batas.

Inilah yang biasanya developer tulis:

const requestCounts = {};

function checkRateLimit(userId) {
  const currentMinute = Math.floor(Date.now() / 60000);
  const key = `${userId}:${currentMinute}`;
  
  if (!requestCounts[key]) {
    requestCounts[key] = 0;
  }
  
  requestCounts[key]++;
  return requestCounts[key] <= 100; // batas 100 req/menit
}

Ini terlihat masuk akal. Efisien, mudah dipahami, dan muat dalam komentar Reddit.

Tapi ada cacat fatal: kerentanan window reset.

Bayangkan user membuat 100 request pada pukul 12:00:59. Counter Anda mengizinkan mereka. Pada pukul 12:01:00, window di-reset. Mereka langsung membuat 100 request lagi.

Itu 200 request dalam 2 detik. Batas "100 request per menit" Anda jadi tidak berarti sama sekali.

Ilusi Sliding Window

Developer yang cerdas menyadari masalah fixed window dan beralih ke sliding window.

Idenya terdengar lebih baik: alih-alih reset keras, lacak request dengan timestamp dan hitung hanya yang dalam 60 detik terakhir.

const requests = {};

function slidingWindowCheck(userId) {
  const now = Date.now();
  const windowStart = now - 60000;
  
  if (!requests[userId]) {
    requests[userId] = [];
  }
  
  // Hapus request lama
  requests[userId] = requests[userId].filter(t => t > windowStart);
  
  if (requests[userId].length >= 100) {
    return false;
  }
  
  requests[userId].push(now);
  return true;
}

Ini lebih baik. Serangan window reset tidak lagi berhasil.

Tapi Anda memperkenalkan masalah baru: ledakan memori.

Setiap request sekarang menyimpan timestamp. Dengan 10.000 user aktif membuat 100 request per menit, Anda menyimpan 1 juta timestamp di memori. Itu sekitar 16 MB hanya untuk metadata rate limiting.

Skala ke 100.000 user dan Anda melihat 160 MB. Rate limiter Anda telah menjadi memory leak.

Realita Token Bucket

Masuklah algoritma token bucket, favorit sistem terdistribusi.

Konsepnya elegan: bayangkan ember berisi token. Token beregenerasi dengan kecepatan tetap. Setiap request mengkonsumsi token. Tidak ada token? Request ditolak.

Algoritma Memori per User Penanganan Burst Kerentanan Edge Case
Fixed Window 1 integer Buruk Serangan window reset
Sliding Window N timestamp Bagus Ledakan memori
Token Bucket 2 integer Sangat Baik Clock drift
Leaky Bucket Ukuran queue Adil Response tertunda

Token bucket menangani burst traffic dengan anggun. Jika user tidak membuat request dalam waktu lama, mereka mengakumulasi token dan bisa burst hingga kapasitas ember.

Jejak memori konstan: hanya dua angka per user (token saat ini dan waktu refill terakhir).

class TokenBucket {
  constructor(capacity, refillRate) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillRate = refillRate; // token per detik
    this.lastRefill = Date.now();
  }
  
  consume(tokens = 1) {
    this.refill();
    
    if (this.tokens >= tokens) {
      this.tokens -= tokens;
      return true;
    }
    return false;
  }
  
  refill() {
    const now = Date.now();
    const timePassed = (now - this.lastRefill) / 1000;
    const tokensToAdd = timePassed * this.refillRate;
    
    this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
    this.lastRefill = now;
  }
}

Tapi bahkan token bucket punya sisi gelap: sinkronisasi jam.

Dalam sistem terdistribusi, jam server bergeser. Satu server pikir pukul 12:00:00.000, yang lain pikir 12:00:00.342. User yang hit kedua server bisa eksploitasi perbedaan mikrodetik ini untuk bypass rate limit.

Jebakan Redis yang Semua Orang Alami

Akhirnya, Anda sadar rate limiting in-memory tidak akan scale di multiple server.

Solusi standar: Redis. Simpan counter di Redis, dan semua server berbagi state rate limit yang sama.

Di sinilah developer membuat kesalahan kritis berikutnya:

async function redisRateLimit(userId) {
  const key = `rate:${userId}`;
  const current = await redis.get(key);
  
  if (current && parseInt(current) >= 100) {
    return false;
  }
  
  await redis.incr(key);
  await redis.expire(key, 60);
  return true;
}

Bisa temukan race condition-nya?

Antara get dan incr, request lain bisa menyelinap masuk. Dua request cek bersamaan, keduanya lihat 99, keduanya increment. Batas 100 Anda jadi 101.

Kalikan ini di ribuan concurrent request, dan rate limiter Anda jadi dekoratif.

Perbaikannya membutuhkan Lua scripting di Redis untuk membuat operasi atomic:

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('GET', key)

if current and tonumber(current) >= limit then
  return 0
end

redis.call('INCR', key)

if current == nil then
  redis.call('EXPIRE', key, window)
end

return 1

Sekarang logika rate limiting Anda terbagi antara kode aplikasi dan script database. Debugging jadi mimpi buruk.

Biaya Tersembunyi Distributed Rate Limiting

Bahkan dengan operasi Redis atomic, distributed rate limiting memperkenalkan latency.

Setiap API request sekarang memerlukan round trip ke Redis. Jika Redis berjarak 2ms, Anda menambahkan 2ms ke setiap request. Pada 1.000 request per detik, itu 2 detik penuh delay kumulatif.

Beberapa tim coba optimasi dengan cache lokal: cek Redis sesekali, cache hasilnya lokal beberapa detik.

Selamat, Anda baru saja memperkenalkan kembali race condition yang coba Anda selesaikan.

Yang Benar-Benar Bekerja di Production

Setelah bertahun-tahun pengalaman, inilah yang bekerja reliabel dalam skala besar:

Susun Rate Limit Berlapis

Jangan andalkan satu strategi rate limit. Gunakan multiple layer:

  • Global rate limit: Hard cap total request per detik di semua user (mencegah overload infrastruktur)
  • Per-user rate limit: Token bucket untuk user normal (menangani burst traffic legitim)
  • Per-IP rate limit: Fixed window agresif untuk request anonim (menghentikan upaya DDoS)
  • Limit spesifik endpoint: Batas lebih ketat pada operasi mahal seperti search atau export

Gunakan Algoritma Approximate

Akurasi sempurna tidak perlu. Akurat 95% dengan performa 10x lebih baik adalah tradeoff yang layak.

Sliding window log approximation menggabungkan kesederhanaan fixed window dengan akurasi sliding window:

function approximateSlidingWindow(userId) {
  const now = Date.now();
  const currentWindow = Math.floor(now / 60000);
  const previousWindow = currentWindow - 1;
  
  const currentCount = redis.get(`rate:${userId}:${currentWindow}`) || 0;
  const previousCount = redis.get(`rate:${userId}:${previousWindow}`) || 0;
  
  const timeIntoWindow = (now % 60000) / 60000;
  const estimatedCount = previousCount * (1 - timeIntoWindow) + currentCount;
  
  return estimatedCount < 100;
}

Anda mendapat behavior sliding window dengan efisiensi memori fixed window. Estimasinya "salah" paling banyak beberapa persen, yang tidak masalah dalam praktik.

Implementasi Circuit Breaker

Rate limiting bukan hanya tentang user individual. Ini tentang melindungi seluruh sistem Anda.

Ketika database mulai struggle, rate limiter harus otomatis memperketat limit di semua board. Ketika Redis down, rate limiter harus fail open (izinkan request) daripada fail closed (blokir semua).

Monitor dan Alert Secara Agresif

Rate limiter Anda hanya sebaik visibilitas Anda ke dalamnya.

Lacak metrik ini dengan religius:

  • Penolakan rate limit per endpoint
  • Top user yang hit rate limit
  • Rasio request ditolak vs diterima
  • P95 latency yang ditambahkan rate limiting
  • False positive rate (user legitim yang terblokir)

Tips Praktis untuk Rate Limiting Anti Peluru

Kembalikan header yang tepat. Sertakan X-RateLimit-Limit, X-RateLimit-Remaining, dan X-RateLimit-Reset di setiap response. Klien yang baik akan menghormatinya.

Gunakan status code 429. Jangan return 403 atau 500 saat rate limiting. Gunakan HTTP 429 Too Many Requests yang benar.

Implementasi retry-after. Beritahu klien kapan tepatnya mereka bisa retry dengan header Retry-After. Ini mencegah masalah thundering herd.

Whitelist service Anda sendiri. Monitoring, health check, dan internal service Anda tidak boleh terhitung dalam rate limit.

Buat limit terlihat. Dokumentasikan rate limit dengan jelas. Kejutkan user dengan limit tidak terdokumentasi menghasilkan tiket support marah.

Test dengan load. Rate limiter Anda bekerja hebat dengan 10 request per detik. Apakah masih bekerja di 10.000? Load test secara terpisah.

Rencanakan mode kegagalan. Apa yang terjadi ketika Redis tidak terjangkau? Ketika rate limiter crash? Desain untuk degradasi anggun, bukan kegagalan katastropik.

Rate limiting terlihat sederhana sampai Anda perlu membuatnya benar-benar bekerja. Perbedaan antara implementasi mainan dan rate limiting production-ready adalah memahami di mana pendekatan naif rusak dan memiliki pola teruji untuk menangani kekacauan dunia nyata.

Pager 3 pagi Anda akan berterima kasih.

#API Design #Backend #Security #Performance