System design
At six years nobody expects you to design Twitter for 500 million users. They are checking whether you can scope a problem, choose a data model, and say what you are giving up. A design with no stated trade-off is not a design — it is a diagram.
The frame, every time
- Scope · 5 min. Who uses it, the two or three core flows, read-heavy or write-heavy, roughly how many users. Say what you are excluding.
- Numbers · 3 min. DAU, peak requests per second, data written per day. You are not being tested on arithmetic — on whether you size before you build.
- API · 5 min. The four or five endpoints that matter. Your strength — use it early to set the tone.
- Data model · 10 min. Tables, keys, indexes, and the one access pattern that drives the schema.
- Architecture · 10 min. Client, edge, services, datastores, cache, queue. Draw it.
- Deep dive · 15 min. They pick one piece. Have caching and concurrency ready — those are yours.
- Failure & scale · 7 min. What breaks first at ten times the load, and what you would do.
Scope it out loud first: browsing and search, viewing availability, booking with payment, and the organiser side for managing listings. Explicitly exclude reviews, messaging and refunds unless they ask.
Core entities:
camps (id, org_id, title, location_id, age_min, age_max, price_cents, status)
slots (id, camp_id, starts_at, ends_at, capacity, remaining, version)
bookings (id, slot_id, user_id, seats, status, idempotency_key, expires_at)
users (id, email, ...)
-- the indexes the access pattern demands
CREATE INDEX ON slots (camp_id, starts_at);
CREATE INDEX ON bookings (user_id, created_at DESC);
CREATE UNIQUE INDEX ON bookings (idempotency_key);
CREATE INDEX ON camps (location_id, price_cents) WHERE status = 'published';The design hangs on three things, and the interviewer will pick one to go deep on:
1 · Search
Faceted filters over location, age band, interest and price. At a thousand listings this is a Postgres query with composite indexes and a GIN index for full-text — and say explicitly that you would not reach for Elasticsearch at that size, because it is a second system to operate and keep in sync for no measurable gain. The crossover is somewhere around a hundred thousand listings, or when you need relevance ranking and typo tolerance rather than filtering. Then the database stays the source of truth and the index is rebuilt from a change stream.
2 · Booking concurrency
The double-booking problem from R5.6: an atomic conditional decrement as the fast path, a unique constraint as the backstop, and an Idempotency-Key so a retried request after a timeout returns the original booking instead of creating a second one.
3 · Payment, which is where most candidates fall down
Never mark a booking confirmed on the client's redirect. The user can close the tab, lose signal, or the redirect can be replayed. The correct flow:
- Create the booking as
pendingand decrementremaining— the seat is held. - Set
expires_ata few minutes out. A background job releases holds that expire, so an abandoned checkout does not permanently consume inventory. - Confirm only on the payment provider's webhook, which must be idempotent because providers retry — verify the signature, then check whether you have already processed that event id.
- Reconcile: a scheduled job that queries the provider for any booking still pending past its window, because webhooks do get lost.
That webhook-and-reconciliation detail is what separates candidates who have shipped commerce from candidates who have read about it.
- What if the payment succeeds but your webhook handler crashes?
- How do you handle a camp in a different timezone?
- Ten times the traffic — what breaks first?
- How would you add "5 people are looking at this slot"?
Three parts: ingest, aggregate, deliver.
Ingest. Agents emit events (call started, handed off, resolved, latency, sentiment). Buffer and batch on the client side — one HTTP request per event does not survive volume. Write to an append-only events table or a stream.
Aggregate. This is the key decision: pre-aggregate into time buckets on write rather than computing over raw events on read. Dashboards are read-heavy with a known query pattern, so paying once per event beats paying per viewer. Keep raw events for a short retention window for drill-down, and roll up into minute, hour and day tables beyond that.
events_raw (id, agent_id, type, latency_ms, ts) -- 7 day retention
metrics_minute (agent_id, bucket, calls, handoffs, p95_ms) -- 30 days
metrics_hour (agent_id, bucket, calls, handoffs, p95_ms) -- 1 year
-- the dashboard queries the rollup that matches its window,
-- so "last 24h" reads 24 rows, not 4 million eventsDeliver. Server-sent events, not WebSockets — the flow is one-directional, SSE is plain HTTP, it reconnects automatically, and it passes through proxies that block WebSocket upgrades. Push deltas, not the whole payload. Cache the current bucket in Redis with a short TTL so a hundred open dashboards do not become a hundred identical queries every second.
The detail worth volunteering: percentiles do not average. You cannot compute a p95 across agents by averaging their individual p95s. Either store a histogram per bucket (t-digest or HDR histogram) or accept that you can only aggregate counts and sums. Interviewers who work with metrics notice this immediately.
- How do you handle an agent that goes offline mid-call?
- What if a viewer opens a dashboard for a 90-day window?
- How would you alert on a metric?
CDN in front; static or incrementally regenerated pages for catalogue content; Redis for anything dynamic. The design decision worth articulating is the staleness boundary:
| Data | Acceptable staleness | Therefore |
|---|---|---|
| Product title, images, description | Hours | Static, regenerated on publish |
| Price | Minutes | ISR with a short revalidate, or edge cache |
| Stock count on the listing | Minutes | Cached — an approximate number is fine |
| Stock at checkout | Zero | Authoritative read + reservation, never cached |
| Cart, recommendations | Per user | Client-side after first paint |
Say the sentence: "a design that insists on real-time accuracy everywhere cannot be cached, and therefore cannot handle the spike. The trick is to be precise about which single number has to be exact and when."
For the spike specifically: pre-warm the cache before a known event, put a queue in front of checkout if the write path is the bottleneck, and have a degraded mode — if the recommendations service is down, render the page without recommendations rather than failing it.
- How do you invalidate a price change across the CDN?
- What is a stampede and how does it show up here?
- How would you handle a flash sale with 10,000 people and 100 units?
Producers write a notification request; a queue decouples the send from the request path; per-channel workers handle provider specifics; a template service renders content.
The parts that make it a real design rather than three boxes:
- Retries with backoff and a dead letter queue. Permanent failures (invalid address) must not be retried forever; transient ones must be.
- Idempotency. A producer retry must not send twice — dedupe on a notification key.
- User preferences and quiet hours, checked at send time rather than at enqueue time, because preferences may change while a job is queued.
- Per-user rate limiting, so a runaway loop does not send someone four hundred emails. This is the guardrail that has actually saved companies from public embarrassment.
- Provider failover. Two email providers, health-checked, with a circuit breaker.
- Delivery tracking. Provider webhooks for delivered, bounced and complained; a hard bounce should suppress that address permanently.
- How do you send 1 million notifications without melting the provider?
- What happens if the template service is down?
- How do you test this without emailing real users?
Key generation is the interesting part, and there are three answers with real trade-offs:
- Base62 of an auto-increment id. Simple, guaranteed unique, shortest keys — but enumerable, and it leaks how many links you have created.
- Random 7 characters. 62⁷ is about 3.5 trillion, so collisions are rare but must still be handled with a unique constraint and retry.
- A pre-generated key pool. A background job fills a table of unused keys; creation just claims one. No collision check on the hot path, and it is the answer that shows you have thought about write latency.
Reads massively outnumber writes, so this is fundamentally a cache problem: Redis in front, database as source of truth, and the redirect served from the edge.
The detail worth volunteering: 301 vs 302. A 301 is cached permanently by browsers, so you get fast redirects and no analytics — the second click never reaches you. A 302 keeps every click observable at the cost of a round trip. If click counting is a product requirement, that decision is forced, and saying so shows you connect technical choices to product ones.
- How would you support custom aliases?
- How do you expire links?
- How would you count clicks without slowing the redirect?
| Model | Isolation | Cost of it |
|---|---|---|
| Shared schema, tenant_id column | Weakest — one missing WHERE tenant_id leaks data across customers | Cheapest to run and migrate. Use row-level security in Postgres so the isolation is enforced by the database, not by remembering. |
| Schema per tenant | Good | Migrations must run N times; connection pooling gets awkward past a few hundred tenants. |
| Database per tenant | Strongest — and the answer for regulated or enterprise customers | Expensive; operationally heavy; per-tenant backup and restore is a feature you now own. |
The pragmatic answer most companies land on, and the one to give: shared schema with row-level security by default, and database-per-tenant as a premium tier for enterprise customers who require it. Then the noisy-neighbour question — per-tenant rate limits and query timeouts, so one customer's report cannot degrade everyone else.
- How do you run a migration across 500 tenant schemas?
- How would you move one tenant to a dedicated database?
- How do you handle a tenant-specific customisation?
- A rate limiter as a shared service. The R5 answer, plus: where does it live — sidecar, gateway, or a library in each service, and what does the network hop cost you?
- A file upload and processing pipeline. Presigned URL → S3 event → worker → status the client subscribes to. The state machine matters more than the boxes.
- An online learning platform with course search and progress tracking. Your Mindbell work. The interesting part is progress: an append-only event log of "completed lesson X" beats a mutable percentage field, because it survives a course being restructured.
- An authentication service with refresh token rotation. R5.7 as a system.
- A comment thread or activity feed. Cursor pagination, not offset — with new items arriving, offset pagination shows duplicates and skips rows.
- A job scheduler. Cron-like triggers, at-least-once delivery, and the question they always ask: what stops two instances running the same job? (A lock, or a leader.)
- An audit log. Append-only, immutable, with who/what/when/before/after. The follow-up is always retention and how you query it.
- "Let me confirm the scope before I design — are we optimising for reads or writes?"
- "At this scale I would not add that yet. Here is the specific signal that would make me add it."
- "The trade-off I am accepting here is X, and the cost is Y."
- "This data can be stale by a minute; that data cannot be stale at all — and that difference is what drives the whole caching strategy."
- "The first thing that breaks at ten times this load is the database, and here is what I would do about it."
Two habits that matter as much as the content: keep drawing — an interviewer staring at a blank board stops believing you can do this — and ask before you assume. "Is this a global product or single-region?" changes the whole design, and asking it is free.