Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Sliding-Window Rate Limiter
Allow at most N requests per rolling time window — the rate limiter that guards real APIs. A sliding log of accepted timestamps gives exact limits without the boundary bursts a fixed window suffers. Decide accept/reject for a stream of requests. Solve it in Python or TypeScript, with hidden tests.
The problem
Implement a rate limiter that permits at most limit requests in any window of window time units. You are given the request timestamps in non-decreasing order. For each timestamp, return True if the request is allowed (and it then counts against the limit) or False if it is rejected. A request at time t is allowed iff fewer than limit accepted requests fall in the half-open window (t - window, t].
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement rate_limiter(limit, window, timestamps): keep a queue of accepted timestamps. For each new t, evict entries ≤ t - window from the front, then allow (and enqueue) if the queue holds fewer than limit; otherwise reject.
Examples and constraints 4 examples
limit = 2, window = 10, timestamps = [1, 2, 3, 11, 12][True, True, False, True, True]limit = 1, window = 5, timestamps = [1, 2, 6][True, False, True]limit = 1, window = 5, timestamps = [1, 2, 3, 4, 6][True, False, False, False, True]limit = 1, window = 5, timestamps = [0, 5][True, True]- Timestamps are non-decreasing; multiple requests may share a timestamp.
- A rejected request does not count toward the limit (only accepted ones occupy the window).
- The window is half-open: a request exactly
windowunits old (t - window) has left the window. - Aim for amortized O(1) per request by dropping expired entries from the front of a queue.
Hints 4 ideas
- Only accepted requests occupy the window, so store just those in a queue (a deque).
- Before deciding
t, pop every front entry≤ t - window— those have slid out of the window. - If the queue length is now below
limit, accept: appendtand return True. Otherwise return False. - The half-open boundary matters: use
≤ t - window(not<) so an entry exactlywindowold is expired.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious approach keeps every accepted timestamp in a plain list and, for each new request at t, counts the entries greater than t - window; accept and append if that count is below limit. It is correct, because it measures the half-open window directly. But it rescans the whole history on every request and never forgets anything, so it costs O(n²) time in the worst case (every request accepted, each scanning all earlier ones) and O(n) space. A million requests under a high limit means on the order of 5 × 10¹¹ comparisons, all to re-check timestamps that left the window long ago.
The approach
The insight is that the window only ever slides forward. Timestamps arrive in non-decreasing order, so once an accepted request is window units old it can never count again, and the expired requests are always the oldest ones. That makes a FIFO queue exact: evict from the front, append at the back, and what remains is precisely the accepted requests in (t - window, t].
The reference keeps log, a deque of accepted timestamps, oldest first. For each t, the loop while log and log[0] <= t - window pops everything that has slid out; the <= is what makes the window half-open, so a request exactly window old is gone. Then len(log) < limit decides: accept, append t and record True; otherwise record False and leave log untouched, because rejected requests do not occupy the window. The TypeScript version gets the same effect by advancing a head index instead of popping.
Walk through an example
The first example, limit = 2, window = 10, timestamps = [1, 2, 3, 11, 12], one row per request.
| step | t | t − window | evicted | log after | decision |
|---|---|---|---|---|---|
| 1 | 1 | −9 | — | [1] | 0 < 2, True |
| 2 | 2 | −8 | — | [1, 2] | 1 < 2, True |
| 3 | 3 | −7 | — | [1, 2] | 2 is not below 2, False (not logged) |
| 4 | 11 | 1 | 1 (1 ≤ 1) | [2, 11] | 1 < 2, True |
| 5 | 12 | 2 | 2 (2 ≤ 2) | [11, 12] | 1 < 2, True |
| return | — | — | — | — | [True, True, False, True, True] |
Complexity
Deque log: O(1) amortized per request, because each accepted timestamp is appended once and popped at most once, so n requests cost O(n); space O(limit), since an entry is logged only while fewer than limit are present. Brute-force scan: O(n²) time in the worst case, because every request rescans the full history, and O(n) space, since nothing is evicted.
Edge cases to test
- A burst at one instant,
limit = 2,window = 10,[5, 5, 5]:[True, True, False]. Equal timestamps all sit inside the same window. - The exact boundary,
limit = 1,window = 5,[0, 5]:[True, True]. At t = 5 the entry 0 is exactlywindowold and is evicted (0 ≤ 0). - Rejects do not count,
limit = 1,window = 5,[1, 2, 3, 4, 6]:[True, False, False, False, True]. If the rejected requests were logged, t = 6 would be rejected too. limit = 0,[1, 2]:[False, False]. Nothing is ever allowed.- A long gap,
limit = 2,window = 10,[1, 2, 100]:[True, True, True]. One eviction loop drains every expired entry at once.
Common mistakes
- Falling back to a fixed window, whose boundary lets a client send up to 2× the limit across the edge — the very thing sliding windows fix. With
limit = 2and 10-unit buckets, requests at 8, 9, 10 and 11 are all accepted, four inside the span(1, 11]. - Unbounded timestamp growth in the log variant (evict old entries).
- Logging rejected requests as well: rejections then keep the window full, and
rate_limiter(2, 10, [1, 2, 3, 11, 12])rejects t = 11 and t = 12 because t = 3 is still in the log. - Evicting with
<instead of<=: an entry exactlywindowold stays, andrate_limiter(1, 5, [0, 5])returns[True, False]. - Accepting while
len(log) <= limit: an off-by-one that letslimit + 1requests into every window.
Follow-ups an interviewer asks next
- One client sends millions of requests per window. Can you avoid storing timestamps?
- Use a sliding-window counter: keep only the counts for the current and the previous fixed bucket, and estimate the rolling count as the current count plus the previous count times the fraction of the previous bucket still inside the rolling window. That is O(1) memory per client instead of O(limit), at the price of assuming the previous bucket’s requests were spread evenly, so the estimate can be off near a burst.
- Enforce the limit per user across many API servers.
- Keep each user’s log in a shared store, such as a Redis sorted set per user: remove members scored at or below t − window (
ZREMRANGEBYSCORE), count the rest (ZCARD), andZADDthe request only if under the limit, all in one Lua script so two servers cannot both pass the check. Members must be unique, so add a request id scored by its timestamp, and expire idle keys. - Rejected requests should count, to punish clients that retry in a tight loop.
- Log every request instead of only the accepted ones. A client that keeps retrying while blocked then keeps its own window full and stays blocked until it pauses for a full window. That is a policy choice, not a fix: the version here, where rejects are free, lets a client recover as soon as its oldest accepted request ages out.
Where this shows up
Sliding-window rate limiting is what you reach for when a fixed window’s edge burst is unacceptable — it makes “100 per minute” actually mean 100 in any 60-second span. It is standard in API gateways and abuse prevention where the limit has to hold across boundaries, not just within neat clock minutes.
Reference solution
Reference solution — Python
from collections import deque
def rate_limiter(limit, window, timestamps):
log = deque() # timestamps of ACCEPTED requests, oldest at the front
out = []
for t in timestamps:
while log and log[0] <= t - window: # slide the window forward
log.popleft()
if len(log) < limit:
log.append(t) # accept: it now occupies the window
out.append(True)
else:
out.append(False) # rejected requests do not count
return out
print(rate_limiter(2, 10, [1, 2, 3, 11, 12])) # -> [True, True, False, True, True]Reference solution — TypeScript
function rateLimiter(limit: number, window: number, timestamps: number[]): boolean[] {
const log: number[] = []; // accepted timestamps, oldest first
let head = 0; // index of the front (amortized O(1) dequeue)
const out: boolean[] = [];
for (const t of timestamps) {
while (head < log.length && log[head] <= t - window) head++; // slide window
if (log.length - head < limit) {
log.push(t); // accept: now occupies the window
out.push(true);
} else {
out.push(false); // rejected: does not count
}
}
return out;
}
console.log(rateLimiter(2, 10, [1, 2, 3, 11, 12])); // -> [true,true,false,true,true]Questions about Sliding-Window Rate Limiter
What is the difference between a fixed window and a sliding window rate limiter?
A fixed window counts requests per calendar bucket, such as each clock minute, and resets the count at every boundary. That is cheap, but a client can send the full limit at the end of one bucket and the full limit again at the start of the next, so twice the limit lands inside one window-length span. A sliding window counts the requests in the window units just before each request, whatever the clock says, so the limit holds over every span. The log version in this challenge is exact; the counter version approximates it with two buckets.
Why does the rate limiter evict a timestamp that is exactly window units old?
Because the window is half-open, (t - window, t]: it includes the current instant and excludes the instant exactly window units earlier. With a window of 5, a request at time 0 stops counting at time 5, so rate_limiter(1, 5, [0, 5]) allows both. That is why the reference evicts while log[0] is at or below t - window, not strictly below it. The convention means a client allowed one request per 5 units can send at 0, 5, 10 and so on, exactly on the beat, without ever being rejected.
How much memory does a sliding window log rate limiter use?
At most limit timestamps per client, because a request is logged only while fewer than limit are already in the window, and expired entries are popped as the window slides. For a limit of 100 per minute, that is up to 100 numbers per client, however many requests the client sends. The cost grows with the limit and with the number of clients, which is why high-limit deployments often switch to a sliding window counter, which keeps just two counts per client and approximates the rolling total.
Why use a deque instead of a list for the request log?
Because the log is consumed from the front. In Python, list.pop(0) shifts every remaining element and costs time proportional to the list length, while deque.popleft() takes constant time. Each accepted timestamp is appended once and popped at most once, so with a deque the whole run is O(n) for n requests. The TypeScript reference reaches the same bound another way: it keeps a head index into an array and advances it instead of removing elements, which is fast but leaves the consumed entries in memory unless the array is compacted.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- LRU CacheThe eviction policy behind every size-limited cache: when you run out of room, throw out whatever was used least recently. The trick is O(1) get and put — an ordered hash map (Python dict / JS Map) gives you exactly that. Solve it in Python or TypeScript.Read →
- Streaming MedianLatency dashboards do this every second: maintain the median of a stream without re-sorting per event. The classic two-heap trick — a max-heap for the low half, a min-heap for the high half, the median always at the boundary. Solve it in Python or TypeScript.Read →
- Token Bucket Rate LimiterThe algorithm inside most production rate limiters — and it never runs a timer. Refill the bucket lazily from the time elapsed since the last request, cap at capacity, spend one token or reject. Two numbers of state per client, exactly like the Redis version. Solve it in Python or TypeScript.Read →
- LFU CacheThe cache that evicts what you use least often — and, on ties, least recently. Harder than LRU: track frequency and recency together, still O(1) per op. Replay get/put operations. Solve it in Python or TypeScript, with hidden tests.Read →
- Bloom FilterA tiny bit-array that answers "have I seen this?" in a fraction of a set’s memory — with occasional false positives but never a false negative. Build one with double hashing. Provided hashes keep both languages in sync. Hidden tests.Read →
- HyperLogLog Cardinality EstimatorCount how many distinct items a stream held — billions of them — using a few kilobytes, not a giant set. Turn "the longest run of leading zeros" into an estimate. Powers COUNT(DISTINCT) in Redis and BigQuery. Solve it in Python or TypeScript, with hidden tests.Read →