Back to blog
Guides

Rate Limiting Algorithms Explained: When to Use Which

There are 7 distinct rate limiting algorithms and they all behave differently for the same config. Here's when each one makes sense.

#rate-limiting#algorithms#typescript#backend

Rate limiting is one of those things every API needs but few developers think deeply about. You slap on a “100 requests per minute” limit and call it a day. But there are actually 7 distinct algorithms, each with wildly different behavior for the same configuration.

This post explains when each algorithm shines, when it fails, and shows the actual behavior difference with code you can run.

The Problem They All Solve

A client sends too many requests. You need to say “no” at some point. The question is how you count and when you say no.

Let’s say your limit is 10 requests per minute. Depending on the algorithm, a client could:

  • Send 20 requests in 2 seconds (boundary exploit)
  • Send exactly 10 evenly-spaced requests (smooth)
  • Send 10 in a burst, then wait (bursty but fair)
  • Run 10 operations in parallel (concurrent)

Same limit. Completely different behavior.

1. Fixed Window

How it works: Divide time into fixed buckets (e.g., 0:00-1:00, 1:00-2:00). Count requests in the current bucket. Reset when a new bucket starts.

Window 1 (0:00-1:00)          Window 2 (1:00-2:00)
|-- req req req -- limit --|  |-- req -- resets --|
    count: 3                      count: 1
    limit: 10                     limit: 10

The catch: A client can send 10 requests at 0:59 and 10 more at 1:00. That’s 20 requests in 2 seconds despite a “10 per minute” limit. This is the boundary burst problem.

import { rateLimit } from '@tzezar/throtto'

const limiter = rateLimit({ limit: 10, window: '1m', algorithm: 'fixed-window' })

// At 0:59 - sends 10 requests (all allowed, window nearly over)
// At 1:00 - sends 10 more (all allowed, new window!)
// Result: 20 requests in 2 seconds

When to use: Non-critical limits where simplicity matters. Dashboard rate limiting, analytics endpoints, internal services.

When NOT to use: Anything where a 2x burst matters. Authentication endpoints, payment APIs, external rate-limited upstream services.

2. Sliding Window Counter

How it works: Combines the current and previous window with a weighted overlap. If you’re 40% into the current window, count = (previous * 0.6) + current.

Previous Window          Current Window
count: 8                 count: 3
                |--- 40% into window ---|

effectiveCount = 8 * 0.6 + 3 = 7.8
limit: 10
result: ALLOWED (7.8 < 10)

This smooths out the boundary burst. It’s not perfect (it’s an approximation) but it’s close enough for virtually every use case while using only O(1) memory.

import { rateLimit } from '@tzezar/throtto'

// This is the default algorithm
const limiter = rateLimit('100/minute')

// Or explicitly:
const limiter = rateLimit({ limit: 100, window: '1m', algorithm: 'sliding-window-counter' })

When to use: Most API rate limiting. It’s the best default.

When NOT to use: When you need exact per-request precision (use Sliding Window Log) or when you want to allow bursts (use Token Bucket).

3. Sliding Window Log

How it works: Stores a timestamp for every single request. On each check, prune expired timestamps, count what remains.

Window: 60s                         now
|--------------------------------------|
  t1  t2  t3  t4  t5  t6  t7  t8
  ^^ expired, pruned

count = timestamps still in window = 6
limit: 10
result: ALLOWED

It’s the most accurate algorithm. Zero approximation. But it stores one entry per request, so memory grows with traffic.

import { rateLimit } from '@tzezar/throtto'

const limiter = rateLimit({ limit: 100, window: '1m', algorithm: 'sliding-window-log' })

// At 1000 req/sec with a 1-minute window, that's 60,000 entries per key.
// Fine for low-volume. Dangerous for high-throughput.

When to use: Financial APIs, compliance systems, anywhere exact enforcement is legally required. Low-volume endpoints where the memory cost is acceptable.

When NOT to use: High-throughput APIs. At scale, the memory cost makes this impractical.

4. Token Bucket

How it works: Imagine a bucket that holds tokens. It starts full. Each request consumes a token. Tokens refill at a constant rate. If the bucket is empty, the request is denied.

Capacity: 10 tokens
Refill: 5 tokens/second

t=0   [##########]  10 tokens (full)
      burst of 8 requests
t=0   [##........]  2 tokens left
t=1   [#######...]  7 tokens (refilled 5)
t=2   [##########]  10 tokens (capped at capacity)

The key insight: Token Bucket explicitly allows bursts. A client that’s been idle accumulates tokens and can spend them all at once. This is a feature, not a bug.

import { rateLimit } from '@tzezar/throtto'

// Simple: rateLimit maps limit -> capacity, derives refill from window
const limiter = rateLimit({ limit: 100, window: '1m', algorithm: 'token-bucket' })

// Fine-grained: control burst size vs sustained rate independently
import { createLimiter, tokenBucket } from '@tzezar/throtto'

const limiter = createLimiter({
  algorithm: tokenBucket({
    capacity: 20,         // can burst up to 20
    refillRate: 10,       // but sustain only 10/sec
    refillInterval: '1s',
  }),
})

When to use: APIs that should tolerate bursts. Chat apps, real-time features, CDNs. When you want to reward idle clients with accumulated “credit.”

When NOT to use: When you need smooth, constant output (use Leaky Bucket). When bursts could overwhelm downstream services.

5. Leaky Bucket

How it works: The inverse of Token Bucket. Requests fill a bucket that drains at a constant rate. If the bucket overflows, requests are rejected. The output is always smooth.

Capacity: 10
Drain: 2/second

requests -->  |##########|  bucket fills
              |########..|
              |------+---|
                     |
                     v
              2 req/sec output (constant)

Key difference from Token Bucket:

Token BucketLeaky Bucket
StartsFull (allows burst)Empty (no burst)
BurstUp to capacityNone, constant drain
ControlsInput rateOutput rate
import { rateLimit } from '@tzezar/throtto'

const limiter = rateLimit({ limit: 100, window: '1m', algorithm: 'leaky-bucket' })

When to use: Traffic shaping. Protecting upstream services from spikes. Queue-based systems where constant throughput matters.

When NOT to use: When clients expect immediate responses to bursts. When idle time should “earn” future capacity.

6. GCRA (Generic Cell Rate Algorithm)

How it works: Originally designed for ATM network cell-rate policing. Tracks only one number per key: the Theoretical Arrival Time (TAT), when the next request “should” arrive assuming perfect spacing.

emission_interval = period / limit  (ideal gap between requests)
delay_tolerance = emission_interval * burst

On each request:
  new_tat = max(current_tat, now) + emission_interval
  allow_at = new_tat - delay_tolerance

  if allow_at <= now -> ALLOW
  else              -> DENY

One number. Per key. That’s it. Insanely memory-efficient.

import { rateLimit } from '@tzezar/throtto'

const limiter = rateLimit({ limit: 100, window: '1m', algorithm: 'gcra' })

When to use: High-cardinality keys (millions of unique users/IPs). When memory is precious. As a drop-in for Sliding Window Counter with lower memory footprint.

When NOT to use: When you need intuitive behavior for debugging. GCRA’s “scheduling” mental model can be confusing compared to simple counting.

7. Concurrency

How it works: Unlike the other 6, this doesn’t limit rate. It limits parallelism. Each allowed request gets a “slot.” You must release the slot when done. If all slots are taken, new requests are denied.

maxConcurrent: 3

  Slot 1: [====active====]
  Slot 2: [====active===========]
  Slot 3: [====active====]
  Request 4: DENIED (all slots in use)

  Slot 1 released:
  Request 4: ALLOWED (slot available)
import { rateLimit } from '@tzezar/throtto'

const limiter = rateLimit({ limit: 5, window: '30s', algorithm: 'concurrency' })

const result = await limiter.check('user-1')
if (result.allowed) {
  try {
    await doExpensiveWork()
  } finally {
    await limiter.reset('user-1')  // release the slot
  }
}

When to use: Database connection pools, file upload limits, expensive computations (image processing, video transcoding). Any time you care about how many things run at once, not how fast they arrive.

When NOT to use: Normal API rate limiting. This is for resource protection, not rate.

Choosing the Right Algorithm

What do you need?
|
|-- Limiting concurrent operations? --> Concurrency
|
|-- Need burst tolerance?
|   |-- High burst --> Token Bucket
|   |-- Tunable --> GCRA
|
|-- Need smooth constant output? --> Leaky Bucket
|
|-- Need per-request precision? --> Sliding Window Log
|
|-- Want simplest/fastest? --> Fixed Window
|
|-- Best default? --> Sliding Window Counter

Real-World Combinations

In practice, you often want multiple algorithms working together:

import { createCompoundLimiter, rateLimit } from '@tzezar/throtto'

const limiter = createCompoundLimiter([
  // Token bucket for burst tolerance (20 requests instantly)
  { name: 'burst', limiter: rateLimit({ limit: 20, window: '1s', algorithm: 'token-bucket' }) },
  // Sliding window for accurate sustained rate
  { name: 'sustained', limiter: rateLimit({ limit: 100, window: '1m' }) },
  // Fixed window for cheap hourly cap
  { name: 'hourly', limiter: rateLimit({ limit: 1000, window: '1h', algorithm: 'fixed-window' }) },
])

// All three are checked. If ANY denies, the request is denied.
const result = await limiter.check('user-123')

This is the pattern most production APIs should use: burst tolerance at the second level, accuracy at the minute level, and a hard cap at the hour level.

Performance

How much overhead do these add? Benchmarked on a memory store (Intel Core 7 240H, Node.js 22):

Algorithmops/secavg latency
Fixed Window2.19M381 ns
Sliding Window Counter2.13M393 ns
Token Bucket2.16M388 ns
Leaky Bucket2.15M390 ns
GCRA2.16M388 ns
Sliding Window Log2.06M411 ns
Concurrency155K6.4 us

All except Concurrency run in under 400 nanoseconds. The bottleneck in any real system is the store (Redis round-trip, database query), never the algorithm.

TL;DR

AlgorithmOne-linerUse when
Fixed WindowCheapest, least accurateYou don’t care about boundary bursts
Sliding Window CounterBest defaultGeneral API rate limiting
Sliding Window LogMost accurate, most memoryExact enforcement required
Token BucketAllows burstsBursty traffic is expected and OK
Leaky BucketSmooth outputProtecting upstream from spikes
GCRAMinimal memoryMillions of keys, memory-constrained
ConcurrencyLimits parallelismResource protection, not rate

Code examples use @tzezar/throtto, a TypeScript rate limiting library I built. But the concepts apply regardless of what library you use.

tzezar. © 2026 Sebastian Drozd

built with svelte & too much coffee