Skip to content

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:

  1. A queue is a time window. If events arrive in non-decreasing time order, a dequedeque holding the events currently inside the window is enough — push the new one, drop the expired ones from the front.
  2. 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 kk items 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

recent_counter.py
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)
recent_counter.py
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 O(1)O(1) amortised per pingping — each timestamp is appended once and popped at most once, so nn pings do O(n)O(n) total work. A single pingping can be O(n)O(n) if a long-idle period expires many entries at once. Space O(w)O(w), 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.

product_of_last_k.py
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]
product_of_last_k.py
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.
twitter_feed.py
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 KeyError
twitter_feed.py
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 KeyError

getNewsFeedgetNewsFeed is O(flogf)O(f \log f) where ff is the number of followees, since it gathers at most 10f10f candidates and nlargestnlargest is O(mlog10)O(m \log 10) over them.

DesignCostSpace
Windowed counter (queue)O(1)O(1) amortisedO(events in window)O(\text{events in window})
Prefix productsO(1)O(1) per add and queryO(n)O(n) since the last zero
Feed (pull model)O(flogf)O(f \log f) per readO(tweets)O(\text{tweets})
Feed (push model)O(f)O(f) per write, O(1)O(1) per readO(f×feed size)O(f \times \text{feed size})

The variant map

VariantThe structureCanonical problem
Count events in a windowdequedeque of timestamps933 · 362
Rate limit per keyDict of key to last-allowed timestamp359 (Premium)
Running average of last kdequedeque with maxlenmaxlen + a running sum346
Product of last kPrefix products with a zero reset1352
Merged ranked feedBounded k-way merge with a heap355
Top-k over a streamA size-k heap; see Top K703

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 O(1)O(1) amortised — each timestamp is added once and removed at most once, so nn pings cost O(n)O(n) total. Space O(w)O(w) 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. O(1)O(1) 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) > N after 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 O(1)O(1) for both operations. Space O(n)O(n) 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) gives 40 // 2 = 2040 // 2 = 20. ✅
  • getProduct(3)getProduct(3) gives 40 // 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 is 00. ✅

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 O(k)O(k) per query and TLEs at 4×1044 \times 10^4 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 O(1)O(1). getNewsFeedgetNewsFeed is O(flogf)O(f \log f) for ff followees, gathering at most 10f10f candidates. Space O(tweets+follow edges)O(\text{tweets} + \text{follow edges}).

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.
  • discarddiscard over removeremove. The test unfollows user 99, never followed; removeremove would raise KeyErrorKeyError.

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 O(1)O(1), 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 O(10logf)O(10 \log f) — see K-way Merge.

LeetCode problem set

#ProblemDifficultyThe twist
933Number of Recent CallsEasyA dequedeque as a time window; the boundary is inclusive
346Moving Average from Data StreamEasy · Premiumdeque(maxlen=k)deque(maxlen=k) plus a running sum
1352Product of the Last K NumbersMediumPrefix products, reset on zero
355Design TwitterMediumBounded k-way merge; global counter as a clock
362Design Hit CounterMedium · PremiumBucket by second for O(1)O(1) space under heavy traffic
359Logger Rate LimiterEasy · PremiumDict of message to next-allowed timestamp
703Kth Largest Element in a StreamEasyA size-kk min-heap; the root is the answer

Interview follow-ups

They askWhat they’re checkingThe answer
“Are timestamps monotonic?”Whether you ask itThe queue design depends on it; out-of-order arrivals need a sorted structure or time buckets
“Amortised or worst case?”PrecisionThe windowed counter is O(1)O(1) amortised; one pingping after a long idle gap can expire many entries
“Millions of events per second?”Scaling instinctBucket by time unit — a fixed array of (timestamp, count)(timestamp, count) slots, O(1)O(1) space regardless of volume
“Pull or push for the feed?”Systems thinkingPull = 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 optimisationAn 11th-newest tweet from one author is beaten by that author’s own 10 newer ones
“Does your dict grow forever?”Production awarenessYes for the naive rate limiter — add TTL eviction or a companion queue
“Why integer division?”CareThe division is exact by construction; float division loses precision on large products

Edge-case checklist

  • Window boundary exactly at the limitping(3001)ping(3001) after ping(1)ping(1) counts 11; inclusive means <<, not <=<=.
  • A long gap between events — one pingping expires many entries; still O(1)O(1) 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; getProductgetProduct spanning it must return 00.
  • kk exactly at the reset boundaryk >= len(prefix)k >= len(prefix) returns 00.
  • 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. O(1)O(1) amortised, and always confirm whether the interval is inclusive.
  • If the event volume is huge, bucket by time unit for O(1)O(1) 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 kk items per source can reach the global top kk.
  • Use a global counter as a clock for a free total ordering.
  • Prefer discarddiscard over removeremove when 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 coffee

Was this page helpful?

Let us know how we did