Many design problems only care about recent events: hits in the last 5 minutes, prints in the last 10 seconds, tokens that have not expired, the average of the last n values. The trap is a dict that remembers everything forever, which is a memory leak. The pattern is to keep only the window, using one of three structures: a deque you prune from the front, a fixed ring buffer that overwrites itself, or coarse time buckets.
The idea#
Events arrive with non-decreasing timestamps, so anything that falls out of the window falls out at the old end. That gives three options.
| Structure | How old data leaves | Memory | Exact? |
|---|---|---|---|
| Deque of events | pop from the left while expired | ✓ | |
Ring buffer of [second, count] |
slot is overwritten when its second is stale | , fixed | ✓ for in-order data |
| Buckets per minute or hour | whole bucket overwritten or dropped | to bucket size |
Why the deque is fast: each event is appended once and popped once, so pruning is amortized per event, even if one call pops many.
Why the ring buffer needs no cleanup: slot t % W is reused every W seconds. Each slot remembers which second it holds. On a hit, if the stored second differs, the slot is reset and reused. Old data is overwritten, so there is no background cleanup thread and memory never grows.
How to spot it#
The problem talks about time or count windows over a stream.
- "in the past 300 seconds", "within the last 10 seconds", "per minute"
- "at most K requests per window", "should this message print"
- "expires after
ttl", "count unexpired tokens" - "moving average of the last
sizevalues" - timestamps are given in non-decreasing order (so the oldest is always at the front)
Template#
Two building blocks cover most problems. First, the operations of a ring-buffer counter: hit is , get_hits is , and memory is fixed at W slots.
init(W):
times = W zeros # which second each slot belongs to
counts = W zeros
hit(t):
i = t % W
if times[i] != t: # slot holds an old second: reuse it
times[i] = t; counts[i] = 0
counts[i] += 1
get_hits(t):
return the sum of counts[i] over slots where t - times[i] < W
Second, a deque of expiries with tombstones: stale entries you leave in place instead of deleting. Renewing a key appends a new entry, and the old one becomes a tombstone. The dict is the source of truth, and a stale entry is checked against it only when it reaches the front.
init(ttl):
expiry = empty dict # key -> expiry time (the truth)
log = empty deque # (expiry, key), sorted: time only grows
add_or_renew(key, now):
expiry[key] = now + ttl
append (now + ttl, key) to log # any older entry is now a tombstone
count_live(now):
while log is not empty and its front has expired:
exp, key = pop from the left of log
if expiry[key] == exp: delete key from expiry # skip tombstones
return the size of expiry
A heap of expiries would also work, but every expiry is now + ttl with now only growing, so entries arrive already sorted. A deque gives appends instead of pushes. That is the key observation: when keys arrive in order, you don't need a heap.
Ring buffer or binary search?#
Before you commit to a ring buffer, compare it with its main alternative: a sorted list of timestamps plus binary search, covered in Sorted Timestamps + Binary Search. That approach answers any window size in , but stores every timestamp unless you prune.
| Binary search on timestamps | Ring buffer | |
|---|---|---|
get_hits |
||
| Memory | grows with every hit | fixed slots |
| Best when | windows vary, or traffic is sparse | window is fixed, traffic is high |
Choose the ring buffer when memory and stability matter most, and binary search when lookup speed and flexible windows matter most.
Rate limiters: three algorithms on one trace#
Rate limiters are the most common place these structures show up. A per-user limiter of limit requests per window seconds can be built three ways.
- Fixed window:
window_id = t // windowplus a counter. Two integers, space. Whenwindow_idchanges, reset the counter. - Sliding window log: a deque of accepted timestamps. Prune the front, then accept if fewer than
limitremain. space per user, amortized time. - Token bucket: a float
tokens, refilled atlimit / windowtokens per second since the last call, capped atlimit. Accept if at least one token is left. space.
The fixed window has a boundary burst: with 2 per 60 s, requests at 58, 59, 60 and 61 are all accepted, because 60 starts a new window. That is 4 requests in 4 seconds. The sliding log rejects 60 and 61.
To compare all three side by side, here is one trace with limit 2 per 60 seconds (the log treats an entry exactly 60 s old as expired):
| Request at | Fixed window | Sliding log | Token bucket (tokens left) |
|---|---|---|---|
| 1 | ✓ | ✓ | ✓ (1.00) |
| 30 | ✓ | ✓ | ✓ (0.97) |
| 50 | ✗ | ✗ | ✓ (0.63) |
| 70 | ✓ | ✓ | ✓ (0.30) |
| 100 | ✓ | ✓ | ✓ (0.30) |
The sliding log is the "honest" one: at t = 50 there were 2 requests in the last 60 seconds. The token bucket is "forgiving": it refills continuously, so evenly spread requests get slightly more throughput, while a concentrated burst is still capped at limit. The full design (per-user state, retry-after, locking, distributed limits) is in the Rate Limiter LLD.
Pitfalls#
Each of these costs you memory, speed or correctness.
- Unbounded dicts. "Last time seen" maps grow forever unless something removes old keys.
- Off-by-one at the window edge. Decide whether an event exactly
windowseconds old is inside, and use<or<=to match. Read the problem's exact range. list.pop(0)instead ofdeque.popleft(). The list version is per pop.- Deleting a renewed key. When you pop an old entry, check that the dict still holds that same time before deleting.
- Pruning in only one method. Every method that reads the state must prune first, or expired data leaks into answers.
- Late events in a ring buffer. A late hit can land in a slot that a newer second has already reset, so the ring buffer assumes roughly in-order data.
Worth exploring: the sliding window counter, which weights the previous fixed window's count by how much of it still overlaps the sliding window. It gets close to the log's accuracy in memory. The LLD rate limiter note compares it with the others.
Problems in this pattern#
These problems use this pattern. Solve them before you open the hints below. Start with Logger Rate Limiter and Design Hit Counter.
- 359. Logger Rate Limiter
- 346. Moving Average from Data Stream
- 362. Design Hit Counter
- 622. Design Circular Queue
- 1797. Design Authentication Manager
Hints#
Open a hint only after you have tried the problem for a while.
Hint: Logger Rate Limiter
The base answer is a dict message -> last printed time. Print if the message is new or last + 10 <= t. That dict leaks: a million one-off messages stay forever.
Hint: Logger Rate Limiter: bounding memory
Keep (time, message) in a deque, pop entries older than t - 10, and delete the dict entry only if its stored time still equals the popped time. Or rotate two maps, current and previous, every 10 seconds, so expiry is one swap. An OrderedDict with a maximum size gives a hard bound but loses the exact rule; Redis keys with a 10-second TTL also work across servers.
Hint: Logger Rate Limiter: 5 prints per 10 seconds
One timestamp per message is no longer enough. The value becomes a deque of timestamps, which is the sliding-window log.
Hint: Design Hit Counter
A defaultdict(int) keyed by second grows forever, and summing it loops over the whole window (86,400 iterations for 24 hours). The ring buffer fixes memory. LeetCode counts hits in (t - 300, t], so check your <.
Hint: Design Hit Counter: follow-ups
Many hits in one second: the slot's second matches, so the count increments. A 24-hour window: bucket by minute, 1,440 slots. Late hits land in slot t % W only if a newer second hasn't reset it. Concurrent hits need a lock or atomic counters, and 100 servers need a shared store such as Redis with atomic increments.
Hint: Moving Average from Data Stream
Keep a deque of the last size values and a running sum. Append and add; if the deque is longer than size, pop the left and subtract. One if is enough, because the window can only overflow by one. A fixed array plus an index also works.
Hint: Moving Average from Data Stream: follow-ups
For money, float drift builds up, so use decimal.Decimal or integer cents. Sensor gaps are a requirements question: forward-fill, or switch to a time-based window. For a huge window, an exponential moving average avg = a * x + (1 - a) * avg needs memory, but every past value still counts with shrinking weights.
Hint: Design Circular Queue
This is the ring buffer itself: a fixed array, front, rear and a size counter, each pointer moving with (p + 1) % k. When front == rear the queue could be full or empty, and size settles it without wasting a slot. The last item is at (rear - 1) % k, which is correct even when rear is 0, because Python's % is non-negative for a positive divisor.
Hint: Design Authentication Manager
Scanning the whole dict in countUnexpiredTokens is per call. The tombstone deque from the template makes the count amortized per expired entry, while generate and renew stay . Renew only a token that is still live.
Key takeaways#
- Keep only the window. Anything that stores all history needs a pruning plan.
- A deque of in-order events prunes in amortized : each item enters and leaves once.
- A ring buffer of
[second, count]gives fixed memory with no cleanup thread; coarser buckets shrink it further. - In-order timestamps make a deque enough; a heap is only needed when keys arrive out of order.
- Tombstones (leave stale entries, validate against the dict at the front) make updates .
- Fixed window, sliding log and token bucket trade memory against accuracy at window boundaries.