~/systems/rolling-counters
Rolling windows and rate counters
Count what happened in the last N seconds, per key, in O(1) memory per second of window. The core of hit counters and rate limiters.
Keep one bucket per second of the window in a ring, plus a running total. Moving the clock empties the buckets that just left the window and subtracts them from the total.
Requests or events in the last N seconds, queries per second, hit counters, and rate limiters that must allow or reject each request.
O(1) amortized per call
O(window) per key
You’ll recognise it when
- You need “how many in the last N seconds”: hits, page views, messages, errors.
- Calls arrive with a timestamp, and time never goes backwards.
- You must allow or reject each request against a limit: per client, per key, per group.
- The service runs for a long time, so memory must not grow with the number of events.
It’s often confused with the sliding window technique on arrays. That one moves two indexes over a fixed array. Here the window is defined by the clock, events keep arriving, and old ones must be forgotten.
The idea
Picture a row of 60 jars on a shelf, one for each second of the last minute, and a tally on the wall of what’s in all the jars together. Each second you move to the next jar along, wrapping around at the end. Before using a jar, you tip out what it held a minute ago and subtract that from the tally. Counting the last minute is reading the tally.
That’s a ring of time buckets with a running total. Each bucket remembers hits for one second; the bucket for second s lives at s % window. A bucket is reused once its second has left the window, and emptying it keeps total right. Memory is fixed by the window’s length, however many hits arrive.
How it works
RollingCounter(window) answers count(t): the hits in the last window seconds, the interval [t - window + 1, t]. It keeps buckets, the running total, and last, the latest second the clock has reached.
hit(t, n)andcount(t)start by advancing the clock tot. Iftequalslast, there’s nothing to do. Atbeforelastis an error: time went backwards.- Empty the seconds that left. For each second
sfromlast + 1tot, the bucket ats % windowholds hits from seconds - window, which is no longer in the window. Subtract it fromtotaland set it to 0. - Skip long gaps in one step. If
t - last >= window, every bucket is stale. Reset all of them at once instead of looping over a gap that could be a billion seconds. - Record or read.
hitaddsnto the bucket att % windowand tototal.countreturnstotal.
With window = 5: hits at seconds 1, 2 and 2 (three of them) make total = 5, and count(4) covers [0, 4], so 5. A hit at 6 first empties the buckets for seconds 5 and 6, which still hold seconds 0 and 1: total drops by 1 and then gains the new hit, so count(6) is 5. At 7 the bucket for second 2 empties: count(7) is 1. At 12 the gap is at least 5, so everything resets.
Why it’s correct: after advancing to last, each bucket holds the hits of exactly one second in [last - window + 1, last], and total is their sum. Advancing by one second retires exactly one old second and opens one new, empty one.
class RollingCounter:
"""Hits in the last `window` seconds: the interval [t - window + 1, t]."""
def __init__(self, window):
self.window = window
self.buckets = [0] * window # buckets[s % window] = hits in second s
self.total = 0 # sum of every bucket still in the window
self.last = None # the latest second we've moved the clock to
def _advance(self, t):
# Move the clock to second t, emptying buckets that leave the window.
if self.last is not None and t < self.last:
raise ValueError("time went backwards")
if t == self.last:
return
if self.last is None or t - self.last >= self.window:
self.buckets = [0] * self.window # every bucket is stale
self.total = 0
else:
for s in range(self.last + 1, t + 1): # at most `window` steps
self.total -= self.buckets[s % self.window]
self.buckets[s % self.window] = 0
self.last = t
def hit(self, t, n=1):
self._advance(t)
self.buckets[t % self.window] += n
self.total += n
def count(self, t):
self._advance(t)
return self.total#include <algorithm>
#include <stdexcept>
#include <vector>
using namespace std;
// Hits in the last `window` seconds: the interval [t - window + 1, t].
class RollingCounter {
long long window;
vector<long long> buckets; // buckets[s % window] = hits in second s
long long total = 0; // sum of every bucket still in the window
long long last = -1; // the latest second we've moved the clock to (-1: none yet)
// Move the clock to second t, emptying buckets that leave the window.
void advance(long long t) {
if (last >= 0 && t < last) throw invalid_argument("time went backwards");
if (t == last) return;
if (last < 0 || t - last >= window) {
fill(buckets.begin(), buckets.end(), 0); // every bucket is stale
total = 0;
} else {
for (long long s = last + 1; s <= t; s++) { // at most `window` steps
total -= buckets[s % window];
buckets[s % window] = 0;
}
}
last = t;
}
public:
explicit RollingCounter(long long window) : window(window), buckets(window, 0) {}
void hit(long long t, long long n = 1) {
advance(t);
buckets[t % window] += n;
total += n;
}
long long count(long long t) {
advance(t);
return total;
}
};import java.util.*;
// Hits in the last `window` seconds: the interval [t - window + 1, t].
class RollingCounter {
private final int window;
private final long[] buckets; // buckets[s % window] = hits in second s
private long total = 0; // sum of every bucket still in the window
private long last = -1; // the latest second we've moved the clock to (-1: none yet)
RollingCounter(int window) {
this.window = window;
this.buckets = new long[window];
}
// Move the clock to second t, emptying buckets that leave the window.
private void advance(long t) {
if (last >= 0 && t < last) throw new IllegalArgumentException("time went backwards");
if (t == last) return;
if (last < 0 || t - last >= window) {
Arrays.fill(buckets, 0); // every bucket is stale
total = 0;
} else {
for (long s = last + 1; s <= t; s++) { // at most `window` steps
total -= buckets[(int) (s % window)];
buckets[(int) (s % window)] = 0;
}
}
last = t;
}
void hit(long t, long n) {
advance(t);
buckets[(int) (t % window)] += n;
total += n;
}
long count(long t) {
advance(t);
return total;
}
}The exact version: a queue of timestamps
When volume is small, the simplest correct answer is a deque of timestamps. Append on each hit; before counting, pop from the left while the oldest is out of the window.
from collections import deque
class HitLog:
def __init__(self, window):
self.window = window
self.times = deque()
def hit(self, t):
self.times.append(t)
def count(self, t):
while self.times and self.times[0] <= t - self.window:
self.times.popleft()
return len(self.times)
It’s exact to any precision and easy to say out loud, but memory grows with the number of hits in the window. A million hits a second means a million timestamps. Start here in an interview, then move to buckets when the interviewer asks about scale.
Rate limiters
A limiter is a counter plus a decision. A sliding-window limiter allows a request if count(t) < limit, then records it. A token bucket allows short bursts: the bucket refills at rate tokens per second up to capacity, and each request spends one token.
class TokenBucket:
def __init__(self, capacity, rate):
self.capacity, self.rate = capacity, rate
self.tokens = capacity
self.last = 0.0
def allow(self, now):
# refill lazily for the time that passed, never above capacity
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
b = TokenBucket(3, 1)
print([b.allow(t) for t in [0, 0, 0, 0, 1, 1.5, 3]])
# [True, True, True, False, True, False, True]
Nothing ticks in the background: every structure here catches up lazily when it’s called.
Why it’s O(1) amortized
A call that moves the clock forward by d seconds empties at most min(d, window) buckets. Over a whole run, the clock moves forward by at most the total elapsed time, and each jump bigger than the window costs one reset. Per call that’s O(1) amortized for regular traffic, and never more than O(window). count is O(1) because the total is maintained, not summed.
| approach | memory per key | count |
exact? |
|---|---|---|---|
| deque of timestamps | O(hits in window) | O(1) amortized | yes |
| ring of second buckets | O(window) | O(1) | to the second |
| fixed window counter | O(1) | O(1) | no: bursts at the boundary |
Per key, keep a dict from key to its counter and drop keys that have been idle longer than the window, or memory grows with every key ever seen.
Common mistakes
An off-by-one window boundary
“The last 5 seconds” at t = 10 is usually seconds 6 to 10, so a hit at 5 is out. Using < where you meant <= keeps one extra second. Write the interval in a comment and test a hit exactly at the edge.
while q and q[0] < t - window: q.popleft() # ✗ keeps second t - window
while q and q[0] <= t - window: q.popleft() # ✓ window is [t - window + 1, t]
Summing every bucket on each query
Looping over all the buckets in count is O(window) per call: a one-hour window at one-second resolution is 3,600 additions per query. Keep the running total and adjust it as buckets are emptied.
return sum(self.buckets) # ✗ O(window) per query
return self.total # ✓ kept up to date in _advance
Reusing a bucket without emptying it
A ring slot for second 12 in a 5-second window is the same slot as second 7. Writing into it without first clearing what second 7 left there counts old hits as new.
self.buckets[t % w] += 1 # ✗ may still hold second t - w
self._advance(t); self.buckets[t % w] += 1 # ✓ stale seconds emptied first
Using the wall clock
time.time() can jump backwards when the system clock is corrected, and a window computed from it then misbehaves. Measure elapsed time with a monotonic clock.
now = time.time() # ✗ can go backwards
now = time.monotonic() # ✓ only moves forward
Variations
- Per key. A dict of counters, one per user or client. Evict idle keys, and in a threaded server lock per key (or per shard of keys), since a dict of counters isn’t safe to update from several threads.
- Several windows at once. “Last minute, last hour, last day”: keep one ring at one-second resolution and another at one-minute resolution, rather than one huge ring.
- Top talkers. The most active key in the window needs counts per key that also expire, plus a way to find the maximum, such as a heap with lazy deletion.
- Fixed windows. Count per calendar minute (
t // 60). It’s cheap, but a client can send2 × limitrequests across a boundary. A sliding window or token bucket fixes that. - Logger-style rate limits. “Show each message at most once every 10 seconds” needs only the last time each message was shown: a dict from message to time.
Climb the ladder
Our rolling-counter problems in ladder order.
- Events in the last N seconds: the deque version.
- Café drink counts over the last N seconds: counts per key, and distinct keys, in a window.
- Logger Rate Limiter: last-seen time per message.
- Token Bucket Rate Limiter: lazy refill, capped bursts.
- Page views in the last five minutes: batched hits, bounded memory.
- Chat message events aggregation: several views of one window, then a top talker.
- Key-value store with rolling QPS: windows up to a week, then thread safety.
- Throttle requests per client: fixed windows, sliding windows, token buckets and shared group limits.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
A dashboard shows each client’s request count over the last hour, to the second. A busy client sends 5,000 requests a second. Which structure keeps memory per client bounded?
A ring of buckets costs 3,600 numbers per client whatever the traffic, and the running total makes each query O(1). A deque or sorted list of timestamps would hold 18 million entries for that client. A counter that resets on the hour answers a different question: this clock hour, not the last 60 minutes.
-
2
This is meant to count hits in the last 5 seconds, the interval
[t - 4, t]. What does it print?from collections import dequeq = deque([3, 5, 6, 9, 10]) # hit times, oldest firstt, window = 10, 5while q and q[0] < t - window:q.popleft()print(len(q))t - windowis 5, and the loop only drops times strictly below 5, so the hit at second 5 stays and it prints 4. The window[6, 10]holds 3 hits. Popping whileq[0] <= t - windowgives the right answer. Always test a hit exactly on the boundary. -
3
What does this print?
class TokenBucket:def __init__(self, capacity, rate):self.capacity, self.rate = capacity, rateself.tokens, self.last = capacity, 0.0def allow(self, now):self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)self.last = nowif self.tokens >= 1:self.tokens -= 1return Truereturn Falseb = TokenBucket(2, 0.5)print([b.allow(t) for t in [0, 0, 0, 1, 2, 4, 4]])It starts full with 2 tokens: two requests at time 0 pass, the third fails. By time 1 only half a token has refilled, so it fails. By time 2 there’s a whole token: pass. From 2 to 4 it refills one more token, so the first request at 4 passes and the second finds the bucket empty.
-
4
A ring-of-buckets counter has a window of W seconds. A call arrives after the service has been idle for 10^9 seconds. What should advancing the clock cost?
Once the gap is at least W, no bucket holds a second inside the new window, so clear them all and zero the total. Looping second by second over the gap would take forever. Doing nothing is the bug: the buckets still hold counts from a billion seconds ago, and a ring slot can’t tell old hits from new ones.
-
5
A limiter allows 100 requests per calendar minute: a counter that resets at every :00. What’s the most requests one client can get through within some 2-second span?
100 requests at 0:59 fit the first minute’s budget, and 100 more at 1:00 fit the next one. That’s 200 within about a second. A sliding window or a token bucket closes this gap, which is why fixed windows are usually the first level of a throttling problem, not the last.
Practice problems
Solve these right here, in Python, C++ or Java. Tests run as you go.