#Design Twitter Trends (Twitter Scale)
Target Scale: 500M tweets/day, 350M MAU, 200K+ hashtags/hour, real-time global + regional + personalized trends.
#1. Problem Statement & Clarifications
#Functional Requirements
- Global Trending Topics: Compute and serve the top-30 trending hashtags/phrases globally, refreshed every 60 seconds.
- Regional Trends: Compute top-30 trends per geographic region (country, city), refreshed every 60 seconds.
- Personalized Trends: For a logged-in user, filter global/regional trends through their interest graph (exclude topics they always see, boost niche ones they care about).
- Trend Metadata: Each trend entry includes tweet volume, trend velocity (acceleration), and representative sample tweets.
- Trend Lifespan Tracking: Track when a topic started trending and estimate time-to-peak.
- Anti-Spam / Manipulation Filtering: Exclude coordinated inauthentic behavior — bot farms, purchased trend injection.
#Non-Functional Requirements
| Requirement | Target |
|---|---|
| Trend freshness | ≤ 60 s from event to trend update |
| Trend API latency (P99) | < 100 ms |
| Throughput | 6K tweets/s avg; 30K/s peak (breaking news) |
| Availability | 99.99% |
| Trend accuracy | Top-K error < 5% for items in true top-30 |
| Regional granularity | Country-level (50 regions), City-level (500 cities) |
| Historical retention | Trend snapshots retained for 90 days |
#Out of Scope
- Full tweet indexing and search (separate Elasticsearch cluster).
- Trend-based ad targeting / monetization pipeline.
- Real-time tweet content moderation.
- Trend notifications / push alerts to users.
- Twitter Spaces or video trending (audio/video signals).
#Assumptions
- A "trend" is defined as a hashtag or n-gram phrase appearing with significant velocity (rate of increase) relative to its historical baseline, not just raw volume.
- Users' IP or profile-set location determines regional trend bucket.
- Bots are identified by a pre-existing ML classifier (score attached to each tweet event); we filter at ingestion.
- Trends are refreshed on a 60-second cycle; clients poll or receive via SSE (Server-Sent Events).
#2. Back-of-Envelope Estimation
#Traffic Estimates
Tweets/day = 500M
Tweets/s (avg) = 500M / 86400 ≈ 5.8K TPS
Peak TPS = 5.8K × 5 (breaking news spike) ≈ 30K TPS
Hashtags per tweet = avg 1.5 → 5.8K × 1.5 ≈ 8.7K hashtag events/s
Unique hashtags/hr = ~200K (long tail; top 500 account for 80% of volume)
Trend API reads = 350M MAU → assume 30% open app/hr → 105M req/hr
= 105M / 3600 ≈ 29K read QPS
Peak: 29K × 3 = 87K read QPS (during major events)#Storage Estimates
Tweet event (Kafka payload):
tweet_id (8B) + user_id (8B) + hashtags[] (avg 40B) + geo (8B) + ts (8B) + bot_score (4B)
= ~76 B per tweet event
= 500M × 76 B ≈ 38 GB/day (Kafka topic, 7-day retention = 266 GB)
Trend snapshot per region per minute:
Top-30 entries × (hashtag 50B + count 8B + velocity 8B + sample_ids 3×8B) ≈ 2 KB
Total regions = 1 global + 50 country + 500 city = 551 regions
Per minute = 551 × 2 KB = 1.1 MB/min
90-day retention = 1.1 MB × 60 × 24 × 90 ≈ 143 GB (PostgreSQL, compressed)
Count-Min Sketch (CMS) per region in-memory:
w=3000, d=5, 32-bit counters → 3000 × 5 × 4 B = 60 KB per sketch
551 regions × 60 KB = 33 MB total in-memory (trivial)
Redis ZSET (top-K per region):
Top-500 per region × (50B hashtag + 8B score) ≈ 29 KB per region
551 × 29 KB ≈ 16 MB total (negligible)#Bandwidth
Kafka ingest = 30K TPS peak × 200 B (full tweet payload) = 6 MB/s
Trend API response = 87K QPS × 3 KB (30 trends + metadata) = 261 MB/s
→ Served from Redis/CDN cache, not app servers
Flink → Redis writes = 551 regions × 1 update/min = 9 writes/s (tiny)#Cache Estimates
Trend results cached in Redis per region = 29 KB × 551 = 16 MB
Personalized trends = computed at edge from base trends + user interest vector
= no separate cache needed (computed in < 5ms)
CDN caches global trends for unauthenticated users (TTL = 60s)#3. API Design
#GET /v1/trends — Fetch Trending Topics
GET /v1/trends?woeid=1&limit=30&personalized=true
Authorization: Bearer <jwt> (optional; omit for global unauthenticated)
woeid = Where On Earth ID (Yahoo WOEID standard)
woeid=1 → worldwide
woeid=23424977 → United States
woeid=2459115 → New York City
Response 200:
{
"as_of": "2026-06-02T00:16:00Z",
"ttl_s": 60,
"location": { "woeid": 1, "name": "Worldwide" },
"trends": [
{
"rank": 1,
"name": "#WorldCup2026",
"query": "%23WorldCup2026",
"tweet_volume": 1284000,
"volume_24h": 8420000,
"velocity": 3.4, // acceleration: volume_now / volume_1h_ago
"trending_since": "2026-06-02T00:00:00Z",
"sample_tweets": ["tweet_id_1", "tweet_id_2", "tweet_id_3"]
}
]
}#GET /v1/trends/available — List Available WOEID Locations
GET /v1/trends/available
Response 200:
[
{ "woeid": 1, "name": "Worldwide", "type": "Supername" },
{ "woeid": 23424977, "name": "United States", "type": "Country" },
{ "woeid": 2459115, "name": "New York", "type": "Town" }
]#POST /v1/internal/trends/refresh — Internal Trend Refresh (Flink → Trends Service)
POST /v1/internal/trends/refresh [INTERNAL]
{
"woeid": 1,
"window_end": "2026-06-02T00:16:00Z",
"top_k": [
{ "term": "#WorldCup2026", "count": 21400, "velocity": 3.4, "sample_ids": ["t1","t2","t3"] }
]
}
Response 204#GET /v1/trends/{term}/history — Trend Volume Over Time
GET /v1/trends/%23WorldCup2026/history?hours=24&resolution=5m
Response 200:
{
"term": "#WorldCup2026",
"data_points": [
{ "ts": "2026-06-01T00:00:00Z", "volume": 12000 },
{ "ts": "2026-06-01T00:05:00Z", "volume": 14500 }
]
}#4. Data Model
#Tweet Events (Kafka — partitioned by user_id)
Topic: tweet-events
Partitions: 200 (keyed by user_id for ordering within user)
Retention: 7 days
Schema (Avro):
{
tweet_id: long,
user_id: long,
text: string,
hashtags: array<string>,
phrases: array<string>, // extracted n-grams by NLP service
lang: string, // ISO 639-1
woeid: int, // resolved from user profile/IP
bot_score: float, // 0.0 = human, 1.0 = bot
created_at: long // epoch millis
}#Trend Snapshots (PostgreSQL — sharded by woeid)
CREATE TABLE trend_snapshots (
woeid INT NOT NULL,
window_end TIMESTAMPTZ NOT NULL,
rank SMALLINT NOT NULL,
term VARCHAR(140) NOT NULL,
tweet_volume BIGINT,
velocity FLOAT,
sample_ids BIGINT[],
PRIMARY KEY (woeid, window_end, rank)
) PARTITION BY RANGE (window_end);
-- Monthly partitions, auto-dropped after 90 days
CREATE INDEX idx_snapshots_woeid_term ON trend_snapshots (woeid, term, window_end DESC);#Real-Time Trend State (Redis — primary serving store)
# Top-K sorted set per region (score = weighted trend score)
Key: trends:woeid:{woeid}
Type: Redis ZSET (term → score)
TTL: 120 s (2× refresh cycle; prevents stale data serving)
# Current trend metadata (volume, velocity, samples)
Key: trend_meta:{woeid}:{term}
Type: Redis Hash
Fields: volume, velocity, trending_since, sample_ids (JSON)
TTL: 120 s
# Trend baseline (hourly historical avg for velocity calc)
Key: baseline:{term}
Type: Redis Hash (hour_of_week → avg_count)
TTL: 7 days (refreshed by nightly batch job)#User Interest Profile (Cassandra — for personalized trends)
CREATE TABLE user_interest_profile (
user_id BIGINT PRIMARY KEY,
topic_weights MAP<TEXT, FLOAT>, -- {"#sports": 0.9, "#politics": 0.1}
exclude_topics SET<TEXT>, -- topics user has explicitly muted
updated_at TIMESTAMP
);#Access Patterns
| Query | Store | Key / Index |
|---|---|---|
| Fetch top-30 trends for woeid | Redis | ZREVRANGE trends:woeid:{woeid} 0 29 |
| Get trend metadata (volume, velocity) | Redis | HGETALL trend_meta:{woeid}:{term} |
| Persist trend snapshot for history | PostgreSQL | INSERT trend_snapshots |
| Query trend history for a term | PostgreSQL | idx_snapshots_woeid_term |
| Get user interest profile | Cassandra | PRIMARY KEY user_id |
| List available woeids | Redis | trends:available (preloaded set) |
#5. High-Level Design (HLD) & Scale Evolution
#Stage 1: MVP / Startup Scale (1M DAU, 1 region, global trends only)
- Single Kafka topic, single Flink job reads tweets, counts hashtags in 1-min tumbling windows.
- Top-30 written to a single Redis ZSET. API server reads from Redis.
- Bottleneck: Tumbling windows cause 60-second "cliff" resets visible to users. No regional trends. No velocity — just raw count. No anti-spam.
#Stage 2: Growth Scale (50M DAU, 50 country regions)
- Flink job parallelized: one Flink subtask per region (fan-out by woeid in Kafka partitioning).
- Shift to bucketed sliding windows (60 × 1-min buckets) to eliminate tumbling-window boundary artifacts.
- Introduce Count-Min Sketch per region to handle high-cardinality hashtag space efficiently.
- Add velocity signal: compare current 60-min window count vs prior 60-min window count.
- Bottleneck: Trend API serves 29K QPS directly from Redis — Redis cluster needed. No personalization. Bot filtering is manual/delayed.
#Stage 3: Twitter Scale (350M MAU, 551 regions, personalized, ML-scored)
#Architecture Diagram
Twitter Clients (Web/iOS/Android)
│ GET /v1/trends
▼
┌─────────────────────────────────────────────────┐
│ CDN (CloudFront) │
│ Caches global/country trends TTL=60s │
│ Cache-miss → Trend API Service │
└──────────────────┬──────────────────────────────┘
│ (cache miss or personalized req)
▼
┌──────────────────────────────────────────────────┐
│ API Gateway + Load Balancer │
└───────┬──────────────────┬───────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ Trends API │ │ Personalization │
│ Service │ │ Service │
│ (read Redis) │ │ (rerank per user)│
└──────┬───────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────┐
│ Redis Cluster │
│ trends:woeid:{id} ZSET (top-500 per region) │
│ trend_meta:{woeid}:{term} Hash │
│ baseline:{term} Hash (historical avg) │
└──────────────────────────────────────────────┘
▲
│ Writes every 60s per region
│
┌──────────────────────────────────────────────────────────┐
│ Trend Computation Pipeline │
│ │
│ Tweet Events (Kafka, 200 partitions) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Flink Streaming Job │ │
│ │ ├── Ingest & Filter (bot_score > 0.7 → drop)│ │
│ │ ├── Hashtag Extractor + NLP Phrase Extractor │ │
│ │ ├── Geo Router (assign woeid) │ │
│ │ ├── Count-Min Sketch per region (60 buckets) │ │
│ │ ├── Top-K Heap per region (Space-Saving) │ │
│ │ ├── Velocity Calculator (current/baseline) │ │
│ │ ├── Trend Scorer (count × velocity weight) │ │
│ │ └── Emit top-500 per region → Kafka output │ │
│ └──────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ ┌──────────────────────┐ │
│ │ Trend Writer Svc │ │ Snapshot Writer Svc │ │
│ │ → Redis ZSET update│ │ → PostgreSQL INSERT │ │
│ └─────────────────────┘ └──────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Tweet Ingestion Path:
Mobile → Tweet Service → Kafka (tweet-events)
└→ NLP Service (async phrase extraction → Kafka)#Component Breakdown
| Component | Responsibility | Tech Choice |
|---|---|---|
| CDN | Cache global/country trends TTL=60s for anonymous users | CloudFront / Fastly |
| Trends API Service | Read Redis ZSET, format response | Go (stateless, horizontally scaled) |
| Personalization Service | Rerank trends using user interest profile | Go + Cassandra user profiles |
| Flink Streaming Job | Count-Min Sketch + top-K + velocity computation | Apache Flink (parallelism=200) |
| Trend Writer Service | Atomic Redis ZSET replacement per region | Go |
| Snapshot Writer Service | Persist trend snapshots to PostgreSQL | Go |
| NLP Phrase Extractor | Extract trending n-grams beyond hashtags | Python microservice (spaCy) |
| Bot Filter | Drop events with bot_score > threshold | Inline Flink filter stage |
| Baseline Job | Nightly batch — compute hourly avg per term | Spark batch → Redis |
| Kafka | Tweet event bus, trend-output topic | Kafka (200 + 50 partitions) |
| Redis Cluster | Serve trend ZSETs + metadata hashes | Redis Cluster (6 shards) |
| PostgreSQL | 90-day trend snapshot history | PostgreSQL (range-partitioned) |
| Cassandra | User interest profiles for personalization | Cassandra |
#Data Flow
Tweet Ingestion → Trend Update (Write Path):
User posts tweet
→ Tweet Service validates + stores tweet
→ Publishes to Kafka topic: tweet-events (partition = user_id % 200)
→ NLP Service (parallel consumer): extracts phrases → publishes enriched event
Flink Job (consumes tweet-events):
1. Filter: drop if bot_score > 0.7
2. Explode: for each hashtag/phrase in tweet → emit (woeid, term, count=1)
3. Route: also emit to parent woeids (city → country → global fan-up)
4. Aggregate: CMS update per (woeid, bucket_minute) + Space-Saving top-K heap
5. Every 60s: emit snapshot per region → trend-output Kafka topic
Trend Writer (consumes trend-output):
→ Redis: ZADD trends:woeid:{id} {score} {term} (atomic MULTI/EXEC)
→ Redis: HSET trend_meta:{woeid}:{term} volume velocity trending_since
→ Publishes to PostgreSQL snapshot writerTrend API Read (Read Path):
Client → CDN (global/country, unauthenticated)
HIT → Return cached trend JSON (TTL=60s)
MISS → Trends API Service
Trends API Service:
→ Redis ZREVRANGE trends:woeid:{woeid} 0 29 WITHSCORES (~1ms)
→ For each term: Redis HGETALL trend_meta:{woeid}:{term}
→ If personalized=true: Personalization Service
→ Cassandra: fetch user_interest_profile
→ Rerank: boost terms matching high-weight topics; filter excluded topics
→ Return JSON to client (or CDN for caching)#6. Deep Dive — Core Components
#Flink Streaming Job — Count-Min Sketch + Top-K Pipeline
This is the algorithmic core. Each Flink task slot handles a subset of woeids.
Stage 1: Bucket Management (Sliding Window)
Window W = 60 min, Bucket size B = 1 min → 60 active buckets per (woeid, term)
Flink ProcessFunction maintains per-woeid state:
- bucket_ring[60]: circular buffer of CMS tables (one per minute)
- current_bucket_idx: int
- top_k_heap: Space-Saving structure (capacity=500 terms)
On event (woeid, term, ts):
bucket_idx = (ts.epochMinute) % 60
bucket_ring[bucket_idx].update(term, 1) # CMS update
top_k_heap.update(term, 1) # Space-Saving update
Every 60s (on watermark tick):
Evict oldest bucket: bucket_ring[evict_idx].reset()
Advance current_bucket_idx
Recompute merged count for each term in top_k_heap:
count(term) = min over all d rows of sum(bucket_ring[i].query(term)) for i in active_buckets
Sort by trend_score = count × velocity_weight
Emit top-500 → trend-output topicStage 2: Velocity Calculation
velocity(term, woeid) = count_current_window / max(1, baseline_count_this_hour_of_week)
baseline_count_this_hour_of_week fetched from Redis:
Redis HGET baseline:{term} {day_of_week}_{hour_of_day}
If velocity > 3.0 → "Trending" label
If velocity > 10.0 → "Viral" label
New terms (no baseline) → velocity = count / global_avg_new_term_rateStage 3: Geo Fan-Up (Hierarchical Aggregation)
City woeid → Country woeid → Global woeid
Each tweet emits events for all levels:
emit(city_woeid, term, 1)
emit(country_woeid, term, 1)
emit(GLOBAL_WOEID, term, 1)
Flink partitions state by woeid → each level processed independently.
Country and global trends naturally aggregate without explicit rollup joins.#Personalization Service — Detailed Design
Personalization runs on the result set (top-500 per region), not on raw tweet events. This keeps the personalization service lightweight.
Input: top-500 trends for user's woeid + user_interest_profile
Output: top-30 personalized trends
Algorithm:
For each trend t in top-500:
topic_boost = user_interest_profile.topic_weights.get(t.term, 0.5)
if t.term in user_interest_profile.exclude_topics: skip
personalized_score = t.trend_score × topic_boost
Return top-30 by personalized_scoreWhy top-500 in Redis (not just top-30)? Personalization may boost rank-100 items above rank-5 for a specific user.
#Bot & Spam Filtering
Pre-filter in Flink (real-time):
- Drop events where
bot_score > 0.7(threshold tuned by Trust & Safety team). - Drop events from accounts created < 48 hours ago for coordinated hashtag events.
- Rate-limit contribution: max 3 hashtag-count contributions per user per 60-min window (prevents single account from boosting a trend). Tracked per-user in Flink keyed state.
Post-filter on trend output:
- If > 30% of a trend's volume comes from accounts in the same geographic cluster created on the same day → suppress trend.
- Flagged via a separate Anomaly Detection Kafka consumer that signals the Trend Writer to exclude the term.
#Scaling Strategy
| Component | Strategy |
|---|---|
| Flink Job | 200 task slots; partition by woeid hash; auto-scale on lag |
| Redis Cluster | 6 shards (16 slots each); reads from replicas; 16 MB trend data — trivial |
| Trends API | Stateless Go services; 50 replicas; behind CDN |
| Kafka tweet-events | 200 partitions; RF=3; 7-day retention |
| PostgreSQL snapshots | Monthly range partitions; 2 read replicas; auto-vacuum |
| CDN | 60s TTL for global/country; no TTL for personalized (per-user, skip CDN) |
#Caching Strategy
Layer 1 — CDN (CloudFront)
Global + country trends for unauthenticated users
TTL = 60s (matches refresh cycle)
→ Absorbs ~70% of all trend read traffic
Layer 2 — Redis ZSET (trends:woeid:{id})
Top-500 terms per region, 551 regions, 16 MB total
→ Serves all cache-miss and personalized requests at ~1ms
Layer 3 — Redis Hash (trend_meta:{woeid}:{term})
Metadata (volume, velocity, sample_ids) for top-500 terms per region
→ Fetched in pipeline with ZSET read
Layer 4 — Redis Hash (baseline:{term})
7-day historical hourly averages for velocity computation
→ Updated nightly by Spark batch job
→ Fetched inline by Flink job (remote Redis call, cached locally in Flink state)#Consistency Models
| Data | Model | Rationale |
|---|---|---|
| Trend rankings (Redis ZSET) | Eventual (Redis, 60s cycle) | 60s lag acceptable; trends aren't real-time stock prices |
| Trend metadata (volume, velocity) | Eventual | Same 60s refresh cycle |
| Historical snapshots (PostgreSQL) | Strong (PostgreSQL ACID) | Snapshot history must be accurate for analytics |
| User interest profile | Eventual (Cassandra QUORUM read) | Slightly stale profile acceptable; personalization is best-effort |
| Bot filter decisions | Eventual (async anomaly detector) | Small window of manipulation exposure acceptable vs sync latency cost |
#7. Low-Level Design — Core Functionality
#Key Algorithms
Algorithm 1: Count-Min Sketch Update & Query
import hashlib
class CountMinSketch:
"""
Fixed-size 2D counter array for frequency estimation.
ε=0.001, δ=0.01 → w=2718, d=5 → ~54 KB per sketch.
"""
def __init__(self, width: int = 2718, depth: int = 5):
self.w = width
self.d = depth
self.table = [[0] * width for _ in range(depth)]
self._hash_seeds = [i * 2654435761 for i in range(1, depth + 1)]
def _hash(self, item: str, seed: int) -> int:
h = int(hashlib.md5(f"{seed}{item}".encode()).hexdigest(), 16)
return h % self.w
def update(self, item: str, count: int = 1) -> None:
for i, seed in enumerate(self._hash_seeds):
self.table[i][self._hash(item, seed)] += count
def query(self, item: str) -> int:
"""Returns min-over-rows estimate. Always >= true count."""
return min(
self.table[i][self._hash(item, seed)]
for i, seed in enumerate(self._hash_seeds)
)
def merge(self, other: "CountMinSketch") -> None:
"""Merge another sketch (element-wise add). Used for regional rollup."""
assert self.w == other.w and self.d == other.d
for i in range(self.d):
for j in range(self.w):
self.table[i][j] += other.table[i][j]Algorithm 2: Bucketed Sliding Window with CMS
from collections import deque
import time
class SlidingWindowCMS:
"""
60-minute sliding window using 60 × 1-minute CMS buckets.
Old buckets are subtracted (or reset) as the window advances.
"""
def __init__(self, window_minutes: int = 60, bucket_size_s: int = 60):
self.bucket_size_s = bucket_size_s
self.n_buckets = window_minutes
self.buckets: deque[tuple[int, CountMinSketch]] = deque() # (minute_epoch, CMS)
self.current_bucket_ts = None
self.current_cms = None
def _get_bucket_ts(self, ts: float) -> int:
return int(ts // self.bucket_size_s) * self.bucket_size_s
def update(self, term: str, ts: float, count: int = 1) -> None:
bucket_ts = self._get_bucket_ts(ts)
# Rotate to new bucket if minute has advanced
if bucket_ts != self.current_bucket_ts:
if self.current_bucket_ts is not None:
self.buckets.append((self.current_bucket_ts, self.current_cms))
self.current_bucket_ts = bucket_ts
self.current_cms = CountMinSketch()
# Evict buckets outside the window
cutoff = bucket_ts - (self.n_buckets * self.bucket_size_s)
while self.buckets and self.buckets[0][0] < cutoff:
self.buckets.popleft()
self.current_cms.update(term, count)
def query(self, term: str) -> int:
"""Sum estimates across all active buckets."""
total = self.current_cms.query(term) if self.current_cms else 0
for _, cms in self.buckets:
total += cms.query(term)
return totalAlgorithm 3: Space-Saving Top-K
class SpaceSaving:
"""
Metwally et al. Space-Saving algorithm for top-K heavy hitters.
Guarantees: any item with true frequency > N/k is in the result.
Stores exactly k (term, count, error) tuples.
"""
def __init__(self, k: int = 500):
self.k = k
self.counts: dict[str, int] = {} # term → estimated count
self.errors: dict[str, int] = {} # term → max overcount error
def update(self, term: str, count: int = 1) -> None:
if term in self.counts:
self.counts[term] += count
elif len(self.counts) < self.k:
self.counts[term] = count
self.errors[term] = 0
else:
# Evict minimum-count item; new item inherits its count
min_term = min(self.counts, key=self.counts.get)
min_count = self.counts.pop(min_term)
self.errors.pop(min_term)
self.counts[term] = min_count + count
self.errors[term] = min_count # inherited error
def top_k(self, k: int = 30) -> list[tuple[str, int]]:
return sorted(self.counts.items(), key=lambda x: -x[1])[:k]#Design Patterns Used
| Pattern | Where | Why |
|---|---|---|
| Count-Min Sketch | Flink per-region frequency estimation | O(1) memory regardless of unique hashtag count; ~54 KB vs GBs for exact HashMap |
| Sliding Window | 60 × 1-min buckets per region | Smooth trend curve; eliminates tumbling-window boundary resets visible to users |
| Fan-Out | City → Country → Global hierarchical aggregation | Each tweet event fans out to all ancestor woeids; no join needed at query time |
| CQRS | Flink (write/compute) vs Trends API (read from Redis) | Independent scaling; read path is pure Redis, zero Flink involvement |
| Event Sourcing | Kafka tweet-events as immutable log | Replay for debugging trend computation bugs; backfill historical snapshots |
| Circuit Breaker | Trends API → Redis fallback to PostgreSQL snapshot | If Redis unavailable, serve last persisted snapshot (slightly stale but correct) |
| Sharding | PostgreSQL trend_snapshots partitioned by window_end | Range partitions enable fast time-range queries; old partitions drop atomically |
| Bloom Filters | Bot filter: track seen (user_id, hashtag) pairs per window | Prevent single user from contributing more than N times without exact set storage |
#8. Failure Handling & Edge Cases
#What Happens When X Fails?
| Failure | Impact | Mitigation |
|---|---|---|
| Flink job crashes | Trend refresh stops; Redis data goes stale after TTL | Flink checkpointing every 30s; restarts from last checkpoint; Redis TTL=120s gives 2-cycle grace period |
| Redis cluster shard fails | Trend reads fail for ~1/6 of woeids | Redis Cluster auto-failover to replica (< 30s); Trends API falls back to PostgreSQL last snapshot for affected woeids |
| Kafka broker down | Tweet events buffered in producers; Flink pauses | Kafka RF=3; producer retries; Flink resumes from last offset on recovery; max ~30s gap in trend data |
| NLP Phrase Extractor down | Only hashtag trends; no phrase trends | Hashtag trends continue independently; NLP is separate consumer group; alert P1 |
| PostgreSQL primary fails | Snapshot writes fail; history reads on replicas OK | Patroni failover < 30s; Flink snapshot writer buffers to local Flink state; replays on recovery |
| Bot filter model stale | Stale bot scores → some bot events included | Trend manipulation bounded by rate-limit (max 3 contributions/user/window); post-hoc suppression via anomaly detector |
| CDN cache poisoning | Stale or corrupted trend returned globally | CDN TTL=60s self-heals; Trends API endpoint has cache-control: max-age=60; purge API available for P0 incidents |
#Domain-Specific Edge Cases
- Sudden Breaking News Spike (30K TPS): Flink back-pressure from Kafka lag. → Flink auto-scales task slots; Kafka consumer group lag alert fires at 100K messages; trends may lag by 1–2 minutes during extreme spikes — acceptable.
- Coordinated Hashtag Attack: 100K bot accounts all tweet
#FakeNewssimultaneously. → Rate-limit (3 contributions/user/window) caps per-account blast; bot_score filter drops most; post-hoc anomaly detector suppresses term within 5 minutes. - Trending Term with No Baseline: New event, new hashtag — no historical baseline. → Default velocity = count / global_avg_new_term_rate; flagged as "New" in trend metadata; no suppression.
- Same Term, Different Languages:
#Coupe du Mondevs#WorldCupboth trend. → Language field in Kafka payload; Flink routes to lang-specific bucket within same woeid; API returns lang-filtered trends based onAccept-Languageheader. - Region Boundary Tweets: User tweeted while crossing borders mid-session. → woeid assigned at tweet creation from profile home location; not real-time GPS; avoids woeid churn.
- Redis ZSET Score Ties: Multiple terms with identical trend scores. → Tiebreak by alphabetical order (deterministic); client renders in consistent order.
#9. Monitoring & Observability
#Key Metrics
| Metric | Target | Alert Threshold |
|---|---|---|
| Flink job lag (tweet-events) | < 30K msgs | > 100K msgs for 3 min |
| Trend refresh latency (tweet → Redis) | < 60 s P95 | > 90 s for 5 min |
| Trends API P99 latency | < 100 ms | > 250 ms for 3 min |
| Redis hit rate for trends | > 99.9% | < 99% for 2 min |
| CDN cache hit rate (global trends) | > 90% | < 80% for 5 min |
| Top-K accuracy (sampled vs ground truth) | < 5% rank error | > 10% error in hourly check |
| Bot-filtered tweet % | 20–40% (expected range) | < 5% or > 60% (filter malfunction) |
| PostgreSQL snapshot write lag | < 5 s | > 30 s |
#Alerting Strategy
P0 — Page immediately (any time)
- Trend refresh completely stopped (no Redis update for > 3 min for any woeid)
- Trends API fully down (CDN all-miss + origin 5xx rate > 1%)
- Redis cluster QUORUM loss
- Kafka topic replication failure on tweet-events
P1 — Page during business hours
- Flink consumer lag > 100K messages sustained 5 min
- Bot filter % drops below 5% (filter likely broken, all bots passing through)
- Any woeid missing trend data for > 5 min
- NLP Phrase Extractor consumer group dead
P2 — Ticket, no page
- CDN hit rate below 80% (configuration issue or cache busting)
- PostgreSQL snapshot write lag > 30s
- Top-K accuracy degradation detected in hourly offline check
- Baseline Redis keys expiring due to missing nightly batch job run
#SLAs / SLOs
Trend Freshness:
SLO: 95% of trend refresh cycles complete within 60s of window close
SLA: No trend data older than 3 minutes served to users at any time
API Latency:
SLO: P50 < 20ms, P95 < 60ms, P99 < 100ms
SLA: 99.9% of requests within 100ms over 30-day window
Availability:
SLA: 99.99% uptime for Trends API = < 52 min downtime/year
Acceptable degradation: Serve 5-min-old trends during Redis failover (< 60s window)
Accuracy:
SLO: Items with true rank ≤ 30 appear in served top-30 with ≥ 95% probability
(Space-Saving guarantee: any item with freq > N/500 is captured)#10. Trade-off Summary
| Decision | Chose | Over | Because |
|---|---|---|---|
| Count-Min Sketch for frequency | Probabilistic CMS (~54 KB/region) | Exact HashMap (GBs for 200K unique terms) | 200K unique hashtags/hr × 551 regions × 8B = 880 MB exact; CMS gives 54 KB × 551 = 30 MB; acceptable ε=0.1% error |
| Bucketed sliding window (60 × 1 min) | Approximate (±1 min lag) | Exact sliding window (store all events) | Storing all 8.7K hashtag events/s in memory is infeasible; 1-min bucket lag is imperceptible to users |
| Space-Saving top-K (k=500) | Probabilistic top-K | Sort all 200K terms every 60s | Sorting 200K terms every 60s × 551 regions = 110M sorts/min; Space-Saving is O(1) per event, O(k log k) per snapshot |
| Redis ZSET as serving store | Pre-computed in Redis | Query Flink state at read time | Trend reads are 87K QPS; querying Flink state at read time would serialize all reads through Flink — impossible |
| Velocity over raw volume | Trend score = count × velocity | Raw volume ranking | Raw volume favors permanently large topics (e.g., "#COVID"); velocity captures what's newly exciting — aligns with user expectation of "trending" |
| 60s refresh cycle | 60s | Real-time (per-tweet update) | Per-tweet Redis update = 8.7K writes/s to 551 ZSETs = 4.8M Redis ops/s; unnecessary given human perception of trends evolves over minutes |
| Fan-up (city → country → global) | Each tweet emits to all ancestor woeids | Rollup aggregation at query time | Query-time rollup requires fetching all city ZSETs for a country — 500 Redis calls; fan-up pre-aggregates without joins |
| Personalization on top-500 result set | Thin personalization layer | Full personalized ranking from raw events | Per-user Flink state for 350M users is infeasible; top-500 → top-30 rerank is a simple 5ms in-process operation |
#11. Extensions & Follow-ups
#What Would You Add With More Time?
- Real-Time Trend Alerts via SSE: Push trend updates to open web clients via Server-Sent Events (SSE) so the trend panel updates without polling. Implemented via a Pub/Sub channel per woeid.
- Trend Lifecycle Visualization: Track each trend from emergence → peak → decay; expose a trend timeline chart via the history API. Requires storing velocity time-series in addition to volume.
- Multi-Language Phrase Clustering:
#WorldCupand#CopaMundialare the same event. Cluster semantically equivalent terms using multilingual embeddings from a Vector DB; surface as a single unified trend. - A/B Testing Trend Ranking Algorithms: Shadow-rank with alternative velocity functions (log-velocity, burst detection) and measure click-through rate (CTR) on trending topics.
- Trend Influence Attribution: For each trending topic, identify the "seed" tweet or account that first triggered the trend — useful for journalism and attribution.
- Predictive Trend Alerting: Use ARIMA or LSTM on historical trend baselines to predict which topics are likely to trend 10–15 minutes in advance, enabling proactive content moderation.
#How Would This Change at 100x Scale?
- 50B tweets/day (580K TPS): Kafka scales linearly; add more partitions. Flink task slots scale proportionally. Redis and PostgreSQL become the bottlenecks.
- Redis throughput: At 100x tweet volume, CMS update rate to Redis grows → move CMS into Flink local RocksDB state (no remote calls); only the final top-K snapshot is written to Redis every 60s. This is the recommended architecture already; Redis writes remain at 551/min.
- 550K woeids (city-level granularity for all cities worldwide): Redis ZSET count grows to 550K × 29 KB ≈ 16 GB — still manageable. PostgreSQL snapshot storage grows linearly → migrate to columnar store (S3 + Parquet via Iceberg) for historical data beyond 7 days.
- Personalization at 3.5B MAU: Pre-compute top-30 personalized trends for high-DAU user segments (cluster users by interest vector k-means); cache results per cluster. True per-user personalization remains on-demand for logged-in requests.
#12. Cross-References
#Related Topics in This Repo
| Topic | Connection |
|---|---|
| Rate Limiter (#2) | Sliding window algorithm is shared; per-user contribution rate-limiting in Flink |
| Recommendation Engine (#23) | Personalized trend reranking uses user interest vectors; similar to collaborative filtering signals |
| Fraud Detection (#24) | Bot filtering and coordinated inauthentic behavior detection share feature patterns |
| Tinder Feed (#26) | Fan-out pattern for candidate stack pre-computation mirrors trend pre-computation to Redis |
| Web Crawler (#12) | Bloom filter deduplication pattern mirrors per-user tweet-contribution dedup |
#Building Blocks Used
- Kafka — Tweet event bus (200 partitions); trend-output topic; 7-day retention for replay
- Apache Flink — Core streaming job: CMS updates, sliding window buckets, top-K computation, velocity scoring
- Redis — Trend ZSET serving store (top-500 per woeid); metadata hashes; baseline hourly averages
- PostgreSQL — 90-day trend snapshot history; range-partitioned by window_end
- Cassandra — User interest profiles for personalized trend reranking
- CDN — Cache global/country trends for unauthenticated users; TTL=60s; absorbs 70% of reads
- Elasticsearch — Sample tweet lookup by tweet_id for trend metadata (representative tweets)
- Load Balancer — Traffic distribution across Trends API service replicas
#Concepts Used
- Count-Min Sketch — Per-region hashtag frequency estimation; 54 KB vs GBs for exact counting
- Sliding Window — 60 × 1-min bucketed window for smooth trend curve without tumbling-window resets
- Fan-Out — Each tweet fans up to city → country → global woeids; pre-aggregates without query-time rollup
- CQRS — Flink handles write/compute; Trends API reads only from Redis; fully separate scaling
- Event Sourcing — Kafka as immutable tweet event log; replay enables backfill of trend snapshots
- Sharding — PostgreSQL trend_snapshots range-partitioned by window_end; Flink tasks partitioned by woeid
- Consistency Models — Eventual for Redis trend state; strong for PostgreSQL historical snapshots
- Circuit Breaker — Trends API falls back to PostgreSQL last snapshot if Redis is unavailable
- Bloom Filters — Per-user per-window contribution dedup to prevent single-account trend manipulation