Five subsystems that appear in almost every design and that you
should almost never build from scratch. Knowing their internals matters anyway —
it's how you choose between them and predict how they fail.
16
Observability
logsmetricstracesSLOalerts
Why it exists
Monitoring answers questions you knew to ask ("is CPU high?"). Observability
lets you answer questions you didn't ("why are exactly these three customers in
Mumbai seeing 4-second checkouts?"). At scale you cannot predict your failure
modes, so you instrument for exploration rather than for a fixed dashboard.
The three signals
Metrics
Logs
Traces
Shape
Numbers over time, pre-aggregated
Discrete events with context
One request's path across services
Answers
Is something wrong? How much?
What exactly happened to this request?
Where did the time go?
Cost
Cheap, bounded
Expensive — grows with traffic
Expensive — sample it
Retention
Months to years
Days to weeks
Days
Danger
Cardinality explosion
Volume and cost; PII leakage
Overhead and sampling bias
Cardinality is how you destroy a metrics system
Each unique combination of label values is a separate time series.
http_requests{status, endpoint} with 5 statuses and 40 endpoints is
200 series — fine. Add user_id with a million users and it's 200
million series, and your Prometheus is dead.
Rule: labels must be bounded and low-cardinality. Never put
user IDs, request IDs, email addresses, full URLs with parameters, or timestamps
in a metric label. Those belong in logs and traces, which are indexed for
exactly that.
What to measure: RED and USE
RED — for request-driven services (what your users feel)
Rate requests per second
Errors failed requests per second (and as a ratio)
Duration latency distribution — p50, p95, p99, p99.9
USE — for resources (what's constraining you)
Utilisation % of time busy
Saturation queue depth / waiting work ← the leading indicator
Errors error counts
# Saturation moves before utilisation saturates and long before latency
# rises. Queue depth, thread pool waits, and connection pool waits are the
# metrics that give you warning rather than confirmation.
Percentiles, not averages — and know that percentiles do not
average. You cannot take the mean of per-server p99s and call it the
fleet p99; you need histograms that can be merged (Prometheus histograms, HDR
histograms, t-digest).
SLI, SLO, SLA, error budget
Term
Meaning
Example
SLI
The measurement
Fraction of checkout requests served in <300 ms with a 2xx
SLO
Your internal target
99.9% over a rolling 28 days
SLA
The contractual promise, with penalties
99.5% or the customer gets credits — always looser than the SLO
Error budget
100% − SLO: the failure you're allowed
0.1% of 28 days ≈ 40 minutes of badness to spend
The error budget is the useful invention: it converts reliability from an
argument into arithmetic. Budget remaining → ship features. Budget spent → freeze
and fix. Nobody has to win a debate about whether 99.9% is "enough".
Alerting
Alert on symptoms, not causes. "Checkout error rate > 1%"
pages someone for a real user impact. "CPU > 80%" pages someone for a machine
doing its job.
Every page must be actionable. If the responder's only move
is to acknowledge it, it should have been a dashboard or a ticket.
Burn-rate alerts beat static thresholds. Page fast when the
budget is burning at 14× (a 2% budget spend in an hour); open a ticket when it's
burning at 2× over six hours. Fast burn = wake someone; slow burn = fix this
week.
Alert fatigue is a real outage cause. A team that ignores
pages will ignore the one that mattered.
Making it all correlate
A trace ID generated at the edge, propagated through every hop (W3C
traceparent), attached to every log line, and exposed to the user on
error pages. That single decision is what turns three separate tools into
observability: see a latency spike in a metric, jump to an exemplar trace, jump to
that request's logs. Without it you have three data sources and no way to join
them.
Trace sampling: head-based (decide at the start — cheap, but you miss the rare
slow request) or tail-based (buffer, then keep the interesting ones — costlier, but
keeps every error and every outlier). A common production setup is 1% of successes
plus 100% of errors and slow requests.
Say this
"RED metrics on every endpoint with p50/p95/p99 histograms, structured JSON
logs with a trace ID propagated from the edge, and distributed tracing sampled at
1% plus 100% of errors. SLO of 99.9% on checkout measured as a rolling 28-day
window, alerting on error-budget burn rate rather than a raw threshold — fast
burn pages, slow burn files a ticket. And no user IDs in metric labels, because
cardinality is how you kill the metrics system you're relying on during an
incident."
17
Storage Systems
objectblockfiledurabilitytiering
Why it exists
"Storage" is three different products. Choosing wrongly is expensive and hard to
undo — putting 50 TB of images in a database, or trying to run a database on
object storage, are both mistakes you pay for monthly.
Object
Block
File
Unit
Immutable blob + key + metadata
Fixed-size blocks; no notion of a file
Files in a directory tree
Access
HTTP GET/PUT by key
Attached device; a filesystem sits on top
NFS/SMB, POSIX semantics
Mutation
Replace the whole object
Random writes anywhere
Random writes, plus locking and permissions
Scale
Effectively unlimited
Per-volume limit
Limited by the appliance
Latency
10–100 ms
<1 ms
1–10 ms
Cost/GB
Lowest
Highest
High
Use for
Images, video, backups, logs, data lakes, static sites
Database volumes, VM disks, anything needing a filesystem
Shared home directories, legacy apps that need POSIX
Examples
S3, GCS, R2, Azure Blob
EBS, persistent disks
EFS, FSx, NFS
The rule that answers most interview questions
Blobs go to object storage; metadata goes to a database. Store
the image in S3 and the row {id, owner, s3_key, size, width, content_type,
created_at} in Postgres. Never put binary blobs in a relational database:
it destroys the buffer pool, bloats backups from minutes to hours, and you lose
the ability to serve bytes directly from a CDN.
Durability: replication vs erasure coding
3× replication 3 copies. Survives 2 failures. 200% storage overhead.
Simple, fast reads from any copy.
Erasure coding Split into k data + m parity fragments (e.g. 6+3).
(Reed-Solomon) Survives any m losses. Overhead = m/k → 50% for 6+3.
Cheaper, but a read may need to reconstruct from
several fragments, and repair costs network I/O.
# Why "11 nines" (99.999999999%) is quoted for S3: fragments are spread
# across multiple facilities and continuously scrubbed and repaired. The
# probability of losing enough fragments before repair completes is tiny.
# 10M objects → statistically one loss every 10,000 years.# DURABILITY IS NOT AVAILABILITY. S3 is ~99.99% available and ~11 nines
# durable: your data is essentially never lost, but you may not be able to
# read it right now. Different problems, different mitigations.
Durability does not protect you from you
Eleven nines of durability does nothing against DELETE, a bad
migration, or ransomware — the storage faithfully replicates your mistake. You
still need versioning, soft deletes,
cross-account or cross-region backups, and object-lock /
write-once retention for the data that would end the company. And a backup you
have never restored is a hypothesis, not a backup.
Tiering and lifecycle
Access frequency drops sharply with age, and prices differ by an order of
magnitude. Encode that as a lifecycle policy rather than a good intention:
0–30 days Standard hot; instant access
30–90 days Infrequent cheaper storage, per-GB retrieval fee
90–365 days Archive minutes to restore
365+ days Deep archive hours to restore, ~1/20th the price
+ delete or anonymise at the end of the retention policy
# Gotchas: minimum storage durations (moving early costs MORE than
# staying), per-object transition fees that dominate for millions of tiny
# objects, and retrieval fees that make "cheap" archive expensive if you
# actually read it. Model the access pattern before tiering.
Design notes worth stating
Key naming. Modern S3 scales prefixes automatically, but a
key layout that groups by tenant and date (tenant/2026/08/29/uuid)
still makes lifecycle rules, listing, and deletion vastly easier. Never make keys
guessable if they're sensitive.
Listing is not querying.LIST is slow and
paginated and is not a substitute for a metadata index. Keep the index in a
database.
Consistency. S3 has offered strong read-after-write
consistency since 2020 — the old "eventually consistent, may 404 after PUT"
caveat no longer applies. Worth knowing so you don't cite a stale gotcha.
Serve through a CDN, never from origin. Origin egress is the
expensive part, and a CDN both cuts latency and cuts the bill.
Encrypt at rest and in transit, and use short-lived presigned
URLs rather than public buckets.
18
Search Systems
inverted indexBM25rankingautocomplete
Why it exists
WHERE title LIKE '%wireless%' cannot use an index, scans every row,
and ranks nothing. Search is a different data structure for a different question:
not "which rows match" but "which documents are most relevant, ordered,
in 50 ms".
The inverted index
Documents
d1: "wireless bluetooth headphones"
d2: "wireless mouse"
d3: "bluetooth speaker wireless"
Inverted index — term → posting list of (doc, positions)
wireless → [d1:0, d2:0, d3:2]
bluetooth → [d1:1, d3:0]
headphones → [d1:2]
mouse → [d2:1]
speaker → [d3:1]
Query "wireless bluetooth" → intersect the two posting lists → {d1, d3}
→ score, sort, return
# Positions are what make phrase queries ("wireless bluetooth" as an exact
# phrase) possible. Posting lists are stored sorted and delta-compressed,
# with skip pointers so intersection can leap rather than walk.
The analysis pipeline
Indexing and querying must use the same analysis chain, or nothing
matches:
"Running Shoes, Men's (Size 10)"
→ tokenise [Running] [Shoes] [Men's] [Size] [10]
→ lowercase [running] [shoes] [men's] [size] [10]
→ strip/fold [running] [shoes] [mens] [size] [10] # also accents: café→cafe
→ stopwords [running] [shoes] [mens] [size] [10] # drop "the", "a", "of"
→ stem/lemma [run] [shoe] [men] [size] [10]
→ synonyms [run|jog] [shoe|sneaker|trainer] …
# Stemming is aggressive and lossy ("universe"/"university" both → "univers").
# Lemmatisation is smarter and slower. Over-stemming causes bad matches;
# under-stemming causes missed ones. This is a tuning dial, not a setting.
Scoring and ranking
TF-IDF: a term matters more if it appears often in this document
(term frequency) and rarely across the corpus (inverse document frequency) — which
is why matching "the" tells you nothing and matching "petrichor" tells you a lot.
BM25 is the modern default (Elasticsearch, Lucene). It refines
TF-IDF two ways: term frequency saturates, so the 20th occurrence of a word
adds almost nothing over the 5th; and it normalises for document length, so a long
document doesn't win just by containing more words.
Real ranking is a funnel, and saying so is the thing that scores:
1. RETRIEVAL BM25 / vector similarity → top ~1,000 candidates (fast, cheap)
2. FILTERING in stock, region, permissions, safety (hard rules)
3. RE-RANKING a model over rich features (slow, on 1,000 not 10M)
· text relevance · popularity / CTR
· personalisation · freshness, price, margin
· seller quality · business boosts
4. BLENDING diversity, dedupe, ads, pinned results
# The whole point: expensive scoring only ever runs on a small candidate
# set. This funnel shape is identical in search, feeds and recommendations.
Autocomplete
Approach
How
Tradeoff
Trie with cached top-K
Each node stores its best K completions
Sub-millisecond, memory-hungry, rebuild to update
Edge n-grams
Index "sam", "sams", "samsu"… at write time
Simple in Elasticsearch; index size balloons
FST / completion suggester
Compressed automaton
Compact and fast; less flexible
Prefix + fuzzy
Edit distance ≤ 1–2 for typos
Catches "samsng"; much more expensive — apply only after a strict pass fails
Practical requirements people forget: rank suggestions by query popularity
(from logs) not alphabetically; debounce client input by ~150 ms so you don't fire
a request per keystroke; cap latency at ~100 ms or users out-type you; and filter
suggestions for safety and personalisation.
Freshness, sharding, and the truth
Near-real-time, not real-time. Lucene-style engines write to
immutable segments and make them visible on refresh (default ~1 s in
Elasticsearch). Sub-second visibility costs merge overhead; batching refreshes
is how you get bulk-indexing throughput.
Shards + replicas. A query scatter-gathers across every shard
and merges results, so p99 is the slowest shard's p99. Replicas add read capacity
and availability. Too many small shards is a very common self-inflicted
performance problem.
The search index is never the source of truth. It is a
derived, rebuildable view fed from the database via CDC or an event stream. If it
is corrupted or the mapping changes, you reindex from truth — which means you must
keep the ability to reindex fast, and you should practise it.
Reindex with aliases. Build the new index alongside, then flip
the alias atomically. Never mutate the live index's mapping in place.
Say this
"Elasticsearch fed by change-data-capture from Postgres, so the index is a
derived view I can always rebuild — it's never the source of truth. Retrieval with
BM25 to get a thousand candidates, then a re-ranker over popularity, personalisation
and stock, because I can afford an expensive model on a thousand documents and not
on ten million. Autocomplete is a separate low-latency path — a completion
suggester ranked by historical query volume, debounced client-side, budgeted at
100 ms. Reindexing happens behind an alias so the switch is atomic."
19
Real-Time Systems
WebSocketsSSElong pollingfan-outpresence
Why it exists
HTTP is client-initiated: the server cannot speak first. Every "live" feature —
chat, notifications, prices, collaborative editing, ride tracking — needs a way
around that, and the options differ enormously in cost and complexity.
Short polling
Long polling
SSE
WebSocket
Direction
Client pulls
Client pulls, held
Server → client
Bidirectional
Latency
Up to the interval
Near-instant
Near-instant
Lowest
Overhead
Terrible — mostly empty responses
Moderate
Low
Lowest per message
Complexity
Trivial
Low
Low
High — connection state, reconnects, scaling
Infra friendliness
Plain HTTP
Plain HTTP
Plain HTTP, auto-reconnect built in
Needs proxy/LB support, no HTTP caching
Use for
Slow-changing data, tiny scale
Fallback when WS is blocked
Feeds, notifications, live dashboards, token streaming
Chat, games, collaborative editing, trading
The under-used answer
If updates only flow server → client, SSE is usually the better
choice than WebSockets: it's plain HTTP (so it traverses proxies, works
with standard auth and compression, and survives corporate firewalls), the browser
reconnects automatically, and Last-Event-ID gives you resumption for
free. Reach for WebSockets when the client genuinely needs to push too. Naming
this tradeoff instead of defaulting to WebSockets is a strong signal.
Scaling stateful connections
The hard part isn't the protocol — it's that connections are state, and
the server holding a user's socket is almost never the server that produces their
event.
Separate the connection tier from the business tier. Gateways own sockets; the backplane routes events; the registry answers "where is this user right now?".
Connection limits. ~50k–100k connections per node is a
realistic planning figure (kernel file descriptors, ~10–50 KB of memory per idle
connection, and epoll/kqueue rather than a thread per socket). 1M concurrent users
≈ 10–20 gateway nodes — a number worth quoting.
Heartbeats. TCP will not tell you a mobile client walked into
a tunnel. Ping every ~30 s and drop the connection after two misses; otherwise you
accumulate zombie sockets and lie about presence.
Reconnect storms. If a gateway dies, all 60,000 of its clients
reconnect at once. Jittered backoff on the client is mandatory, and the gateway
tier needs headroom to absorb a redistribution.
Fan-out cost. One message to a 100,000-member channel is
100,000 socket writes. Fan out on the gateways (each node writes only to its own
connections) rather than through the backplane per-recipient.
Never rely on the connection for delivery
A socket is a fast path, not a durable one. Persist the message first, then
push. On reconnect the client sends its last-seen message ID and the server
backfills the gap from storage. Without this, every disconnect is silent data
loss — and this is exactly the follow-up question you'll get in a chat design.
Presence
Presence looks trivial and is genuinely expensive: a naive "broadcast every status
change to every friend" is O(users × friends) writes, and it's the feature that
melts under load. The usual approach: a Redis key per user with a short TTL
refreshed by heartbeat (absence of the key = offline, no explicit offline event
needed); read presence on demand for the contacts actually on screen rather
than pushing every transition; batch and debounce changes; and accept coarse
granularity — "active recently" instead of second-accurate.
20
File Uploads
presigned URLschunkingmultipartresumescanning
Why it exists
A 5 GB upload over a mobile connection will be interrupted. If your design
restarts from zero, it never completes. And if those bytes flow through your
application servers, a hundred concurrent uploads consume your entire fleet doing
work a storage service does better and cheaper.
The correct flow: never proxy the bytes
1. POST /uploads {filename, size, content_type, sha256}
→ server validates (size limit, type allow-list, quota)
→ creates an upload record, state = PENDING
→ returns a PRESIGNED URL (15 min expiry) + upload_id
2. Client PUTs the bytes DIRECTLY to object storage.
Your servers never see them. No bandwidth, no memory, no timeouts.
3. Storage fires an event (S3 → SNS/SQS/Lambda) OR the client calls
POST /uploads/{id}/complete.
# Trust the storage event over the client — clients lie and crash.
4. Server verifies size + checksum, marks READY, enqueues processing
(thumbnails, transcode, scan, index).
5. Reads are served via a short-lived presigned GET or a CDN signed URL.
The bucket itself is never public.
Multipart and resumability
Split the file into parts (5 MB–100 MB; S3 minimum is 5 MB except the last).
· Parts upload in PARALLEL → saturates the link, much faster
· Each part retries INDEPENDENTLY → one flaky part costs one part
· Resume = ask which parts landed, upload only the missing ones
· Storage assembles on "complete", given the part list + ETags
Resume handshake after an app restart or lost connection:
GET /uploads/{id}/parts → {"uploaded": [1,2,3,5], "total": 40}
client re-uploads 4, then 6…40
# Always set a lifecycle rule to abort incomplete multipart uploads after
# ~7 days. Orphaned parts are invisible in the bucket listing and you are
# billed for them forever. This is a real, common, expensive bug.
Deduplication
Hash the content (SHA-256) client-side, check whether that hash already exists,
and if so just create a new reference to the existing object. This is how Dropbox
gets its famous "instant" uploads. Two cautions worth voicing: dedup across
users leaks information (an attacker learns a file exists by observing an
instant upload), so many systems dedup per-user or per-tenant only; and reference
counting must be right, or deleting one user's copy deletes everyone's.
Validation and scanning
Do not trust anything the client says
Content type: the declared Content-Type and the
file extension are both attacker-controlled. Sniff magic bytes server-side and
re-encode images rather than trusting them.
Size: enforce at the presigned-URL policy level, not just in
your API — otherwise a client can PUT 50 GB.
Serving: serve user content from a separate domain
with Content-Disposition: attachment,
X-Content-Type-Options: nosniff, and a restrictive CSP. An HTML
file served from your main domain is stored XSS with access to your cookies.
Zip bombs and decompression: bound the output size of any
unpacking or transcoding step, and run it in a sandbox with hard CPU and memory
limits.
Scanning is asynchronous, which forces a state machine, and being explicit about
it is what a good answer looks like:
PENDING → UPLOADED → SCANNING → ┬→ READY (served to users)
└→ QUARANTINED (blocked, owner notified)
# Choose deliberately: block on the scan (safe, slow — the user waits)
# or serve optimistically to the uploader only until it clears (fast, and
# the exposure is bounded to the person who uploaded it). For anything
# shared publicly, block.
Say this
"The client asks our API for a presigned URL and uploads straight to object
storage — bytes never touch our servers, so uploads don't consume application
capacity. Anything over 100 MB uses multipart with 10 MB parts uploaded in
parallel, so a dropped connection costs one part instead of the whole file, and
resume is 'tell me which parts you already have'. Completion is driven by the
storage event rather than the client, because clients crash. Then it's a state
machine — uploaded, scanned, ready — with a lifecycle rule aborting abandoned
multipart uploads after seven days so we're not paying for invisible orphans."