Q5 - Sliding-Window Rate Limiter (Throttling Abusive Clients)
We believe Amazon has started mixing low-level design and data modeling into its DSA rounds, and this is one of them. Every real API needs a rate limiter, the guardrail that keeps one client from taking a service down.
Scenario: Amazon's API gateway must throttle abusive clients. The rule: each user may make at most limit requests in any rolling window of windowMs milliseconds. Requests arrive as a stream of [timestamp, user] in non-decreasing timestamp order, and for each one you decide allow or reject. A rejected request does not count against the user (it never reached the service), so it never fills a slot in the window.
Part 1: Return the decision for each request in order: true if it is allowed, false if it is rejected.
⏱️ Heads up: big tech loves multi-part questions now. You build the first version, then get a follow-up that extends it. So watch the clock: get a clean, correct Part 1 working first, then move on. Same deal here, so finish Part 1 before you scroll down.
Example 1
Input: requests = [[0, "u1"], [100, "u1"], [200, "u1"], [300, "u2"], [1100, "u1"]]
limit = 2, windowMs = 1000
Output: [true, true, false, true, true]
Why: u1 is allowed at t=0 and t=100 (2 in the window). At t=200, u1 already has 2 requests
in the last 1000ms, so it is rejected. u2 at t=300 is a different user, so it is allowed. By
t=1100 both of u1's earlier requests (0 and 100) have aged out, so u1 is allowed again.
💡 This is a modeling question, not an algorithm question. There is no clever trick; one pass over the requests does it. What the interviewer watches is whether your state stays consistent: do you keep each user's window separate, do you drop old timestamps at exactly the right moment, and do you make sure a rejected request leaves no trace. Amazon leans on questions like this because real gateway code lives or dies on exactly these details.
Part 1: Modeling the State
📝 A note on the format. In a question like this you are not handed a class with methods stubbed out to fill in; you start from a blank page. Part of what is being graded is whether you can decide for yourself what state to keep, what methods you need, and what to name them. So before writing code, say out loud what your service will store and what operations it will expose. The model below is one clean way to do it.
Keep, for each user, a queue of the timestamps of their allowed requests that are still inside the window. On a new request at time ts:
- Drop timestamps from the front while they are
<= ts - windowMs(they have aged out of the window). - If fewer than
limitremain, the user has room: recordtsand allow. - Otherwise the window is full: reject, and record nothing.
Because each timestamp is added once and dropped once, the work per request is O(1) amortized.
In code the drop-then-decide is the whole method: age out the front, then allow only if there is room.
timestamps = history[user]
while timestamps and timestamps[0] <= ts - window: # drop timestamps that aged out
timestamps.popleft()
if len(timestamps) < limit: # room in the window: allow
timestamps.append(ts)
return True
return False # window full: reject
Here is Example 1 request by request. Watch u1's window fill, reject the third request, and then reopen once the old timestamps age out (u2 keeps its own separate window):
limit = 2, windowMs = 1000
request state (allowed timestamps in window) decision
----------------------------------------------------------------------
t=0 u1 u1: [0] ALLOW
t=100 u1 u1: [0, 100] ALLOW
t=200 u1 u1: [0, 100] (already 2 in window) REJECT
t=300 u2 u2: [300] ALLOW
t=1100 u1 u1: drop 0 and 100 -> [1100] ALLOW
----------------------------------------------------------------------
decisions: [true, true, false, true, true]
# O(1) amortized per request | O(active users * limit) space
from collections import deque
class RateLimiter:
def __init__(self, limit, window_ms):
self.limit = limit
self.window = window_ms
self.history = {} # user -> deque of allowed timestamps in the window
def allow(self, ts, user):
timestamps = self.history.setdefault(user, deque())
while timestamps and timestamps[0] <= ts - self.window: # drop timestamps that aged out
timestamps.popleft()
if len(timestamps) < self.limit: # room in the window: allow
timestamps.append(ts)
return True
return False # window full: reject
Part 2: Telling Clients When to Retry
Got Part 1? Here is the follow-up. A good API does not just say "no", it tells a throttled client how long to wait (this is the HTTP 429 Too Many Requests and its Retry-After header). Change allow to return 0 when a request is allowed, and when it is rejected, the number of milliseconds until the client would be let through.
A slot frees up the instant the user's oldest in-window request ages out. That request was made at timestamps[0] and leaves the window at timestamps[0] + windowMs, so the wait is timestamps[0] + windowMs - ts.
Only the rejection path changes, and it needs no new state, the answer is already in the queue:
if len(timestamps) < limit:
timestamps.append(ts)
return 0
return timestamps[0] + window - ts # oldest request leaves the window at ts0 + window
Example 2
Input: requests = [[0, "u1"], [100, "u1"], [200, "u1"], [300, "u2"], [1100, "u1"]]
limit = 2, windowMs = 1000
Output: [0, 0, 800, 0, 0]
Why: only the request at t=200 is rejected. u1's oldest live request is at t=0, which leaves
the window at t = 0 + 1000 = 1000, so the client should wait 1000 - 200 = 800ms and try again.
# O(1) amortized per request | O(active users * limit) space
from collections import deque
class RateLimiter:
def __init__(self, limit, window_ms):
self.limit = limit
self.window = window_ms
self.history = {}
def allow(self, ts, user): # returns 0 if allowed, else ms to wait
timestamps = self.history.setdefault(user, deque())
while timestamps and timestamps[0] <= ts - self.window:
timestamps.popleft()
if len(timestamps) < self.limit:
timestamps.append(ts)
return 0
return timestamps[0] + self.window - ts # oldest in-window request frees a slot then
Complexity Analysis
Time: O(1) amortized per request. Each timestamp is added once and dropped once over the whole run, so the eviction loop is amortized constant. Use a real double-ended queue for O(1) front-eviction; the JavaScript and TypeScript versions use array.shift() for readability, which is O(n) per drop, so a production build would swap in a proper deque or ring buffer.
Space: O(active users * limit). Each user holds at most limit timestamps inside the window. Users who go quiet keep a small empty queue; a real system would evict idle users entirely.
Final Notes
We believe this rate-limiter question, and close variations of it, have appeared frequently in recent Amazon interviews. It is deliberately not about a clever algorithm; it tests whether you can build a small stateful service correctly: keep each user's window independent, expire old requests at exactly the right boundary, and make sure a rejected request changes nothing.
- One window per user. The whole design is a map from user to that user's recent timestamps. Sharing a single window across users is the classic first-draft bug; say "per user" out loud before you write a line.
- A rejected request must leave no trace. Only allowed requests are recorded. If you push the timestamp before checking the limit, a burst of rejects can wrongly lock a user out. Check first, record only on allow.
- Be precise about the boundary. A timestamp ages out when it is
<= ts - windowMs. Off-by-one here (using<instead of<=, or comparing againsttsinstead ofts - windowMs) is the single most common mistake, so state your rule explicitly. - Retry-after falls out of the state you already have. Part 2 needs no new structure: the moment a slot frees is just when the oldest in-window request expires,
timestamps[0] + windowMs. The point of the follow-up is noticing the answer is already sitting in the queue you built.
It reads like a five-minute problem, and then the boundary conditions bite. Keep each user's window separate, drop old timestamps at the exact right instant, and leave nothing behind when you reject. That is the gap between code that works in a demo and code that holds up at 3am. 🚀