Design Problems Interview Questions and Answers
17 hand-picked Design Problems interview questions with
detailed answers. Open the interactive version above to search, filter
by difficulty, run code, bookmark questions and track your progress.
Design a URL shortener (like bit.ly).
Functional: shorten long URL → short code; redirect short → long; optional custom alias, expiry, click analytics.
High-level: API servers → cache (Redis) → DB (SQL or NoSQL). Load balancer in front. CDN optional for redirect edge caching.
ID generation: base62 encode auto-increment ID, or hash (MD5 truncated — collision risk). Store mapping short_code → long_url.
Redirect flow: GET /{code} → cache lookup → 301/302 redirect. Read-heavy — cache aggressively.
Design a notification system (push, email, SMS).
Functional: send notifications via push/email/SMS; support templates, user preferences, scheduling, delivery status.
Architecture: API receives send request → validate → publish to message queue → channel-specific workers (email worker, push worker, SMS worker) → third-party providers (SendGrid, FCM, Twilio).
Store notification history and user preferences. Use priority queues for OTP vs marketing. Retry with backoff; dead-letter queue for failures.
Design a chat/messaging system (1:1 and group).
Functional: 1:1 and group chat, message history, online status, read receipts, media attachments.
Real-time: WebSockets (or long polling fallback) between client and chat servers. Use a gateway layer for persistent connections.
Storage: messages in NoSQL/Cassandra (write-heavy, time-ordered) or SQL sharded by conversation_id. Media in object storage (S3).
Delivery: publish message to queue → fan-out to recipient's connected server via pub/sub (Redis). Offline users fetch history on reconnect.
Design an e-commerce shopping cart.
Functional: add/remove items, update quantity, view cart, merge guest → logged-in cart, checkout handoff.
Storage: cart data in Redis (fast, TTL for abandoned carts) keyed by user_id or session_id. Persist to DB on login or checkout.
Inventory: don't hard-reserve on add-to-cart — show availability from inventory service. Reserve stock at checkout with a short TTL lock.
Consistency: cart is per-user/session; eventual consistency OK. Price snapshot at checkout, not on every cart view.
Design a social media news feed (Twitter / Instagram).
Functional: post content; follow users; view a home feed of posts from people you follow, newest first; like and comment.
The one decision that defines this design: fanout on write vs fanout on read.
- Fanout on write (push) — when you post, immediately write the post ID into every follower's precomputed feed list (in Redis). Reading a feed is then a single cheap list read. Feeds load instantly. The problem: a celebrity with 50M followers triggers 50M writes for one post.
- Fanout on read (pull) — store posts once; when a user opens the app, query all the people they follow and merge the results. Cheap writes, but expensive reads on every single feed open.
The real answer is hybrid: fanout on write for normal users (the vast majority, and it makes reads instant), and fanout on read for celebrities above a follower threshold — their posts are merged in at read time. This is what production systems actually do.
Architecture: Post service → Kafka → fanout workers → Redis feed lists per user. Read path: feed service pulls post IDs from Redis, hydrates post content from the post store/cache, merges in celebrity posts, applies ranking, returns a page.
# Fanout on write — precomputed feed per user in Redis
ZADD feed:{userId} {timestamp} {postId} # sorted set, newest first
ZREVRANGE feed:{userId} 0 49 # read page 1 = one O(log N) call
ZREMRANGEBYRANK feed:{userId} 0 -1001 # cap at 1000 entries
# Hybrid read path
feed = ZREVRANGE feed:{userId} 0 49 # precomputed (normal users)
celebs = getCelebritiesFollowed(userId) # small list
extra = fetchRecentPosts(celebs, since=cursor) # pulled at read time
return rank(merge(feed, extra))[:50]
Design a distributed rate limiter.
Requirements: enforce "N requests per window" per user/API key/IP, across many API servers, with minimal added latency, failing gracefully.
The algorithms, and their trade-offs:
- Fixed window counter — a counter per key per minute. Trivial and cheap, but has a boundary burst flaw: 100 requests at 11:59:59 and 100 more at 12:00:00 means 200 requests in one second while technically respecting "100/minute".
- Sliding window log — store a timestamp per request in a sorted set and count entries in the window. Perfectly accurate, but memory grows with request volume.
- Sliding window counter — weight the previous window's count by how much of it overlaps. Nearly as accurate as the log at fixed memory cost. The usual production choice.
- Token bucket — tokens refill at a constant rate up to a capacity; each request takes one. Allows controlled bursts, which is usually what you actually want, and it's what most API gateways implement.
- Leaky bucket — processes at a fixed rate, smoothing output completely. Good for protecting a downstream with a hard throughput ceiling.
Distributed state is the hard part: counters must be shared across all API instances, so they live in Redis, updated with an atomic Lua script (read-modify-write in application code races). Add a small local in-process cache for obviously-over-limit keys to cut Redis calls.
-- Token bucket in a single atomic Redis Lua script
-- KEYS[1]=bucket key ARGV: capacity, refillRate, now, requested
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or tonumber(ARGV[1])
local ts = tonumber(b[2]) or tonumber(ARGV[3])
local elapsed = math.max(0, tonumber(ARGV[3]) - ts)
tokens = math.min(tonumber(ARGV[1]), tokens + elapsed * tonumber(ARGV[2]))
if tokens < tonumber(ARGV[4]) then
return {0, tokens} -- denied → 429
end
tokens = tokens - tonumber(ARGV[4])
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[3])
redis.call('EXPIRE', KEYS[1], 3600)
return {1, tokens} -- allowed
Design a video streaming platform (YouTube / Netflix).
Split it in two — the upload/processing pipeline and the playback path. They have completely different characteristics.
Upload & processing (write path, batch, expensive):
- Client uploads directly to object storage via presigned multipart URLs — never through your API servers.
- Upload completion fires an event onto a queue.
- A transcoding pipeline splits the video into chunks and converts each into multiple resolutions and bitrates (240p → 4K) in parallel across a worker fleet. Chunk-level parallelism is what makes a 2-hour film transcode in minutes.
- Generate thumbnails, extract metadata, run content moderation, produce subtitles.
- Write the manifest (HLS
.m3u8 or DASH .mpd) listing every rendition and segment, then publish to the CDN.
Playback (read path, latency-critical, massive scale): the player fetches the manifest, then requests 2–10 second segments over HTTP from the CDN. Adaptive bitrate streaming means the player measures throughput and buffer health and switches rendition per segment — that's why quality drops instead of stalling when your network degrades.
The CDN is the entire scaling story. Video bytes must be served from edge caches close to users; origin only ever sees cache misses. Netflix goes further and places its own appliances inside ISPs.
# HLS manifest — the player picks a rendition, then pulls segments
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
360p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720
720p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080
1080p/index.m3u8
# within 720p/index.m3u8 — segments the player fetches sequentially
#EXTINF:6.0,
seg_00001.ts
#EXTINF:6.0,
seg_00002.ts
# transcoding fanout: chunk-level parallelism
video → split into 6s chunks → N workers × M renditions → reassemble manifests
Design a ride-hailing service (Uber / Ola) — the matching problem.
Core challenge: millions of drivers broadcasting location every few seconds, and riders needing "find me the nearest available drivers" answered in milliseconds. A naive SELECT ... WHERE distance < 5km over every driver is a full scan — completely unworkable.
The key idea is spatial indexing. Divide the world into cells and index drivers by cell ID, turning a geometric search into a hash lookup:
- Geohash — encodes lat/long into a string where a shared prefix means spatial proximity. Search = prefix match on the target cell plus its 8 neighbours (you must include neighbours, or you miss drivers just across a cell boundary).
- Uber's H3 — hexagonal cells; hexagons have uniform distance to all 6 neighbours, unlike squares where diagonal neighbours are farther. Better for radius queries.
- Redis GEO commands (built on geohash + sorted sets) give you
GEOADD/GEOSEARCH out of the box — a pragmatic production answer.
Two separate write paths: driver location updates are extremely high-volume but ephemeral — they go to Redis (in-memory, TTL'd), not to a durable database. Trip state (requested → matched → started → completed → paid) is low-volume but must be durable and transactional.
Matching: find candidate drivers in nearby cells → filter by availability, vehicle type, rating → rank by ETA (road-network time, not straight-line distance) → offer to the best driver with a short timeout → fall through to the next on decline.
# Driver location: high-volume, ephemeral → Redis GEO
GEOADD drivers:available 72.8777 19.0760 "driver_8821"
EXPIRE drivers:available 30 # stale drivers fall out
# Rider request: nearest available drivers within 3 km
GEOSEARCH drivers:available
FROMLONLAT 72.8800 19.0750
BYRADIUS 3 km ASC COUNT 20 WITHDIST
# Geohash: shared prefix = spatial proximity
# te7ud ≈ 5 km cell | te7udd ≈ 1.2 km cell | te7uddq ≈ 150 m cell
# ALWAYS query the target cell + its 8 neighbours (boundary problem)
# Trip state: low-volume, durable, transactional → Postgres
UPDATE trips SET status='MATCHED', driver_id=:d
WHERE trip_id=:t AND status='REQUESTED'; -- optimistic, prevents double-match
Design a payment system. How do you guarantee you never double-charge?
Payments are where correctness beats availability. Two absolute rules: never double-charge, and never lose money — the books must always balance.
Idempotency is the core mechanism. The client generates an Idempotency-Key for each payment intent. The server stores it with the result in a table with a unique constraint. A retry with the same key returns the original stored response instead of charging again. Retries are inevitable — the client times out, the network drops, the user double-taps — so the design must assume duplicates rather than hope they don't happen.
Double-entry ledger — model money movement as immutable ledger entries where every transaction has balanced debits and credits summing to zero. Never UPDATE balance SET ...; balance is derived from the ledger. This gives you a complete audit trail and makes reconciliation possible — you can always prove where money went.
State machine: INITIATED → AUTHORIZED → CAPTURED → SETTLED, with FAILED and REFUNDED branches. Transitions are explicit and only ever move forward — never mutate a payment record in place.
The unavoidable reality: the payment gateway is an external system you can't transact with atomically. If your DB commit succeeds but the gateway call times out, you don't know whether the charge happened. Resolve it with the gateway's own idempotency key plus a reconciliation job that compares your ledger against the provider's settlement file daily and flags every mismatch.
-- Idempotency: the unique constraint IS the guarantee
CREATE TABLE payment_requests (
idempotency_key TEXT PRIMARY KEY, -- duplicate insert → conflict
user_id TEXT NOT NULL,
amount_minor BIGINT NOT NULL, -- integer paise/cents, NEVER float
currency CHAR(3) NOT NULL,
status TEXT NOT NULL,
response_body JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Retry-safe: second call returns the stored response, no second charge
INSERT INTO payment_requests (idempotency_key, user_id, amount_minor, currency, status)
VALUES (:key, :user, :amount, 'INR', 'INITIATED')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING *;
-- Double-entry ledger: every transaction sums to ZERO
CREATE TABLE ledger_entries (
entry_id BIGSERIAL PRIMARY KEY,
txn_id UUID NOT NULL,
account_id TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('DEBIT','CREDIT')),
amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
created_at TIMESTAMPTZ DEFAULT now()
);
-- invariant, assert continuously:
-- SELECT txn_id FROM ledger_entries GROUP BY txn_id
-- HAVING SUM(CASE WHEN direction='DEBIT' THEN amount_minor ELSE -amount_minor END) <> 0;
Design a ticket booking system (BookMyShow / IRCTC). How do you prevent overselling?
The whole problem is concurrency on scarce inventory: 50,000 people trying to book 100 seats in the same second, and you must sell each seat exactly once.
The two-phase model — hold then confirm:
- Hold/lock — when a user selects seats, atomically mark them
HELD with a short expiry (5–10 minutes) before sending them to payment. This is what stops two users paying for the same seat. - Confirm — on successful payment, transition
HELD → BOOKED. - Expire — a background job (or Redis TTL) releases holds that were never confirmed, returning seats to inventory.
Preventing overselling — the mechanisms:
- Conditional update (optimistic) —
UPDATE seats SET status='HELD' WHERE seat_id=? AND status='AVAILABLE'. If it updates 0 rows, someone else won. Simple, no lock held, and the database enforces correctness. - Pessimistic lock —
SELECT ... FOR UPDATE for multi-seat atomicity (all seats or none). Correct, but locks reduce throughput under heavy contention. - Unique constraint on
(event_id, seat_id) in the bookings table — the ultimate backstop; even if application logic is wrong, the database refuses a duplicate.
Never check-then-write in application code (if (available) { book() }) — that's a textbook race condition. The atomicity must live in the database or in an atomic Redis operation.
-- Optimistic hold: atomic, no lock held, DB enforces correctness
UPDATE seats
SET status = 'HELD', held_by = :userId, held_until = now() + interval '8 minutes'
WHERE seat_id = ANY(:seatIds)
AND show_id = :showId
AND (status = 'AVAILABLE'
OR (status = 'HELD' AND held_until < now())); -- reclaim expired holds
-- rows_updated < seatIds.length → someone else got one → fail the whole request
-- Multi-seat atomicity, deterministic order to avoid deadlocks
BEGIN;
SELECT * FROM seats
WHERE seat_id = ANY(:seatIds) AND show_id = :showId
ORDER BY seat_id -- consistent order prevents deadlock
FOR UPDATE;
-- verify all AVAILABLE, then update
COMMIT;
-- Final backstop: the DB cannot be talked into overselling
ALTER TABLE bookings ADD CONSTRAINT uq_seat_per_show UNIQUE (show_id, seat_id);
-- General admission (no seat map): atomic counter
DECRBY tickets:show_991:remaining 2 -- Redis; if result < 0, roll back
Design a file storage and sync service (Dropbox / Google Drive).
Functional: upload/download files, sync across a user's devices, share with others, version history, offline edits.
The central technique is chunking with content-addressed storage. Split each file into fixed or content-defined blocks (~4 MB), hash each block (SHA-256), and store blocks in object storage keyed by their hash. Metadata records the ordered list of block hashes that make up a file version. This one decision gives you four things at once:
- Delta sync — editing one paragraph of a 500 MB file re-uploads only the changed blocks, not the file.
- Deduplication — identical blocks are stored once, globally. The same PDF shared by 10,000 users costs one copy.
- Resumable uploads — a failed transfer resumes at the block level.
- Cheap versioning — a new version is just a new block list, sharing unchanged blocks with the old one.
Split metadata from data. The metadata service (files, versions, block lists, permissions, sharing) is a small, transactional, strongly consistent database. The block store is object storage — huge, immutable, and dumb. They scale completely differently.
Sync: each device holds a cursor/version vector. On change, the client uploads new blocks, commits a new version in metadata, and the server notifies other devices (long-poll or WebSocket). Those devices diff their local block list against the server's and download only what's missing.
# A file version is just an ordered list of block hashes
file: report.pdf v7
blocks: [
"sha256:a3f9...", # unchanged from v6 → not re-uploaded
"sha256:c71b...", # unchanged
"sha256:9e04...", # CHANGED → only this block uploads
"sha256:2d88..." # unchanged
]
# Upload negotiation: ask what's missing before sending anything
POST /v1/blocks/check { hashes: [...] }
→ { missing: ["sha256:9e04..."] } # dedupe across ALL users
PUT /v1/blocks/sha256:9e04... # upload only the missing block
POST /v1/files/{id}/commit { version: 7, blocks: [...] }
# Sync: client polls/subscribes with its cursor
GET /v1/delta?cursor=8821
→ { changes: [{ path, versionId, blocks[], deleted }], cursor: 8905 }
Design a web crawler.
Functional: start from seed URLs, fetch pages, extract links, follow them, store content — at billions of pages, without being blocked or trapped.
The loop: URL frontier → fetcher → parser → link extractor → dedupe → back into the frontier. The interesting engineering is in everything that makes that loop not break.
- URL frontier — a prioritised queue, but with a hard constraint: politeness. You must not hammer one domain with 1,000 parallel requests. Standard design is per-host queues with a rate limit per host, plus priority queues so important pages are crawled more often.
- Deduplication — at billions of URLs, a hash set doesn't fit in memory. Use a Bloom filter ("definitely not seen" is free) backed by a persistent store for verification.
- Content dedupe — the same content appears at many URLs. Hash the normalised content, or use SimHash for near-duplicate detection.
- Crawler traps — infinite calendars, session IDs in URLs, and dynamically generated infinite depth. Defend with max depth, per-domain page caps, URL normalisation, and pattern detection.
- robots.txt and crawl-delay must be respected and cached per host.
Architecture: stateless fetcher workers pulling from a distributed queue (Kafka), with the frontier and seen-set in a shared store. Fetchers are I/O-bound, so use async I/O with high concurrency rather than thread-per-request.
# Frontier: per-host queues enforce politeness, priority drives order
frontier = {
"example.com": { queue: [...], nextFetchAt: t+1.0s, crawlDelay: 1.0 },
"wikipedia.org":{ queue: [...], nextFetchAt: t+0.5s, crawlDelay: 0.5 }
}
# Worker loop
while true:
host = pickHostWhereNextFetchAt <= now() # ready + highest priority
url = frontier[host].queue.pop()
if not robots.allows(host, url): continue
page = fetch(url, timeout=10s) # async I/O, not a thread each
frontier[host].nextFetchAt = now() + crawlDelay
if seen.mightContain(contentHash(page)): continue # Bloom filter
store(page)
for link in extractLinks(page):
link = normalise(link) # strip fragments, sort params,
if depth(link) > MAX_DEPTH: continue # lowercase host, drop session IDs
if not urlSeen.mightContain(link):
frontier[hostOf(link)].queue.push(link)
# Normalisation matters — these are ONE page:
# http://Example.com/a?b=1&c=2#frag
# http://example.com/a?c=2&b=1
Design a distributed cache (like Redis / Memcached).
Requirements: sub-millisecond GET/SET, scale beyond one machine's memory, survive node failure, and evict sensibly when full.
Data distribution — consistent hashing with virtual nodes, so adding or removing a node remaps only K/N keys instead of invalidating the entire cache. Clients can compute the target node themselves (client-side sharding, no extra hop) or go through a proxy (simpler clients, one more hop).
Eviction policy — the cache is bounded, so something must go when it's full:
- LRU — evict least recently used. Good general default; implemented as a hash map + doubly linked list for O(1) get and put.
- LFU — evict least frequently used. Better when a stable hot set exists; resists a one-off scan flushing your working set.
- TTL-based — expire by age. Usually combined with LRU.
- Random — surprisingly acceptable and very cheap.
Replication & failure — each shard has a primary and one or more replicas. Async replication keeps writes fast but can lose recent writes on failover; that's usually acceptable for a cache, since the source of truth is the database. Failover is automatic via a monitor (Redis Sentinel or Cluster).
Memory management — the real engineering. Slab allocation (Memcached) avoids fragmentation; expiry is done lazily on access plus a background sampler, because actively scanning every key for expiry would be O(n) and destroy latency.
// O(1) LRU: hash map for lookup + doubly linked list for recency order
class LRUCache {
constructor(capacity) {
this.cap = capacity;
this.map = new Map(); // JS Map preserves insertion order
}
get(key) {
if (!this.map.has(key)) return null;
const val = this.map.get(key);
this.map.delete(key);
this.map.set(key, val); // re-insert → now most recently used
return val;
}
put(key, val) {
if (this.map.has(key)) this.map.delete(key);
else if (this.map.size >= this.cap) {
this.map.delete(this.map.keys().next().value); // evict LRU (oldest)
}
this.map.set(key, val);
}
}
// Placement: consistent hashing ring with virtual nodes
node = ring.ceilingEntry(hash(key)) ?? ring.firstEntry();
Design a distributed job scheduler (cron at scale).
Functional: schedule jobs (one-off at a time, or recurring by cron expression), execute them reliably across a worker fleet, retry failures, and never run a job twice when you said once.
Two-layer design — separate deciding what should run from running it:
- Scheduler — periodically scans for jobs whose
next_run_at has passed and enqueues them. Must be highly available but must not double-enqueue. - Workers — stateless consumers pulling from the queue, executing, and reporting status. Scale horizontally with load.
The hard problems:
- Don't run twice. If two scheduler instances scan simultaneously, both enqueue the same job. Fix with leader election (only the leader schedules — simple, but the leader is a bottleneck) or, better, an atomic conditional claim:
UPDATE ... SET status='CLAIMED' WHERE status='PENDING' AND next_run_at <= now(), letting the database arbitrate. - Don't lose jobs. A worker that dies mid-execution must not silently drop the job. Use visibility timeouts (SQS-style) or a heartbeat + lease, so an unacknowledged job returns to the queue.
- At-least-once is the realistic guarantee, so jobs must be idempotent. Exactly-once across a network and a crash boundary isn't achievable; idempotency is how you make at-least-once behave like exactly-once.
- Scanning at scale —
SELECT * WHERE next_run_at <= now() over 100M rows every second doesn't work. Use a time-bucketed index or a priority queue keyed by execution time (Redis sorted set scored by timestamp).
-- Atomic claim: the DB arbitrates, no leader needed
UPDATE jobs
SET status = 'RUNNING',
claimed_by = :workerId,
lease_until = now() + interval '5 minutes',
attempts = attempts + 1
WHERE job_id IN (
SELECT job_id FROM jobs
WHERE status = 'PENDING' AND next_run_at <= now()
ORDER BY next_run_at
LIMIT 100
FOR UPDATE SKIP LOCKED -- workers don't block each other
)
RETURNING *;
-- Reclaim jobs from dead workers (expired lease)
UPDATE jobs SET status='PENDING', claimed_by=NULL
WHERE status='RUNNING' AND lease_until < now();
-- Time-bucketed scheduling in Redis: O(log N), no table scan
ZADD jobs:due {epochSeconds} {jobId}
ZRANGEBYSCORE jobs:due -inf {now} LIMIT 0 100
-- After a successful recurring run, compute the next occurrence
UPDATE jobs SET status='PENDING', next_run_at = :nextFromCron WHERE job_id = :id;
Design an autocomplete / typeahead system.
The defining constraint is latency: suggestions must appear within ~100ms of a keystroke, and every keystroke is a request. A user typing "laptop" generates 6 requests. At scale that's more QPS than the main search itself.
Data structure — a trie (prefix tree): each node is a character, and the path from the root spells a prefix. The critical optimisation is to precompute and store the top-K completions at every node. Then a lookup is: walk the prefix (O(length of prefix), tiny) and return the stored list — no traversal of the subtree, no sorting at query time.
Ranking — suggestions are ordered by a score combining frequency, recency (trending queries should surface fast), personalisation, and often geography.
The build path is separate and offline. Query logs stream into an aggregation pipeline that computes frequencies over a rolling window, builds a new trie, and publishes it. Serving nodes load the prebuilt trie into memory. You never mutate the serving trie per query — updates are periodic full or incremental rebuilds, because a read-only in-memory structure is what makes sub-10ms responses possible.
Client-side matters as much as the backend: debounce input (~50ms) so you don't fire a request per keystroke, cache results per prefix locally, and cancel in-flight requests when the user keeps typing.
# Trie node with PRECOMPUTED top-K — no query-time sorting
{
"lap": {
topK: ["laptop", "laptop bag", "laptop stand", "laptop skin"],
children: { "t": {...}, "s": {...} }
}
}
# Query = walk the prefix, return the stored list. O(len(prefix)).
GET /suggest?q=lap → 200 in ~5ms { suggestions: [...] }
# Offline build pipeline (runs continuously, publishes periodically)
query logs → Kafka → windowed aggregation (frequency + recency decay)
→ build trie with top-K per node → serialise
→ publish to serving nodes (load into memory, atomic swap)
# Client: debounce + cancel + local cache
let ctrl;
const suggest = debounce(async (q) => {
ctrl?.abort(); // cancel the in-flight request
ctrl = new AbortController();
if (cache.has(q)) return render(cache.get(q));
const r = await fetch(`/suggest?q=${q}`, { signal: ctrl.signal });
cache.set(q, await r.json());
}, 50);
Design a real-time leaderboard and counting system.
Requirements: millions of players, scores updating constantly, and queries for "top 100" plus "my rank" — both in milliseconds.
Why the obvious approach fails: SELECT COUNT(*) FROM scores WHERE score > :myScore to compute a rank is O(n) per query. At 10M rows and thousands of QPS, the database dies.
The answer is a Redis sorted set (ZSET), which is built for exactly this — a skip list plus hash map giving O(log N) insert and rank lookup:
ZADD to update a score — O(log N).ZREVRANGE 0 99 WITHSCORES for the top 100 — O(log N + 100).ZREVRANK for "my rank" — O(log N). This is the operation that's impossible to do cheaply in SQL.
Scaling beyond one node: a single ZSET with 100M entries is large and hot. Options: shard by score range and merge (complex), bucket by time window (daily/weekly leaderboards are naturally smaller and are what users actually care about), or approximate deep ranks — return exact ranks for the top N and an estimated percentile for everyone else. Most players only care whether they're top 10 or roughly "top 5%".
Durability: Redis is the serving layer, not the source of truth. Score events go to Kafka → durable store for audit and rebuild, and the ZSET can be reconstructed from that log.
# Update a score — O(log N)
ZADD leaderboard:global 15200 "player_8821"
ZINCRBY leaderboard:global 150 "player_8821" # relative update
# Top 100 — O(log N + 100)
ZREVRANGE leaderboard:global 0 99 WITHSCORES
# "My rank" — O(log N). This is what SQL can't do cheaply.
ZREVRANK leaderboard:global "player_8821" # → 4271 (0-indexed)
ZSCORE leaderboard:global "player_8821"
# Players around me (the leaderboard slice users actually want)
rank = ZREVRANK leaderboard:global "player_8821"
ZREVRANGE leaderboard:global (rank-5) (rank+5) WITHSCORES
# Time-bucketed boards: smaller, hotter, and what users care about
ZADD leaderboard:2026-08-05 15200 "player_8821"
EXPIRE leaderboard:2026-08-05 604800
# High-cardinality unique counting — HyperLogLog, ~12KB for billions
PFADD daily:uniques:2026-08-05 "player_8821"
PFCOUNT daily:uniques:2026-08-05 # ~0.81% standard error
Design a centralised logging and metrics pipeline.
Requirements: collect logs and metrics from thousands of hosts/containers, make them searchable within seconds, retain them affordably, and — critically — never let the observability system take down the applications it observes.
The pipeline: collect → buffer → process → store → query.
- Collect — a lightweight agent per node (Fluent Bit, Vector, OTel Collector) tailing container stdout. It must be resource-capped, because an agent that OOMs the node has caused an outage.
- Buffer — Kafka in the middle. This is the single most important design decision: it decouples producers from consumers, absorbs traffic spikes, and lets the storage layer go down for maintenance without losing a single log line. Without a buffer, a slow Elasticsearch backs pressure all the way into your applications.
- Process — parse, enrich (add pod/service/region metadata), redact PII, sample, and route by class.
- Store — different data, different stores: logs to Elasticsearch or Loki, metrics to Prometheus/Mimir (time-series), traces to Tempo/Jaeger. One store cannot serve all three well.
- Query — Grafana or Kibana over all of them, correlated by trace ID.
Tiered retention is what makes it affordable: hot and searchable for 7–14 days, then compressed in object storage for the compliance tail.
# Pipeline
[app stdout] → Fluent Bit/Vector (per node, resource-capped)
→ Kafka (buffer, replay, decoupling) ← the key component
→ stream processors (parse, enrich, redact, sample, route)
├→ Elasticsearch / Loki (logs, 7-14d hot)
├→ Prometheus / Mimir (metrics, downsampled long-term)
├→ Tempo / Jaeger (traces, tail-sampled)
└→ S3 / Glacier (raw archive, compliance)
# Agent config: never let the agent hurt the node
[SERVICE]
Mem_Buf_Limit 50MB # cap memory
storage.type filesystem # spill to disk instead of blocking
storage.max_chunks_up 128
[FILTER] # drop noise BEFORE it costs anything
Name grep
Match kube.*
Exclude log (GET /health|GET /ready|GET /metrics)
[OUTPUT]
Name kafka
Match kube.*
Retry_Limit 5 # then drop — do NOT block the app