Design Trackers and Feeds
These are the design problems that feel closest to real systems work: rate limiters, hit counters, activity feeds. They are also where interviewers most often follow up with “and now make it work at scale”, so the reasoning matters as much as the code.
Two ideas do most of the work:
- A queue is a time window. If events arrive in non-decreasing time order,
a
dequedequeholding the events currently inside the window is enough — push the new one, drop the expired ones from the front. - Do not compute what you can maintain. A feed does not need every tweet sorted; it needs the newest ten. Bounding what you keep, and what you look at, is what keeps these designs fast.
What you’ll learn
- The sliding-window queue for time-bounded counting, and its amortised cost.
- Prefix products with a reset trick to handle zeros — and why division needs care.
- Bounded k-way merge for feeds: why only the last
kkitems per source matter. - How to answer the inevitable “how would this scale?” follow-up honestly.
- Three real LeetCode problems solved in the browser: 933, 1352, 355.
The cue
Pattern 1 — a queue as a time window
from collections import deque
class RecentCounter:
def __init__(self):
self.q = deque()
def ping(self, t):
self.q.append(t)
while self.q[0] < t - 3000: # window is (t - 3000, t] inclusive
self.q.popleft() # expired: drop from the FRONT
return len(self.q)from collections import deque
class RecentCounter:
def __init__(self):
self.q = deque()
def ping(self, t):
self.q.append(t)
while self.q[0] < t - 3000: # window is (t - 3000, t] inclusive
self.q.popleft() # expired: drop from the FRONT
return len(self.q)Time amortised per pingping — each timestamp is appended once and
popped at most once, so nn pings do total work. A single pingping can be
if a long-idle period expires many entries at once. Space ,
where ww is the number of pings inside the window.
No explicit removal of old data is needed beyond this, and the queue never grows unboundedly as long as the window is bounded — which is exactly why the design is memory-safe for a long-running process.
Pattern 2 — prefix products with a reset
LC 1352 needs the product of the last kk numbers added. Prefix sums let
you get a range sum by subtraction; prefix products let you get a range
product by division — which works right up until someone adds a zero.
class ProductOfNumbers:
def __init__(self):
self.prefix = [1] # prefix[i] = product of the last i values
def add(self, num):
if num == 0:
self.prefix = [1] # RESET: nothing before a zero is reachable
else:
self.prefix.append(self.prefix[-1] * num)
def getProduct(self, k):
if k >= len(self.prefix):
return 0 # the window reaches back past a zero
return self.prefix[-1] // self.prefix[-1 - k]class ProductOfNumbers:
def __init__(self):
self.prefix = [1] # prefix[i] = product of the last i values
def add(self, num):
if num == 0:
self.prefix = [1] # RESET: nothing before a zero is reachable
else:
self.prefix.append(self.prefix[-1] * num)
def getProduct(self, k):
if k >= len(self.prefix):
return 0 # the window reaches back past a zero
return self.prefix[-1] // self.prefix[-1 - k]Pattern 3 — bounded k-way merge for feeds
A news feed must merge tweets from every followee in reverse-chronological order. Two observations keep it cheap:
- You only need the newest 10, so only the last 10 tweets per user can possibly qualify. Everything older is dominated.
- A global timestamp counter gives a total ordering for free — no clock needed, and no ties.
from collections import defaultdict
import heapq
class Twitter:
def __init__(self):
self.time = 0
self.tweets = defaultdict(list) # user -> [(time, tweetId)]
self.following = defaultdict(set) # user -> set of followees
def postTweet(self, userId, tweetId):
self.tweets[userId].append((self.time, tweetId))
self.time += 1 # a monotonic global clock
def getNewsFeed(self, userId):
candidates = []
for u in self.following[userId] | {userId}: # a user sees their own
candidates.extend(self.tweets[u][-10:]) # only the last 10 matter
return [tid for _, tid in heapq.nlargest(10, candidates)]
def follow(self, followerId, followeeId):
if followerId != followeeId: # never self-follow
self.following[followerId].add(followeeId)
def unfollow(self, followerId, followeeId):
self.following[followerId].discard(followeeId) # discard: no KeyErrorfrom collections import defaultdict
import heapq
class Twitter:
def __init__(self):
self.time = 0
self.tweets = defaultdict(list) # user -> [(time, tweetId)]
self.following = defaultdict(set) # user -> set of followees
def postTweet(self, userId, tweetId):
self.tweets[userId].append((self.time, tweetId))
self.time += 1 # a monotonic global clock
def getNewsFeed(self, userId):
candidates = []
for u in self.following[userId] | {userId}: # a user sees their own
candidates.extend(self.tweets[u][-10:]) # only the last 10 matter
return [tid for _, tid in heapq.nlargest(10, candidates)]
def follow(self, followerId, followeeId):
if followerId != followeeId: # never self-follow
self.following[followerId].add(followeeId)
def unfollow(self, followerId, followeeId):
self.following[followerId].discard(followeeId) # discard: no KeyErrorgetNewsFeedgetNewsFeed is where ff is the number of followees, since it
gathers at most 10f10f candidates and nlargestnlargest is over them.
| Design | Cost | Space |
|---|---|---|
| Windowed counter (queue) | amortised | |
| Prefix products | per add and query | since the last zero |
| Feed (pull model) | per read | |
| Feed (push model) | per write, per read |
The variant map
| Variant | The structure | Canonical problem |
|---|---|---|
| Count events in a window | dequedeque of timestamps | 933 · 362 |
| Rate limit per key | Dict of key to last-allowed timestamp | 359 (Premium) |
| Running average of last k | dequedeque with maxlenmaxlen + a running sum | 346 |
| Product of last k | Prefix products with a zero reset | 1352 |
| Merged ranked feed | Bounded k-way merge with a heap | 355 |
| Top-k over a stream | A size-k heap; see Top K | 703 |
Practice — real LeetCode problems
LC 933 — Number of Recent Calls · Easy
Problem. Implement RecentCounterRecentCounter with a single method ping(t)ping(t) that
records a call at time tt and returns the number of calls that happened in the
inclusive range [t - 3000, t][t - 3000, t]. Calls arrive with strictly increasing
values of tt.
Constraints. 1 <= t <= 10^91 <= t <= 10^9, each pingping uses a strictly larger tt, up
to 10^410^4 calls.
Examples. ping(1)ping(1) gives 11, ping(100)ping(100) gives 22, ping(3001)ping(3001) gives
33, ping(3002)ping(3002) gives 33 (the call at t = 1t = 1 has just expired)
Editorial — approach, complexity, follow-ups
Because timestamps only increase, everything that has expired sits at the front
of the queue. So each pingping appends once and drops a prefix.
Time amortised — each timestamp is added once and removed at most
once, so nn pings cost total. Space for the pings inside the
window.
The boundary is the whole difficulty. ping(3001)ping(3001) returns 33 because the call
at t = 1t = 1 is exactly 3001 - 30003001 - 3000 and the range is inclusive. Then
ping(3002)ping(3002) returns 33 again: the call at 11 finally expires (it is now
strictly below 3002 - 3000 = 23002 - 3000 = 2), but the new call replaces it. Using <=<=
instead of << returns 22 and 33 — wrong on the first, and it is precisely the
case the examples are chosen to expose.
Note that self.q[0]self.q[0] is safe without an emptiness check, because tt was just
appended — the queue always has at least one element when the loop runs.
Follow-ups you should expect:
- “What if there are millions of pings per second (LC 362)?” Bucket by
second: a fixed 300- or 3000-slot array of
(timestamp, count)(timestamp, count)pairs. space regardless of traffic. - “What if timestamps could arrive out of order?” The queue breaks; use a sorted structure or a Fenwick tree over time buckets, and say so — this is the assumption the design rests on.
- “Support querying at an arbitrary past time?” Keep the full history plus a prefix count, then binary search.
- “A rate limiter that allows at most N per window?” Same queue, and reject
when
len(q) > Nlen(q) > Nafter appending.
LC 1352 — Product of the Last K Numbers · Medium
Problem. Implement add(num)add(num) and getProduct(k)getProduct(k), returning the product of
the last kk numbers added. It is guaranteed that kk never exceeds the count of
numbers added.
Constraints. 0 <= num <= 1000 <= num <= 100, 1 <= k <= 4 * 10^41 <= k <= 4 * 10^4, up to 4 * 10^44 * 10^4
calls, and the answer fits in a 32-bit integer.
Examples. After adding 3, 0, 2, 5, 43, 0, 2, 5, 4: getProduct(2)getProduct(2) gives 2020,
getProduct(3)getProduct(3) gives 4040, getProduct(4)getProduct(4) gives 00 (the window reaches the
zero). Then add(8)add(8) and getProduct(2)getProduct(2) gives 3232.
Editorial — approach, complexity, follow-ups
Prefix products give a range product by division, the same way prefix sums give a range sum by subtraction. Zeros are the only complication, and resetting handles them completely.
Time for both operations. Space since the last zero.
Trace the example. After 3, 0, 2, 5, 43, 0, 2, 5, 4, the zero reset the list, so
prefix = [1, 2, 10, 40]prefix = [1, 2, 10, 40] — covering only 2, 5, 42, 5, 4.
getProduct(2)getProduct(2)gives40 // 2 = 2040 // 2 = 20. ✅getProduct(3)getProduct(3)gives40 // 1 = 4040 // 1 = 40. ✅getProduct(4)getProduct(4):4 >= len(prefix) == 44 >= len(prefix) == 4, so the window reaches back past the reset boundary and the answer is00. ✅
That last case is the whole design. len(prefix)len(prefix) is exactly “how many values I
can still see”, so one comparison detects a zero in the window without storing
any zero positions.
Using //// is safe because the division is exact — prefix[-1]prefix[-1] contains every
factor of prefix[-1-k]prefix[-1-k] by construction. Using // would return a float and
lose precision on large products, so integer division is the correct choice, not
merely a stylistic one.
Follow-ups you should expect: “Why not keep a list and multiply the last kk
on demand?” — that is per query and TLEs at queries.
“Support removal from the front?” — prefix products no longer suffice; you would
need a Fenwick tree over logs, or a two-stack running-product trick. “Negative
numbers?” — division still works and the sign takes care of itself; only zero is
special. “Products overflowing?” — a non-issue in Python, but in a fixed-width
language you would track logarithms or use modular arithmetic.
LC 355 — Design Twitter · Medium
Problem. Implement a simplified Twitter with postTweet(userId, tweetId)postTweet(userId, tweetId),
getNewsFeed(userId)getNewsFeed(userId) returning the 10 most recent tweet ids from the user
and the people they follow (most recent first), follow(followerId, followeeId)follow(followerId, followeeId)
and unfollow(followerId, followeeId)unfollow(followerId, followeeId).
Constraints. 1 <= userId, tweetId <= 5001 <= userId, tweetId <= 500, up to 3 * 10^43 * 10^4 calls.
Examples. postTweet(1, 5)postTweet(1, 5), getNewsFeed(1)getNewsFeed(1) gives [5][5], follow(1, 2)follow(1, 2),
postTweet(2, 6)postTweet(2, 6), getNewsFeed(1)getNewsFeed(1) gives [6, 5][6, 5], unfollow(1, 2)unfollow(1, 2),
getNewsFeed(1)getNewsFeed(1) gives [5][5]
Editorial — approach, complexity, follow-ups
This is a pull model: tweets are stored per author, and the feed is assembled on read by merging the newest tweets from the people you follow.
Time postTweetpostTweet, followfollow and unfollowunfollow are . getNewsFeedgetNewsFeed is
for ff followees, gathering at most 10f10f candidates.
Space .
Four decisions worth stating:
- A global counter as the clock. It gives every tweet a unique, increasing
timestamp with no ties and no reliance on wall-clock time. Comparing
(time, tweetId)(time, tweetId)tuples then sorts correctly by recency. - Only the last 10 per user. An 11th-newest tweet from one author cannot be in the global newest 10, since the 10 newer ones from that same author all beat it. Slicing turns an unbounded merge into a bounded one — the key optimisation.
- Include the user themselves. The set union
| {userId}| {userId}does it in one expression and makes a self-follow harmless. discarddiscardoverremoveremove. The test unfollows user 99, never followed;removeremovewould raiseKeyErrorKeyError.
The last test case posts 12 tweets from one user and expects [12..3][12..3] —
verifying both the 10-item cap and the newest-first ordering.
Follow-ups you should expect:
- “How would this scale? Pull or push?” The real discussion. Pull (this design) makes writes cheap and reads expensive — bad for users following thousands of accounts. Push (fan-out on write) precomputes each user’s feed so reads are , but a celebrity with millions of followers makes a single post enormously expensive. Production systems do both: push for ordinary users, pull for high-follower accounts merged in at read time. Being able to name that hybrid is what the follow-up is fishing for.
- “Deleting tweets?” Tombstones, or filter at read time.
- “Feed ranked by relevance rather than time?” The heap key becomes a score, and the “only last 10” pruning is no longer valid, since an older tweet could outrank newer ones.
- “Use a k-way merge instead of
nlargestnlargest?” With each user’s tweets already sorted, a heap of iterators gives — see K-way Merge.
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 933 | Number of Recent Calls | Easy | A dequedeque as a time window; the boundary is inclusive |
| 346 | Moving Average from Data Stream | Easy · Premium | deque(maxlen=k)deque(maxlen=k) plus a running sum |
| 1352 | Product of the Last K Numbers | Medium | Prefix products, reset on zero |
| 355 | Design Twitter | Medium | Bounded k-way merge; global counter as a clock |
| 362 | Design Hit Counter | Medium · Premium | Bucket by second for space under heavy traffic |
| 359 | Logger Rate Limiter | Easy · Premium | Dict of message to next-allowed timestamp |
| 703 | Kth Largest Element in a Stream | Easy | A size-kk min-heap; the root is the answer |
Interview follow-ups
| They ask | What they’re checking | The answer |
|---|---|---|
| “Are timestamps monotonic?” | Whether you ask it | The queue design depends on it; out-of-order arrivals need a sorted structure or time buckets |
| “Amortised or worst case?” | Precision | The windowed counter is amortised; one pingping after a long idle gap can expire many entries |
| “Millions of events per second?” | Scaling instinct | Bucket by time unit — a fixed array of (timestamp, count)(timestamp, count) slots, space regardless of volume |
| “Pull or push for the feed?” | Systems thinking | Pull = cheap writes, costly reads; push = the reverse and bad for celebrities; production hybridises the two |
| “Why only the last 10 tweets per user?” | The key optimisation | An 11th-newest tweet from one author is beaten by that author’s own 10 newer ones |
| “Does your dict grow forever?” | Production awareness | Yes for the naive rate limiter — add TTL eviction or a companion queue |
| “Why integer division?” | Care | The division is exact by construction; float division loses precision on large products |
Edge-case checklist
- Window boundary exactly at the limit —
ping(3001)ping(3001)afterping(1)ping(1)counts11; inclusive means<<, not<=<=. - A long gap between events — one
pingpingexpires many entries; still amortised. - First operation — the queue has exactly the just-added element, so
q[0]q[0]is safe. - Zero added (LC 1352) — resets the history;
getProductgetProductspanning it must return00. kkexactly at the reset boundary —k >= len(prefix)k >= len(prefix)returns00.- No zeros ever — the plain prefix-product path.
- Empty feed — a user with no tweets and no followees returns
[][]. - Self-follow (LC 355) — must not duplicate the user’s own tweets.
- Unfollow someone never followed — must not raise; use
discarddiscard. - More than 10 tweets from one user — only the newest 10 appear, newest first.
Recap
- A queue is a time window when timestamps arrive monotonically — expired events are always at the front. amortised, and always confirm whether the interval is inclusive.
- If the event volume is huge, bucket by time unit for space regardless of traffic.
- Prefix products give range products by division; reset on zero rather than tracking zeros, which also makes division-by-zero impossible. Use integer division — it is exact by construction.
- For feeds, bound what you merge: only the last
kkitems per source can reach the global topkk. - Use a global counter as a clock for a free total ordering.
- Prefer
discarddiscardoverremoveremovewhen an absent key is legal. - The scaling follow-up is nearly always pull versus push, and the real answer is a hybrid. Watch for structures that grow without bound.
Next: Simulation and Stateful Iteration — problems where the algorithm is just “do exactly what the statement says”, carefully.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
