lumo

Module 12 · Lesson 10 of 13Free

Rate limiting algorithms: token bucket to sliding window

Level
Intermediate
Topic
Reliability, overload and traffic spikes
Reading time
19 min

When you finish this page you can

  1. Distinguish a rolling-window quota from a sustained rate with burst allowance.
  2. Trace fixed-window, sliding-window and token-bucket decisions on concrete requests.
  3. Implement a token-bucket state transition in Python and TypeScript.
  4. Identify concurrency and state-loss failures that weaken a rate limit.

Builds on 2 lessons

Start with lesson 918 min
  1. Rate limiter placement: where and what to limit18 min
  2. Exponential backoff and jitter18 min

Video lesson · coming soon

accepts 120 requests from one seller just before a minute ends. It accepts another 120 just after the next minute begins. The seller obeyed “120 requests per minute”, yet sent 240 requests almost together.

Which requests should a admit when traffic arrives unevenly?

This lesson follows Kartly’s seller stock-read API. Kartly already has separate services. The numbers below describe one account’s allowance, not the store’s total traffic.

A minute has two meanings

Suppose the rule means “120 accepted requests during each clock minute”. A stores a counter for each account and clock interval. It admits a request if incrementing that counter would stay within the limit. The next interval gets a fresh counter. 1

For this lesson’s scenario, the window lasts 60 seconds. The interval number is the current timestamp divided by 60, rounded down.

At 12:00:59.9, the request uses the 12:00 counter. At 12:01:00.1, it uses a different counter. Neither counter exceeds 120.

Check yourselfAnswer in your head first, then reveal
Would changing the fixed window from one minute to one second eliminate boundary bursts?Show answerHide answer
No. Requests can still arrive on both sides of the reset. A shorter window reduces the size of the possible burst if its quota is smaller, but it does not remove the boundary effect.

Diagram not drawn yet: rate-algorithms-minute-boundary.svg

Two stacked timelines use a limit of 120 accepted requests. The fixed-window timeline accepts 120 at 12:00:59.9 and 120 at 12:01:00.1 because they use separate counters. The rolling-window timeline includes both groups in the same preceding 60 seconds, so it rejects the second group.

A clock-minute quota and a preceding-minute quota make different promises.

Now change the wording: “At most 120 accepted requests in the preceding 60 seconds.”

That rule follows the current time instead of a clock boundary. At 12:01:00.1, the first group is still recent. The second group must wait.

The common belief that “120 per minute means about two per second” misses this distinction. Both rules permit 120 requests together after sufficient inactivity. Neither promises evenly spaced traffic.

Before choosing an algorithm, write down the promise you actually need.

Choose the promise before storage

From Rate limiter placement, keep the same identity and enforcement point. Here, the trusted identity is a seller account, and the policy covers stock reads.

Kartly’s product question is: should a seller dashboard load several stock panels together?

For this scenario, yes. Allow a short burst of 10 requests. After that, admit work at a sustained rate of two requests per second. These are illustrative policy values, not measured service capacity.

Should Kartly enforce a rolling quota or a sustained rate with burst allowance?

Use a fixed-window quota.

A counter resets at each clock boundary. It uses little state, but an account can spend two intervals’ allowances almost together.

Enforce at most 120 admissions in every rolling minute.

Record recent admissions and refuse requests once the preceding interval contains the full allowance. This matches strict rolling wording, but still permits the entire allowance at once.

Why this choice

The dashboard needs a small immediate burst, not a calendar quota. Separate controls let Kartly choose that burst independently from the sustained rate.

Choose a rolling quota instead when a contract explicitly promises a maximum in every preceding interval. Choose fixed windows when aligned reporting periods matter and boundary bursts are acceptable.

These policies are not interchangeable implementations of the same guarantee. Changing the algorithm can change the API’s promise.

A token bucket fits this scenario. Next, we need to see exactly how it earns and spends permission.

Tokens remember unused permission

A token bucket keeps an available balance and a last-update time. Elapsed time restores tokens up to a capacity. An admitted request spends tokens; an insufficient balance causes rejection. 1

Kartly charges one token per stock read. Capacity is 10 tokens, and refill is two tokens per second.

There is no need for a timer that wakes twice a second. On each request, compute what should have accumulated since the previous update:

text
available = min(capacity, previous_tokens + elapsed_seconds × refill_rate)

If available is at least the request cost, subtract that cost. Otherwise, leave the refreshed balance unspent.

Assume a full bucket at time zero:

Time Arrival Balance after refill Result Remaining
0 s 10 requests together 10 tokens All admitted 0
0 s One more request 0 tokens Rejected 0
0.25 s One request 0.5 tokens Rejected 0.5
0.5 s One request 1 token Admitted 0
3 s One request 5 tokens Admitted 4

Fractional tokens preserve elapsed-time credit. Rounding the balance down on every arrival would discard that credit.

A rejection updates the accounting time but does not spend a token. Frequent rejected requests therefore cannot prevent the bucket from refilling.

Show the allowance’s limits

Recover from an empty bucket

10 tokens ÷ 2 tokens/second = 5 seconds

After five idle seconds, the bucket is full. Waiting longer cannot increase its saved allowance.

Find the next possible admission

(1 token − 0.5 tokens) ÷ 2 tokens/second = 0.25 seconds

This wait assumes no competing request spends the next token first.

Bound admissions over a minute

10 saved tokens + 2 tokens/second × 60 seconds = 130 tokens

This is an upper bound from a full bucket. It is not a strict 120-in-any-minute promise.

The bound follows from accounting: requests can spend only the starting balance plus tokens earned during the interval.

An expensive endpoint could charge several tokens. That makes the allowance a budget of work rather than a count of requests. Its cost estimate must still reflect the resource you intend to protect.

The next risk is implementation: a correct equation does not help if two callers spend the same balance.

Implement one serialized state transition

The following function belongs to the limiter, not the stock service. It receives one bucket’s state and returns the new state, the decision and a retry delay.

The caller initializes a new bucket as full. It must serialize calls for the same bucket. The function itself provides neither locking nor shared storage.

Time comes from a within one running process. Supplying time explicitly also makes request traces easy to reason about.

python
from dataclasses import dataclass
from math import isfinite

@dataclass(frozen=True)
class Bucket:
    tokens: float
    updated_at: float

def admit(
    state: Bucket, now: float, capacity: float,
    rate: float, cost: float = 1.0,
) -> tuple[Bucket, bool, float]:
    values = (state.tokens, state.updated_at, now, capacity, rate, cost)
    if not all(isfinite(value) for value in values):
        raise ValueError("values must be finite")
    if capacity <= 0 or rate <= 0 or not 0 < cost <= capacity:
        raise ValueError("invalid policy")
    if not 0 <= state.tokens <= capacity:
        raise ValueError("invalid balance")

    timestamp = max(now, state.updated_at)
    available = min(
        capacity, state.tokens + (timestamp - state.updated_at) * rate
    )
    allowed = available >= cost
    remaining = available - cost if allowed else available
    retry_after = 0.0 if allowed else (cost - available) / rate
    return Bucket(remaining, timestamp), allowed, retry_after
ts
type Bucket = Readonly<{ tokens: number; updatedAt: number }>;

function admit(
  state: Bucket, now: number, capacity: number,
  rate: number, cost: number = 1,
): readonly [Bucket, boolean, number] {
  const values = [
    state.tokens, state.updatedAt, now, capacity, rate, cost,
  ];
  if (!values.every((value) => Number.isFinite(value))) {
    throw new Error("values must be finite");
  }
  if (capacity <= 0 || rate <= 0 || cost <= 0 || cost > capacity) {
    throw new Error("invalid policy");
  }
  if (state.tokens < 0 || state.tokens > capacity) {
    throw new Error("invalid balance");
  }

  const timestamp = Math.max(now, state.updatedAt);
  const available = Math.min(
    capacity, state.tokens + (timestamp - state.updatedAt) * rate,
  );
  const allowed = available >= cost;
  const remaining = allowed ? available - cost : available;
  const retryAfter = allowed ? 0 : (cost - available) / rate;
  return [{ tokens: remaining, updatedAt: timestamp }, allowed, retryAfter];
}

For an empty bucket at time zero, a call at 0.25 seconds returns a half-token balance. It rejects the request and reports a further wait of 0.25 seconds.

The timestamp clamp prevents an earlier reading from moving accounting backwards. It does not make unrelated machines’ clocks comparable.

These examples use floating-point arithmetic for clarity. A policy requiring exact boundary decisions needs defined integer units and rounding rules. Otherwise, a tiny representation error can change a decision at the threshold.

This implements the burst policy. A strict preceding-minute quota needs different state.

Sliding windows remember recent admissions

A stores timestamps of accepted requests. Before deciding, remove expired entries, count the survivors, and append the new admission only if space remains. 1

Define Kartly’s alternative rolling interval precisely: include timestamps later than now − 60 seconds, through now.

At time 60 seconds, an admission at time zero has expired. An admission at time 0.001 seconds has not. This boundary convention prevents implementations from disagreeing about equality.

For a limit of three, trace this smaller example:

Arrival Recent accepted timestamps before decision Result
0 s Empty Accept; store 0
10 s 0 Accept; store 10
20 s 0, 10 Accept; store 20
59 s 0, 10, 20 Reject
60 s 10, 20 Accept; store 60

Do not append the rejected arrival at 59 seconds. This lesson’s policy counts admissions. Counting attempts instead would define a different policy.

Inside one process, ordered arrivals can use a double-ended . Remove old entries from the front and append accepted entries at the back. Each timestamp enters and leaves once, giving amortized constant work per request.

A shared implementation can use a Redis , with timestamps as scores and unique admission identifiers as members. Redis’s tutorial uses sorted sets for this algorithm. 1

The unique member matters: two admissions can have the same timestamp. Using that timestamp as both score and member could collapse them into one entry.

Exact history has a price

Suppose, for this scenario, 100,000 active seller-policy pairs each retain 120 admissions.

That is 12 million entries. At an illustrative 16 bytes per timestamp-and-identifier pair, the raw payload alone is 192 MB. Container overhead, keys and replication are additional.

The log also permits all 120 admissions together when the preceding interval is empty. Exact counting does not imply smooth traffic.

If approximate fairness is enough, we can discard individual timestamps. The cost is uncertainty about where requests occurred.

Two counters lose timing information

A estimates recent traffic from the current fixed-window count plus a weighted previous count. It assumes the previous window’s requests were spread evenly. 1

Use this rule for the estimate before admitting a new request:

text
estimate = current_count + previous_count × (1 − elapsed_fraction)
accept only if estimate + request_cost <= limit

Halfway through a minute, half the preceding clock minute overlaps the rolling interval. With a previous count of 120 and a current count of 20:

text
20 + 120 × 0.5 = 80 estimated recent admissions

Now consider two histories with those same counters.

In the first history, the previous 120 requests arrived near that minute’s end. At the midpoint of the current minute, all remain recent.

In the second history, they arrived near its beginning. At the current midpoint, all have expired.

The estimator returns the same previous contribution, 60, for both histories. The true contribution is different. No calculation can recover timestamps that were never stored.

This algorithm can therefore admit too much or reject too early. More, smaller time buckets reduce the uncertainty but retain more state.

When a rolling policy is required, should Kartly retain exact timestamps?

Use a weighted counter for an approximate allowance.

Two counters use constant state per identity, but burst timing can make their estimate wrong in either direction.

Why this choice

Choose based on the cost of a wrong decision. A strict contractual quota cannot be replaced by an estimate without changing the promise.

For a broad fairness control, compact counters may be preferable. If occasional excess admissions are acceptable, spending memory on exact history may buy little.

Kartly’s dashboard policy still uses a token bucket. This comparison matters when another endpoint genuinely needs rolling-window wording.

One remaining distinction is whether excess work should wait or leave.

Waiting is another policy

A puts accepted work in a bounded queue and releases it at a configured pace. It trades immediate bursts for waiting. 1

For this scenario’s rate, releasing one request every half-second spaces downstream admissions. Ten queued requests take about five seconds to release, depending on the initial release slot.

That may suit a seller’s background export. It is less attractive for an interactive stock panel that might time out before its turn.

The phrase “leaky bucket” also appears in rejecting, rather than queueing, implementations. Specify the behavior: store pending work, or reject arrivals that exceed a pacing allowance.

Do not silently add waiting to a rejecting limiter. Waiting requires queue bounds, deadlines and cancellation. Backpressure covers slowing producers without accumulating unlimited work.

Whatever algorithm you choose, multiple servers must agree on the state they are spending.

One balance needs one decision

Kartly’s illustrative shared design stores one bucket per seller and policy. The gateway performs the check before forwarding an admitted stock read.

Diagram not drawn yet: rate-algorithms-shared-bucket.svg

A seller dashboard sends a stock-read request to a gateway limiter. The gateway asks a shared state store to atomically refill, check and debit the seller-policy bucket. An allowed result leads to the stock service; a denied result returns HTTP 429 without calling that service.

Keep refill, decision and debit together. The stock service receives only admitted requests.
Component Responsibility and communication State Failure behavior
Dashboard Calls gateway; handles rejection Retry schedule Shows a temporary limit message
Gateway limiter Checks store; forwards to stock service Policy configuration Applies an explicit store-timeout policy
Shared state store Serializes bucket transitions Tokens and update time per key Unavailable checks or lost allowance state
Stock service Answers admitted reads Stock data An admission does not guarantee success

The normal is:

  1. The gateway derives a key such as rl:v1:seller:842:stock-read.
  2. The state owner obtains time and loads that bucket.
  3. One atomic operation refills, checks and conditionally debits it.
  4. The gateway forwards an allowed request or returns a rejection.

The limiter writes allowance state even for a rejected request if it refreshes the accounting timestamp. The stock service does not own that write.

If two gateways both read one remaining token, both could admit a request before either writes zero. Atomic individual reads and writes do not make the whole transition atomic. A Redis script can keep that sequence together. 1

A local bucket avoids the shared-store call. However, independent full buckets on four replicas multiply both this scenario’s burst allowance and sustained rate by four. Envoy’s local limiter defaults to per-process enforcement; it also supports a per-connection scope. 3

Rejection must explain recovery

HTTP defines 429 Too Many Requests for . Its response may include Retry-After; such responses must not be cached. 2

For a calculated half-second wait, Kartly rounds up to a whole second:

http
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 1

{"error":"RATE_LIMITED","policy":"seller-stock-read"}

That delay is not a reservation. Another request can spend the next token. Follow Exponential backoff and jitter rather than retrying immediately in a loop.

A store timeout is different from an exhausted bucket. Record it separately. For this read endpoint, an illustrative fallback is a conservative local cap, accepting weaker account-wide enforcement during the outage.

Track check latency, store errors and rejections by policy. Otherwise, a broken limiter can look like abusive customers.

Deleting a partly empty bucket and recreating it full grants extra allowance. Expire an idle token bucket only after enough time for it to become full anyway. A restart or state loss also requires an explicit reset policy.

Where this shows up

An algorithm controls allowance; the next lesson asks how Kartly handles bots that spread abuse across many identities.

Common mistakes

  • Treating all per-minute rules as equivalent. Clock windows, and token buckets admit different request sequences.
  • Calling sliding counters exact. Their missing timing information is an intentional tradeoff.
  • Making refill a large periodic reset. Adding a minute’s tokens together recreates a large burst instead of gradual permission.
  • Resetting expiry on every fixed-window attempt. A duration since the latest attempt is not an aligned clock window.
  • Assuming admission guarantees capacity. Many accounts can spend saved tokens together. Aggregate overload still needs separate protection.

Check your understanding

Check yourselfAnswer in your head first, then reveal
A full bucket holds 10 tokens and earns two per second. Can it promise at most 120 admissions in every rolling minute?Show answerHide answer
No. Its budget includes saved tokens as well as newly earned tokens. Use a rolling-window policy if that exact minute-by-minute bound is required.
Check yourselfAnswer in your head first, then reveal
A sliding-window log permits 120 requests in the preceding minute. After a long idle period, must it space the next requests apart?Show answerHide answer
No. It can admit all 120 together because the recent log is empty. Exact rolling counting is not a pacing guarantee.
Check yourselfAnswer in your head first, then reveal
Two gateways each read one remaining token and then write zero. Why did two requests pass?Show answerHide answer
The whole read-decide-update sequence was not serialized. Atomic writes alone do not prevent both gateways from spending the same token.
Check yourselfAnswer in your head first, then reveal
A weighted counter overestimates recent traffic after an early burst in the previous window. What does the seller experience?Show answerHide answer
Some requests are rejected even though the true rolling count has room. The estimate still assigns weight to requests that have already expired.
Check yourselfAnswer in your head first, then reveal
A deployment clears all token-bucket state and initializes every seller with a full bucket. Does the previous allowance still hold?Show answerHide answer
No. Partly empty buckets receive extra tokens. State loss changes enforcement unless the reset behavior is included in the policy.

Summary

  1. Choose requests by defining the promise first: aligned quota, rolling quota, burst budget or paced release.
  2. Token buckets separate sustained refill rate from saved burst allowance.
  3. Sliding logs retain exact recent admissions; weighted counters discard timing information to save state.
  4. Serialize each allowance transition, and define what happens when its state disappears.
  5. Return useful retry guidance, but do not confuse a rate limit with a guarantee of available capacity.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.