Twitter Trending Interview Questions | JiQuest

add

#

Twitter Trending

System design deep dive · HLD

Design Twitter Trending Topics: real-time hashtag counting at global scale.

Requirements, back-of-envelope capacity estimation, a high-level design diagram, a deeper deployment architecture diagram, sequence diagrams for the ingestion path and the top-K compute path, and an entity-relationship diagram for the rollup data model - with the reasoning an interviewer expects behind every box and arrow.

100K/secPeak hashtag events
~60 secTime to surface a spike
32 KBFixed sketch size / bucket
Post stream~5.8K/sec avg CMS aggregatorregion + global Baseline checkrate-of-change #tag ↑342%top-K now Rollup storeasync persist served to client

1. Clarify requirements before drawing any box

A trending-topics system is deceptively simple to state and easy to over-engineer. Pinning down exactly what "trending" means - and how approximate the answer is allowed to be - decides whether the design lives or dies under a live-event traffic spike.

Functional requirements

Real-time hashtag extractionParse every post as it arrives on the firehose, pulling out #tags (and optionally keyword phrases) with no batch delay.
Sliding-window countsMaintain a rolling count of hashtag occurrences over a recent window, e.g. the last 10-15 minutes, continuously refreshed.
Top-K "trending now"Compute and continuously refresh a ranked shortlist of what's hot right now, not a static all-time popularity chart.
Regional segmentationA topic can trend in one city or country without trending globally; the system serves per-region lists plus one global list.

Non-functional requirements

Near-real-timeA rapidly emerging topic should appear in the trending list within roughly a minute - not require a slow hourly or nightly batch job.
Approximate is acceptableBounded-error counts are fine in exchange for throughput; the product need is "how hot, and is it rising," not an audit-grade count.
Extreme write throughputMust absorb 10-20x traffic multipliers during a goal, an election, or breaking news without falling behind real time.
Read path stays cheapServing the trending list must never involve scanning or resorting the hashtag universe at query time.
Explicitly out of scope Full-text search over post content, a personalized "trending for you" ranking model, and hate-speech/policy takedown workflows are called out as extensions in the deep-dive section rather than core requirements, so the core counting pipeline stays the focus.

2. Back-of-the-envelope capacity estimation

These numbers are the entire justification for the design's central decision: an approximate counting structure instead of an exact hash-map counter. Walk through them in order - each row sets up the next.

MetricAssumptionResulting estimate
Post volume (global firehose)~500 million posts/day~5,800 posts/sec average; 50,000-100,000+ posts/sec during a major live event (8-17x average)
Posts containing ≥1 hashtag~15-20% of all posts~870-1,160 hashtagged posts/sec average; ~15,000-20,000/sec at peak
Hashtag-occurrence events~1.3 hashtags per hashtagged post~1,100-1,500 occurrences/sec average; ~22,000-26,000 occurrences/sec at peak - the increment rate every aggregator must sustain
Distinct active hashtag vocabularyLong-tail: most tags used a handful of times, a few used millions1-5 million distinct hashtags active in a rolling 15-minute window during a major global event
Naive exact counter memory (hash map per hashtag, per region, per bucket)~500 tracked regions × 15 one-minute buckets × up to ~100K locally-active tags each × ~100 bytes/entryTens of GB, growing linearly with vocabulary size and with region × bucket count
Count-Min Sketch memory (fixed structure)Width 2,048 × depth 4 × 4-byte counters = 32KB per sketch instance500 regions × 15 buckets × 32KB ≈ 240MB total - independent of how many distinct hashtags actually appear
Why this matters A hash-map-per-hashtag counter scales with vocabulary size, which explodes precisely when it matters most - during a viral event with millions of distinct tags spread across hundreds of regional buckets. Count-Min Sketch trades a small, one-directional (over-count only) error for a memory footprint that is fixed in advance and independent of cardinality, which is the only property that survives a 100,000-post/sec spike without a capacity re-plan.

3. High-level design (HLD)

The HLD names the major components and the data flow between them, without committing yet to cluster sizes, partitioning schemes, or specific deployment topology - that belongs in the architecture diagram in the next section.

Post firehoseKafka, region-partitioned Hashtag extraction#tags + region tag Regional aggregatorFlink + CMS, per region Global aggregatorFlink + CMS, always-on Baseline storeper-hashtag avg rate Spike detectortick every ~5s Top-K sorted setsRedis, per region+window Trending APIreads top-K only Clientweb / mobile
Stateless / stream servicesFast in-memory aggregation stateDurable rollup storeIngestion / edge

What each box owns

Post firehose (Kafka)

Every new post flows through here, partitioned by a coarse region key derived from the poster's locale/geo. Partitioning by region up front means a regional aggregation cluster only ever has to process its own region's volume - it never has to filter out the other 490 million daily posts that belong elsewhere.

Hashtag extraction

Lightweight text processing: a regex/tokenizer pulls out #tags (optionally a keyword-phrase extractor for un-hashtagged trending phrases), and tags the resulting event with a region derived from the poster's locale. It is stateless, so it scales horizontally with ingestion volume alone - it holds no counters and can be scaled up during a spike without any coordination.

Regional and global aggregators (Flink + Count-Min Sketch)

Each regional cluster consumes only its own Kafka partitions and maintains a Count-Min Sketch per 1-minute tumbling bucket for that region. A separate, always-on global cluster subscribes to every partition and maintains its own global sketch - it does not wait for or derive from the regional clusters, because a topic can trend globally through broad, diffuse volume that never exceeds any single region's local threshold.

Baseline store and spike detector

The baseline store holds a slow-moving historical average rate per hashtag (an exponentially weighted moving average). On a periodic tick - not per event - the spike detector reads each active hashtag's current sliding-window estimate from the sketch, compares it against that hashtag's own baseline, and computes a rate-of-change score. Decoupling this from per-event processing keeps its cost proportional to the number of active hashtags, not the number of posts.

Top-K sorted sets and the Trending API

Redis holds one bounded sorted set per region+window (top 200 entries, never the whole vocabulary), updated incrementally by the spike detector's tick. The Trending API is a thin, stateless read service that does nothing but read the top of that sorted set - all the computation already happened upstream, so a client request never triggers a scan or a resort.

4. Detailed architecture diagram

The architecture diagram answers "how is this actually deployed?" - specifically, how ingestion is partitioned by region, why a global path runs in parallel rather than deriving from regional results, and where the durable rollup store sits relative to the real-time hot path.

Ingestion & edge layer Regional Kafka partitions CDN / edge cache API gateway + L7 LB Rate limiterper-client read throttle Region: us-east-1 (regional cluster) Extraction ×8 pods Flink job ×4 TMs (CMS) Redis Top-K (regional) Trending API ×6 pods Region: eu-west-1 (active-active) Extraction ×5 pods Flink job ×3 TMs (CMS) Redis Top-K (regional) Trending API ×4 pods Global aggregation path Global Flink job (all partitions) Global CMS state (RocksDB) Baseline + spike scoring Baseline store (EWMA/tag) Spike scorer job, tick 5s Persisted rollup store Postgres: hashtag_counts, trend_windows read-only: history, backfill, no live queries
DecisionChoiceReasoning
Windowing strategy1-minute tumbling buckets, summed over the trailing 10-15 bucketsA literal sliding window recomputed per event is O(events); bucketed rollups are O(buckets) and expire cheaply by dropping the oldest bucket.
Counting structureCount-Min Sketch (width ~2,048, depth 4) per region+bucketFixed memory regardless of hashtag cardinality; a viral tag gets a small, bounded overcount, while a rare tag costs nothing extra to track.
Regional vs global aggregationA separate, always-on global Flink job - not derived synchronously from regional resultsThe global path must not wait on every regional cluster's tick to finish, and a topic can trend globally through diffuse volume that's sub-threshold in any single region.
Top-K maintenanceBounded Redis sorted set per region+window, updated with incremental ZADDAvoids resorting the entire hashtag universe on every tick; ZREVRANGE for the top 50 is O(log N + 50), not O(all hashtags).

5. Sequence diagrams for the two critical flows

One flow is on the hot ingestion path (every post), the other runs on a periodic tick (every few seconds, independent of post volume). Keeping them decoupled is exactly what lets the trending computation stay cheap even at 100,000 posts/sec.

5.1 Real-time hashtag extraction and counting

Post firehose Extraction svc Regional aggregator Global aggregator 1. new post {text, author_id, locale} 2. parse #tags; derive region from locale (→ us-east) 3. HashtagEvent{tag=#worldcup, region=us-east} 4. increment CMS[us-east][bucket_t] (k=4 hash cells) 5. HashtagEvent{tag, region=global} (fire-and-forget) 6. increment CMS[global][bucket_t] 7. rollup: sum trailing 10-15 buckets → regional sliding-window estimate 8. rollup: sum trailing buckets → global sliding-window estimate

Step 5 is deliberately drawn as a dashed, non-blocking arrow: the extraction service publishes the same tagged event to the global aggregator without waiting on the regional aggregator's ack in step 3 - both increments happen independently, so a slow regional cluster can never hold up the global count. Steps 4 and 6 never touch a per-hashtag hash map; they touch a fixed number of Count-Min Sketch cells regardless of how many distinct hashtags exist in that bucket.

5.2 Top-K / trending computation (periodic tick)

Ticker Aggregator (CMS) Baseline store Top-K (Redis) Trending API Client 1. tick every ~5s 2. current_count ≈ estimate(tag) from CMS + buckets 3. getBaseline(tag) 4. baseline_avg (EWMA of historical rate) 5. score = rateOfChange(current_count, baseline_avg) 6. ZADD region:window score tag (bounded top-K) 7. GET /trending?region=us-east 8. ZREVRANGE region:window 0 49 9. [(tag, score), ...] top 50 10. 200 OK trending list

Steps 1-6 run on a fixed cadence, entirely independent of how many posts arrived that second - a 100,000-post/sec spike changes the counters the tick reads in step 2, but never changes how often the tick fires or how much work each firing does. Steps 7-10 are the whole client-facing read path: the Trending API performs zero aggregation or comparison at request time, it only reads the already-ranked sorted set, which is what keeps read latency flat regardless of write volume.

6. Entity-relationship (ER) diagram and schema

These three tables model the periodically-persisted, queryable rollup layer only - the in-memory Count-Min Sketch counters and Redis sorted sets that do the actual real-time counting live entirely inside the stream processors and are never modeled as relational rows.

regions PK region_id BIGINT name VARCHAR parent_region BIGINT NULL self-FK: city → country → global trend_windows PK window_id BIGINT window_type ENUM(1m,15m) start_ts TIMESTAMP end_ts TIMESTAMP hashtag_counts PK hashtag VARCHAR PK region_id BIGINT (FK → regions) PK window_id BIGINT (FK → trend_windows) estimated_count BIGINT first_seen_at TIMESTAMP last_updated_at TIMESTAMP 1N 1N one region and one window each own many hashtag_counts rows; regions self-reference for city→country→global roll-up

Key modeling decisions

Composite primary key, no surrogate idhashtag + region_id + window_id together are the natural key - a row is uniquely identified by "this tag, in this region, during this window," so a synthetic id adds nothing.
estimated_count is named honestlyThe column name signals it's a Count-Min Sketch readout, not an exact COUNT(*), so dashboards and historical charts built on it never mistake it for audit-grade data.
trend_windows separates bucket definition from datawindow_type distinguishes a raw 1-minute tumbling bucket from a rolled-up 15-minute sliding estimate, so one table serves both granularities without duplicated start/end logic per row.
regions is a shallow adjacency listparent_region_id lets a city roll up to country to global in a couple of recursive lookups - a closure table would be overkill for a 3-4 level hierarchy.
Storage layerWhat lives hereWhy
Relational rollup store (Postgres)hashtag_counts and trend_windows, once rolled up to 1-minute/15-minute granularityLow volume once rolled up (thousands of active hashtags × a handful of windows, not per-event); simple range queries (WHERE window BETWEEN ...) power historical trend charts; sits entirely off the hot path.
In-memory probabilistic + streaming stateCount-Min Sketch counters (Flink task-manager memory / RocksDB) and Redis top-K sorted setsDoes the actual heavy lifting at 20,000+ increments/sec; never queried directly by clients and never treated as the durable source of truth - it's regenerable from the raw post stream on replay.

7. Deep dives interviewers actually probe

Exact counting vs. Count-Min Sketch: why a hash map doesn't scale here

A hash-map-per-hashtag exact counter needs one entry per distinct hashtag that has ever appeared in a given region+bucket. During a viral event the vocabulary is both huge and skewed - millions of one-off tags alongside a handful used millions of times - and that table has to exist per region, per 1-minute bucket, simultaneously. Count-Min Sketch replaces the map with a small fixed-size 2D array of counters: hash the hashtag with k independent hash functions, increment the k cells it maps to, and estimate its count as the minimum of those k cells (the minimum cancels out most of the hash-collision noise from other hashtags sharing a cell). The error is strictly one-directional - it can only overcount, never undercount - and is bounded by the sketch's width and depth, not by how many distinct hashtags exist.

// Count-Min Sketch: fixed WIDTH x DEPTH regardless of hashtag cardinality
int[][] table = new int[DEPTH][WIDTH]; // e.g. 4 x 2048, ~32KB total

void increment(String tag) {
    for (int d = 0; d < DEPTH; d++) {
        int col = hash(tag, seed[d]) % WIDTH;
        table[d][col]++;
    }
}

long estimate(String tag) {
    long min = Long.MAX_VALUE;
    for (int d = 0; d < DEPTH; d++) {
        int col = hash(tag, seed[d]) % WIDTH;
        min = Math.min(min, table[d][col]);
    }
    return min; // always >= true count, error is bounded and one-directional
}

Sliding window vs. tumbling buckets: how the 10-15 minute window is actually implemented

A literal sliding window - recomputing the count over exactly "the last 900 seconds" on every single event - means every increment also has to expire whatever fell off the trailing edge, which is expensive at 20,000+ events/sec. Instead, the aggregator keeps a fresh Count-Min Sketch per 1-minute tumbling bucket and sums the trailing 10-15 buckets to approximate the sliding window. The trade-off is granularity: the window's effective boundary only moves in 1-minute steps, so a hashtag's reported count can be up to one bucket-width stale at the edges. A 1-minute bucket was chosen specifically because it's small enough to keep the near-real-time freshness requirement (spikes visible within ~60 seconds) while being large enough that per-bucket overhead (allocating and later garbage-collecting a sketch) stays a small fraction of total processing cost.

Detecting "trending" as a rate-of-change signal, not raw volume

A naive "top hashtags by raw count in the window" list would be dominated forever by whatever is perennially popular - generic tags that always have huge volume, not necessarily anything happening right now. The spike detector instead compares each hashtag's current sliding-window estimate against its own historical baseline (an exponentially weighted moving average maintained per hashtag), producing a score such as current/baseline or a normalized z-score. A tag that jumps from a baseline of 50/min to 5,000/min scores far higher than a tag that's steady at 50,000/min, which is exactly the signal that makes "trending" mean "emerging" rather than "generically popular."

Regional segmentation and the roll-up hierarchy

Regional aggregation clusters are deliberately kept separate from the global one rather than having the global list synchronously derive from regional results, for two reasons: first, the global tick must not block on every regional cluster finishing its own tick, especially when one region is under a traffic spike; second, a topic can trend globally through broad, diffuse volume - meaningful upticks spread thinly across dozens of regions - without ever crossing any single region's local trending threshold. The regions table's parent_region_id lets a query roll a city's numbers up into its country and then into global for display purposes, but the counting itself always happens independently at each level, never by summing children synchronously at read time.

Spam and manipulation resistance

Coordinated bot posting or duplicate-account amplification can artificially inflate a hashtag's count within minutes - exactly the timescale the system is designed to react to. Two mitigations sit in the extraction stage, before anything reaches the sketch: deduplicating by unique author per short time window (e.g. count a given author's use of a tag at most once per 10 seconds, regardless of how many times they post it), and down-weighting or dropping events from accounts whose posting velocity or account-age/follower profile matches known bot patterns. Both run at extraction time specifically because it's the cheapest point to filter - rejecting a bad event before it costs a sketch increment is far cheaper than trying to subtract it out later.

What happens when a Flink task manager crashes mid-window?

The Count-Min Sketch state lives in RocksDB-backed Flink state, checkpointed periodically (e.g. every 10-30 seconds) to durable storage. On a task manager crash, Flink restores the last checkpoint and replays Kafka events from the corresponding offset, so at most a few seconds of increments are ever redone - not lost, and not double-applied beyond Flink's exactly-once state semantics. This is also the reason the sketch state is never treated as a durable source of truth in its own right: it's fully regenerable from the raw post stream, which is exactly what makes it safe to keep purely in memory/local disk rather than in a replicated database.

8. Summary: what a strong answer covers

Justified approximate counting with real memory mathSeparated per-event work from periodic tick workKept regional and global aggregation independent Made "trending" a rate-of-change signal, not raw volumeBounded the top-K structure instead of resorting everythingNamed the durable rollup layer vs. the in-memory hot state
Interview tip When asked to design a trending-topics system, the strongest signal is naming the specific reason exact counting fails at this scale - cardinality times region times time-bucket, not just "there's a lot of data" - and then showing that every subsequent choice (Count-Min Sketch, tumbling buckets, bounded top-K, a separate global path) exists to keep a fixed, predictable cost in the face of an unpredictable, spiky, high-cardinality workload.
No comments
Leave a Comment