Lesson 7 of 8 · 55 min

Worked designs — rate limiter + search

Distributed token buckets without global locks, enforcement layers, inverted indexes, typeahead, async freshness, and ranking altitude.

Lesson 7 · Component designs

Rate limiter + search

Two deep-dives that show up everywhere

Interviewers love rate limiter and search either as full prompts or as forced deep-dives inside larger designs. Master both as portable components: token bucket in a distributed setting without a global lock, and inverted-index search with freshness and ranking altitude. Stripe’s rate-limiter writing (reported classic) and Cloudflare’s “counting things” post (reported) are the production anchors for limits; Brin & Page 1998 and modern ES/Lucene mental models anchor search. Shadow-test new rankers or limit configs on a percentage of traffic with kill switches. A bad limiter config is an instant brownout; a bad ranker is a slow quality regression — different rollback urgencies, same need for staged delivery.
You will reuse rate limits on every public API (lessons 4–6, 8) and search on feeds, chat history, marketplaces, and SaaS. Treat this lesson as installing two micro-frameworks in your head. Slack’s search architecture posts (classic) show hybrid inverted index + personalization — ranking is never “only BM25” at product scale, but BM25 is still the right interview baseline. Part A — Rate limiting algorithms. Fixed window: count requests in [0,60s). Simple; allows 2× burst at boundaries. Sliding window log: store timestamps; accurate; memory-heavy. Sliding window counter: approximate with weighted previous window. Token bucket: tokens refill at rate r, burst capacity b — industry default for API limits (Stripe-style). Leaky bucket: smooths egress to constant rate (edge traffic shaping). Pick by whether you need burst tolerance, strict smoothing, or billing-grade accuracy.
python Rate limit dimensions to list quickly: IP, user id, API key, tenant, route cost class, and sometimes device id for mobile. Search dimensions: tenant shard, language, index freshness generation, and cache key for normalized query text. Mentally separate control plane (limit configs, allowlists) from data plane (atomic counters, segment readers). Config changes should canary; a bad limit deploy can either melt you (limits too high) or outage you (limits too low) — treat limiter config like production code.
1import time23class TokenBucket:4	def __init__(self, rate_per_s: float, burst: float):5		self.rate = rate_per_s6		self.burst = burst7		self.tokens = burst8		self.t = time.monotonic()910	def allow(self, n: float = 1.0) -> bool:11		now = time.monotonic()12		self.tokens = min(self.burst, self.tokens + (now - self.t) * self.rate)13		self.t = now14		if self.tokens >= n:15			self.tokens -= n16			return True17		return False1819# 100 req/s sustained, burst 20020bucket = TokenBucket(100, 200)2122ALGORITHM TRADEOFF23Algorithm              Pros                    Cons                     Pick when24---------------------  ----------------------  -----------------------  ---------------------25Fixed window           trivial, 1 counter      2× boundary burst        low-stakes / debug26Sliding log            exact                   O(n) memory/key          strict low-QPS quotas27Sliding window counter good approx, O(1) mem   edge error               general API limits28Token bucket           burst + sustained       two params               API gateways default29Leaky bucket           smooth egress           adds latency             outbound shaping

Distributed rate limiting without a global lock

Single-node bucket fails when many API nodes exist. Options: 1) Centralized Redis with atomic Lua/INCR + TTL (common; Stripe-style atomicity story). 2) Local buckets + periodic reconcile (approximate, faster, can overshoot). 3) Consistent-hash users to limiter shards (each key has an owner). 4) Quotas service for multi-tenant billing-grade accuracy. Cloudflare-scale counting (reported) uses careful data structures for huge cardinality at the edge. Avoid: naive read-modify-write without atomicity; global mutex across the fleet.
code
1-- Redis token bucket sketch (atomic via Lua in prod)2-- KEYS[1] = rl:{user}3-- refill based on rate; compare tokens; decr4-- Alternative simple fixed window:5INCR rl:user:123:20260715T12006EXPIRE rl:user:123:20260715T1200 607-- if count > LIMIT → 42989# Placement: edge (IP, coarse) → gateway (API key) → service (expensive ops)10# Return headers: Retry-After, X-RateLimit-Remaining when product allows11# Fail open vs fail closed if Redis is down — pick by abuse risk vs availability

Where to enforce (layered)

  1. 01Edge/WAF: IP and bot floods.
  2. 02API gateway: per-user/token policy.
  3. 03Service: protect expensive endpoints (search, export, fan-out).
  4. 04Downstream bulkheads: per-dependency limits so one client cannot melt Redis.
  5. 05Cost-based limits: expensive ops spend more tokens than cheap GETs.
  6. 06Nested buckets: per-user AND per-tenant must both allow.

Part B — Search with an inverted index

Core structure: for each token/term, a posting list of document IDs (plus positions/frequencies for phrase/rank). Query: tokenize → lookup lists → intersect/union → rank → page. System of record remains OLTP; search index is derived via async indexing (queue or CDC). Brin & Page’s 1998 anatomy (crawler → repository → indexer → PageRank → serving) still frames the pipeline even when your “web” is a product catalog.
python
1# Tiny inverted index sketch (interview-scale)2from collections import defaultdict34class InvertedIndex:5	def __init__(self):6		self.postings = defaultdict(set)  # term -> doc_ids7		self.docs = {}89	def add(self, doc_id, text):10		self.docs[doc_id] = text11		for term in tokenize(text):12			self.postings[term].add(doc_id)1314	def search(self, query):15		terms = tokenize(query)16		if not terms:17			return []18		sets = [self.postings[t] for t in terms]19		hits = set.intersection(*sets) if sets else set()20		return rank(hits, query, self.docs)2122def tokenize(text):23	return [t for t in text.lower().split() if t]

Index build, freshness, sharding

Write path: commit doc to DB → enqueue IndexDoc job → analyzer → update inverted index segments. Freshness SLO: seconds vs minutes. Near-real-time search uses small in-memory segments flushed periodically (Lucene/Elasticsearch mental model: immutable segments + merges). Deletes/updates: tombstones + eventual merge. Shard by doc_id hash or tenant_id; query fans out → coordinator merges top-k. Per-shard size sweet spot often tens of GB class (heuristic) before merge/query cost hurts. Stale index behavior: search may miss brand-new docs or show deleted ones briefly — state that explicitly and filter against source of truth on hydrate when correctness matters.
code
1RANKING SIGNALS (altitude)2Signal                 Type           Cost        Staleness OK?3---------------------  -------------  ----------  ---------------4BM25 / lexical         static         low         hours5Freshness              time           medium      minutes6Popularity / CTR       behavioral     high        hours7Personalization        behavioral     high        days8Embedding similarity   ML inference   very high   days910# Classic SWE: BM25 + business features. Deep LTR/embeddings → ml-system-design companion.

Typeahead / suggest (full mini design)

Requirements: prefix p (1–20 chars) → top-10 completions, p99 < 100 ms. Capacity heuristic: 100M DAU × 5 searches/day → ~500M/day → ~6k QPS avg → ~30k peak. Components: in-memory trie/FST per shard keyed by first characters; logs of past queries rebuild popularity nightly; ranker blends popularity + recency + light personalization; L1 hot-prefix cache (top 10k prefixes) + L2 Redis. Hot prefix “a” can be 30% of traffic — replicate hot set, do not let one shard melt. Spelling: edit distance ≤2 suggestions. Cold start: promote new queries after frequency threshold.
python
1# Typeahead capacity → design (heuristic)2peak_qps = 30_0003# trie ~20–50 GB compressed class for large corpora → few boxes + replicas4# budget: ~5ms trie + ~5ms rank + network headroom inside 100ms p995# DECISIONS: in-memory structure, hot-prefix cache >95% hit, nightly rebuild + incremental counts6GET /suggest?q=pre&limit=10 → [{q, score}]
  1. 01Where enforce limits? → edge coarse, gateway policy, service expensive ops.
  2. 02Distributed limits? → Redis atomic / sharded owners / local approx with overshoot stated.
  3. 03Fixed window vs token bucket? → burst tolerance → token bucket; simple debug → fixed.
  4. 04Redis down? → fail open (avail) or closed (abuse) by product risk.
  5. 05Per-user and per-tenant? → nested buckets; both must allow.
  6. 06Why Lua? → atomic check-and-decrement; no lost tokens under race.
  7. 07Cost-based limiting? → exports spend more tokens than GETs.
  8. 08Client headers? → Remaining / Reset / Retry-After.
  9. 09Shard search index? → by doc or tenant; scatter-gather top-k.
  10. 10Doc updates in inverted index? → new segment / tombstone old; eventual merge.
  11. 11Typeahead personalization? → blend only above confidence; else global.
  12. 12Measure quality? → NDCG/MRR/CTR offline + online interleaving.
  13. 13200ms-old write missing? → derived index freshness SLO, not always a bug.
  14. 14Deleted doc still appears? → hydrate filter + async purge postings.
articleStripe — Scaling your API with rate limitersStripearticleCloudflare — Counting things, a lot of different thingsCloudflarearticleByteByteGo — Design a Rate LimiterByteByteGoarticleDiscord — How Discord Indexes Trillions of MessagesDiscord EngineeringdocsElasticsearch — documents and indicesElastic

Rate limit headers and client UX

Communicate limits with Retry-After, remaining quota, and reset time when the product is a public API. Clients should backoff; SDKs should jitter. For browser apps, map 429 to user-visible “slow down” only when it is a user action, not for background polling (fix the poller). Document cost-based limits so a single export cannot surprise a customer who thought they had 1000 “requests.”

Testing rate limiters

Unit test with injected clocks (monotonic time fake). Deterministic replay of request timestamps. Chaos: kill Redis and assert fail-open or fail-closed matches config. Load test boundary of fixed windows to show 2× burst if someone proposes fixed window for money-critical quotas.

Search quality and spam

Offline metrics: NDCG, MRR on labeled sets. Online: CTR, reformulation rate, dwell. Spam/SEO abuse: authority features, rate limits on indexable publishes, human review queues for edge cases. Multi-lingual: language detection + per-language analyzers; do not assume whitespace tokenization works for all languages. When ML re-rankers appear, keep them as a second stage over a lexical candidate set unless you are in an ML SD round.
code
1SEARCH PIPELINE (interview board)2OLTP write → CDC/queue → analyzer → inverted index segments3Query → tokenize → retrieve postings → BM25 → (optional LTR) → hydrate SoT4Typeahead → trie/FST → popularity rank → cache56Freshness SLO example: 99% of docs searchable within 30s of commit7Delete SLO: deleted docs not returned after 60s (hydrate filter sooner)
Combined prompt pattern: “Design Twitter search with rate limits.” Compose: ingest tweets to index async; typeahead on query logs; rate limit search QPS per user/IP; cache hot queries; celebrity tweets still a hot document in the index (cache the tweet body on hydrate). You are stitching L5+L7 deliberately.

Cardinality at the limiter

Millions of API keys need careful key design: rl:{customer}:{window} with TTLs so Redis memory stays bounded. Cloudflare-scale edge limiting (reported) leans on probabilistic structures when exact per-key state cannot fit. In interview, say when exact counters become impossible and approximate is OK for abuse defense.

Checkpoint

You need a distributed per-user limit across 50 API nodes without a fleet-wide mutex. Best approach?

AGlobal lock on a single leader process for every requestBAtomic counters/token bucket in Redis (or sharded limiter service) keyed by user; optional local approx for soft limitsCEach node allows full limit independently always
Sign up free to answer and see why

Checkpoint

A document was written 200ms ago; search does not find it. Is the system wrong?

AAlways — search must be strongly consistent with primaryBNot necessarily — derived inverted indexes are often eventually consistent; state freshness SLO and async indexingCYes — switch to LIKE queries on primary forever
Sign up free to answer and see why

Checkpoint

Token bucket vs fixed window — when is token bucket worth it?

ANever; fixed window is always identicalBWhen you want sustained rate plus controlled burst without fixed-window boundary doublingCOnly for video streaming CDN bits
Sign up free to answer and see why

Checkpoint

Search index returns a deleted doc. Best handling?

ACrash the clusterBHydrate/filter against source of truth (or tombstone bit); async purge postings; accept brief stalenessCNever delete documents
Sign up free to answer and see why

Checkpoint

Where should you rate limit a costly “export all messages” API?

AOnly at the CDNBGateway per-user/tenant + service-level concurrency limit on the export workersCNo limit if the user is logged in
Sign up free to answer and see why

End-to-end narration scripts (two 8-minute deep dives)

Rate limiter: requirements (per-user, burst, distributed) → token bucket default → Redis Lua atomicity → placement layers → fail open/closed → headers → nested tenant buckets. Search: derived index → analyzer → postings → BM25 → hydrate SoT → freshness SLO → typeahead trie + hot prefix cache. Combined: rate limit the search API, cache hot queries, never LIKE the primary for free text.
code
1RATE LIMIT DECISION LOG2Need                         Pick3---------------------------  ---------------------------4Burst + sustained            token bucket5Simple debug quota           fixed window (know 2× edge)6Exact low-QPS                sliding log7Multi-node hard quota        Redis/atomic or owner shard8Abuse-only soft limit        local approx OK9Export expensive             cost-based tokens1011SEARCH DECISION LOG12Need                         Pick13---------------------------  ---------------------------14Free text retrieval          inverted index derived15Freshness seconds            NRT segments + CDC16Prefix suggest <100ms        in-memory FST/trie + cache17Deletes                      tombstone + hydrate filter18ML re-rank                   second stage / ML track1920Reported anchors: Stripe rate limiters; Cloudflare counting; Slack search hybrid.
Portable senior move inside larger designs: spend three minutes placing a token bucket at the gateway for expensive GETs, and one minute declaring search freshness. Interviewers hear that you will not melt the core service and will not lie about index consistency. That is often worth more than an extra box on the diagram.

Rate limit + search decision cards

code
1RATE LIMIT PICKER2burst+sustained → token bucket (default API)3simple/debug → fixed window (warn 2x boundary)4exact low QPS → sliding log5multi-node hard quota → Redis Lua / owner shard6abuse soft → local approximate OK7expensive export → cost-based tokens8Redis down → fail open (avail) vs closed (abuse) by risk910PLACEMENT11edge IP/bot → gateway user/key → service expensive op → dependency bulkhead12headers: Retry-After, remaining, reset1314SEARCH PIPELINE15OLTP commit → CDC/queue → analyze → inverted segments → serve16query: tokenize → postings → BM25 → optional LTR → hydrate SoT17freshness SLO e.g. 30s searchable; deletes filtered on hydrate sooner1819TYPEAHEAD2030k QPS peak class → in-memory trie/FST + hot prefix cache21hot prefix 'a' replicated; nightly rebuild + incremental counts22p99 budget <100ms: few ms lookup + rank + network2324REPORTED ANCHORS25Stripe rate limiters; Cloudflare counting at edge;26Slack hybrid search; Lucene/ES segment mental model2728COMPOSE WITH OTHER DESIGNS29feed/chat/marketplace all need limits on expensive reads30search never replaces SoT; never LIKE% primary at scale

Can you whiteboard a Redis-backed token bucket and an async inverted-index search path with freshness caveats?

New to itGetting thereConfident

Takeaways

  • Token bucket = sustained + burst; fixed window is simpler but boundary-bursty.
  • Distributed limits: atomic store / sharded owners — not global locks.
  • Enforce at multiple layers with different goals.
  • Search is a derived inverted index; freshness is an SLO.
  • Typeahead is in-memory prefix structures + hot-prefix cache under 100ms p99.

Next: capstone marketplace/dispatch design + mock rubric — and when to switch to the ML/LLM system design companion track.

Sources

Free to read · better with Enzo

Learn it with Enzo

Save your progress, answer the checkpoints, and let Enzo quiz you on what you just read.