Cheat sheet · Practical Design Problems: From Whiteboard to Working Code

Practical Design Problems cheat sheet

The six-step frame, the numbers worth knowing, and the API, data model and core trick of each classic design problem. Lead the talk, use numbers, and always say what fails.

Read the full series →

blogs · jp-blogs.vercel.app/cheatsheets/series/design-problems

The six-step frameLesson 0 →

  1. Clarify: what, who, out of scope. Functional and non-functional.
  2. Estimate: requests per second, storage, read:write ratio.
  3. API: methods, status codes, errors, auth, pagination, idempotency.
  4. Data model: what is stored, how it is read, keys and indexes.
  5. Core design: the hard part, with alternatives and a choice.
  6. Scale and fail: 10x traffic, a dependency down, hot keys, abuse.

Tip: 45 min: 5 clarify and estimate, 10 API and data, 15 core, 10 scale and failure, 5 wrap-up. Say: "A gives X but costs Y; given Z I choose A."

Back-of-envelope numbersLesson 0 →

FactApproximation
Seconds in a day86,400, about 10^5
1 million requests a dayabout 12 per second
100 million a dayabout 1,200 per second
Peak trafficAverage x 2 to 10 (use 3 to 5)
1M rows x 1 KBabout 1 GB (1B rows is 1 TB)
LatencyMemory 100 ns, Redis 0.5 ms, SSD 100 us, region hop 100 ms+
writes: 100M/month = 40/s (peak 200)
reads 100x = 4,000/s (peak 20,000)
6B links x 500 B = 3 TB
hot 20%: 20M x 500 B = 10 GB
  (one Redis node)

Then say the conclusion: writes are small, reads are the problem.

URL shortener: codesLesson 1 →

ApproachCollisionsCatch
Hash + truncateYes, handle themSame URL gets same code for every user
Random + retryRare; retry on unique errorSimple. Watch how full the space gets
Counter + Base62NoneGuessable unless scrambled; needs ranges
Key generation serviceNoneAnother service to run
  • Base62 (0-9a-zA-Z) is URL-safe. 7 characters give 62^7, about 3.5 trillion; 6B links use 0.17%.
  • Base64 has +, / and =, which are not URL-safe.
  • Retry loop: insert, catch Prisma P2002 (unique violation), make a new code, up to 5 tries.

URL shortener: redirect and readsLesson 1 →

Aspect301 Moved Permanently302 Found
Browser cacheStrong; may skip your serverNot cached by default
AnalyticsLost on repeat clicksEvery click counted
Change or disable linkHard (stale cache)Easy, instant
  • Analytics, expiry or edits: 302. Permanent links, least load: 301.
  • Cache-aside in Redis with TTL no longer than the link's life. Negative-cache unknown codes (60 s) against scanners.
  • Statuses: 404 unknown, 410 expired, 409 alias taken, 429 rate limited.
  • Log clicks to a queue, never in the redirect request.

Watch out: Abuse: allow only http and https, check blocklists, block SSRF if you fetch the target, reject reserved aliases (api, admin), do not number codes in order.

Rate limiter: designLesson 2 →

AlgorithmMemory per keyNote
Fixed window1 counter2x burst at the boundary
Sliding logMany timestampsExact, costly
Sliding counter2 countersSmooth estimate; good default per minute
Token bucket2 valuesControlled bursts; usual API default
Leaky bucketQueueSmooth output for a constant-rate service
  • A limiter is key + rule + algorithm + shared state + response.
  • Rate limit counts per period; quota counts a long period; concurrency limit counts running now; load shedding protects the system.
  • Place it at the edge or gateway first, then app middleware for per-user rules.

Rate limiter: correct and visibleLesson 2 →

-- ARGV: capacity, rate, nowMs, cost
tokens = min(capacity,
  tokens + elapsed * rate)
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end
HSET key tokens ts
PEXPIRE key (idle keys vanish)

Run it as one Lua script so check-and-update is atomic. INCR then EXPIRE is not atomic.

429 Too Many Requests
Retry-After: 18
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
  • Fail open for most APIs; fail closed for login, OTP, reset. Choose per rule, with a short timeout.
  • Users behind one NAT: key on user id or API key, IP only as a wide outer layer. Trust X-Forwarded-For from trusted proxies only.

Pagination: offset vs cursorLesson 3 →

AspectOffsetCursor (keyset)
CostGrows with offset, O(offset)Fast at any depth
Data changesDuplicates and skipsStable
Jump to page NYesNo
Total countCOUNT(*), slowNot cheap
SELECT * FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 21;  -- limit + 1: hasMore

Needs index (created_at DESC, id DESC) and a unique tiebreaker.

Pagination: cursors and choosingLesson 3 →

  • Cursor is opaque base64url JSON. Validate it strictly, use parameterised queries; sign it with HMAC if needed.
  • Put a fingerprint of sort and filters in the cursor. Keep full timestamp precision.
  • Cap limit, set a default, cap offset depth. Backwards: flip comparison and sort, then reverse.
  • Counts: approximate, cached, a counter table, or "10,000+".
SituationChoose
Small table, admin page jumpsOffset
Feed, timeline, log, big table, public APICursor
Infinite scroll, Load moreCursor
Ranked search resultssearch_after

File uploadLesson 4 →

  1. Browser asks the API to upload (name, type, size). API validates, saves a PENDING row, returns a presigned URL.
  2. Browser PUTs the bytes straight to object storage. Your servers stay out of the data path.
  3. Storage event goes to a queue. Worker verifies, scans, makes thumbnails, sets READY or REJECTED.
  4. Client polls or gets a push. A cleanup job removes stale PENDING rows.
  • Large files: multipart, parts 5 MB to 5 GB, up to 10,000 parts, 5 TB object. Parallel parts, retry each, resume with ListParts.
  • Add lifecycle rule AbortIncompleteMultipartUpload. CORS must expose ETag.

Watch out: Timeouts behind the load balancer mean bytes pass through it. Move to presigned direct upload.

File upload: validate and serveLesson 4 →

CheckHow
Sizecontent-length-range in policy, recheck after
TypeAllow-list plus magic bytes; header and extension lie
NameServer-made key, never the client name
ContentMalware scan; re-encode images; cap pixels
ArchivesCap unpacked size and nesting (zip bomb)
  • Serve from a separate domain, with X-Content-Type-Options: nosniff and Content-Disposition: attachment.
  • Private files: private bucket and short-lived signed GET URLs.

Notification systemLesson 5 →

TransportUse
PollingSimple; wasteful, delayed
Long pollingLower delay; still a connection each
SSEServer to client only; auto reconnect, Last-Event-ID
WebSocketTwo-way; sticky routing and heartbeats
  • Pipeline: events, bus, notification service (recipients, preferences, dedupe, template), one queue per channel, workers.
  • Table has UNIQUE (user_id, dedupe_key), a partial index on unread, and deliveries (notification_id, channel).
  • Queues are at-least-once, so dedupe key plus idempotent senders plus DLQ.
  • Digest, quiet hours (user time zone), frequency caps, critical messages always allowed.

Notifications: scenariosLesson 5 →

3 tabs open
3 SSE connections; or a leader tab via BroadcastChannel; use HTTP/2
Broadcast to 1M
Fan-out on write (batched, throttled) or on read; hybrid; FCM topics
Offline user
Saved in-app list, Web Push, email or SMS fallback
Estimate
20M a day, about 230/s (peak 2,000), 100,000 open connections
The bell
List + unread count + stream; optimistic read; aria-live="polite"

Search autocompleteLesson 6 →

  1. Minimum 2 characters. Debounce 150 to 300 ms.
  2. Check the per-prefix cache. On a miss, abort the previous request and fetch.
  3. Ignore stale responses (compare the returned query). Cache by prefix.
  4. Combobox ARIA, arrow keys, Enter, Escape. Handle IME. Highlight safely.
Backend optionNote
SQL LIKE 'pre%'Index with text_pattern_ops; ranking is weak
Search engineTypo tolerance, ranking; heavier
Trie, top-k at each nodeLookup is O(prefix length); build offline
Hash map prefix to top-kCap prefix length (20)

Tip: Serve GET /v1/suggest with Cache-Control: public, max-age=300 at the CDN. Fail softly: hide suggestions if slow.

Payments, retries, flags, LRULesson 7 →

  • Idempotency key per intended payment, unique on (account_id, key), with request hash. Same key + same hash returns the saved response; different hash is 422; in progress is 409.
  • Pass the key to the provider too. A timeout means unknown, not failed. Payments are a state machine with conditional UPDATE.
  • Full jitter: delay = random(0, min(cap, base * 2^attempt)). Retry temporary errors on idempotent calls only; one layer only.
  • Flag: hash(flagKey + userId) % 100 < percent. Same answer every time; raising 10 to 20 keeps the first 10%.
  • Flags: evaluate locally, safe default, kill switch, audit log, owner and end date. Not for authorization.
get(k): v = map.get(k)
  map.delete(k); map.set(k, v)
put(k, v): map.delete(k)
  map.set(k, v)
  if (map.size > cap)
    delete map.keys().next().value

A Map keeps insertion order, so the first key is the least recently used.

Interview one-linersLesson 0 →

QuestionShort answer
301 or 302?302 for analytics and control; 301 for permanent and light.
Offset or cursor?Cursor for feeds and big data; offset for small admin tables.
Why presigned URLs?Bytes skip your servers: no double bandwidth, no LB timeouts.
Exactly-once?At-least-once delivery plus idempotent handlers.
Fail open or closed?Open for most APIs; closed for login, OTP and reset.
Why jitter?Clients that failed together would retry together.
Why a cursor tiebreaker?Equal sort values make the page edge ambiguous.

A cheat sheet is a reminder, not a teacher. Each card links to the lesson that explains it step by step.