Shorts

1 / 189
System design · Requirements

Cut the scope, then put a number on every requirement

A design without requirements solves the wrong problem, and a design without numbers can't justify anything.

  • Name the 3 core actions as verbs. For Twitter: post, follow, read the timeline. Everything else hangs off these.
  • Cut features out loud: "I'll leave out DMs, search and ads." Cutting is a sign of seniority, not laziness.
  • Give every non-functional requirement a number: latency, availability, consistency, durability and scale.
  • No number from the interviewer? Propose one: "I'll assume 100M daily users; tell me if that's off."
  • Talk about p99, not averages: the slowest 1% of requests are the ones users complain about.

What each target forces

  • Latency (p99 under 200 ms): caches, a CDN, precomputation.
  • Availability (99.99%): redundancy across zones, no single points of failure, graceful degradation.
  • Durability (never lose an order): replication, a write-ahead log, backups.
  • Scale (100M DAU): sharding, queues, horizontal scale.
  • Consistency (strong for payments, eventual for likes): different stores per feature.

Numbers to know

  • 99.9% allows about 8.8 hours of downtime a year; 99.99% about 53 minutes.

Why it protects you

  • Once scope is written down, "and it should support video" at minute 30 becomes "that changes the storage estimate".
Go deeperRequirements and Estimation
DSA · Hashing

Replace the inner search with one O(1) lookup

Most O(n²) brute forces only scan for a partner or a repeat, and a hash map turns that scan into one O(1) lookup.

  • Spot it: you keep asking "have I seen this before?", or you need how many times something occurs.
  • Spot it: each element needs a partner such as target - x, or items that share a property must be grouped.
  • Store what each element tells you as you pass it: presence in a Set, or a count, index or group in a Map.
  • The map describes only the elements before the current one, so look up, then insert: nothing pairs with itself.

Template

  • Seen set: has, then add. Frequency map: count once, then answer "how many?" in O(1).
  • Group by a canonical key that makes equivalent items collide: sorted letters for anagrams, "r,c" for cells.
  • For lowercase letters, an array of 26 indexed by ch.charCodeAt(0) - 97 is a perfect hash: faster than a Map.

Complexity

  • O(n) time with O(1) average lookups, paid for with O(n) memory. Grouping by sorted key costs O(n · k log k).

Watch out

  • Inserting before looking up lets an element pair with itself: [3, 4] with target 6 returns [0, 0].
  • if (map.get(k)) fails when the stored index or count is 0. Use has.
  • Arrays and objects as keys never match a fresh copy: encode composite keys as strings or numbers.
function countPairs(nums, target) {
  const seen = new Map(); // value -> times seen so far
  let pairs = 0;
  for (const x of nums) {
    pairs += seen.get(target - x) ?? 0;  // look up first...
    seen.set(x, (seen.get(x) ?? 0) + 1); // ...then record x
  }
  return pairs;
}
Go deeperHash Maps and Sets
Building blocks · Bloom filter

A Bloom filter answers "definitely not" in a few bytes per item

Checking whether a key exists is expensive when the answer lives on disk or across the network. Most of the time the answer is no.

  • A bit array of m bits plus k hash functions. Adding a key sets the k bits its hashes point to.
  • To check a key, read its k bits. Any bit is 0: definitely not present. All are 1: maybe present.
  • No false negatives, ever. False positives happen when other keys happened to set all k bits.
  • About 9.6 bits per item gives a 1% false-positive rate, whatever the size of the keys.

Where it's used

  • Cache penetration: keep a filter of real ids so lookups for ids that don't exist never reach the database.
  • LSM-tree stores (Cassandra, RocksDB, HBase) keep one per file to skip disk reads for keys it can't contain.
  • CDNs use one to skip caching one-hit wonders: an object is cached only on its second request.

Watch out

  • You can't delete from a plain Bloom filter: clearing a bit may remove other keys. A counting Bloom filter can.
  • It doesn't grow. Size it for the final item count, or the false-positive rate climbs as it fills.
  • 1% at 1M items is about 1.2 MB with 7 hashes; 0.1% needs about 14.4 bits per item and 10 hashes.
// m bits and k hash functions for n items at false-positive rate p
function bloomSize(n, p) {
  const m = Math.ceil((-n * Math.log(p)) / Math.LN2 ** 2);
  const k = Math.round((m / n) * Math.LN2);
  return { m, k };
}

bloomSize(1e6, 0.01); // { m: 9585059, k: 7 }, about 1.2 MB
Related lessonCaching Techniques
System design · Caching

Pick a caching pattern by what happens when the cache dies

Every caching pattern answers one question differently: who keeps the copy in sync with the database, and when?

  • Cache-aside is the default: simple, works with any store, and losing Redis only costs latency.
  • Write-through writes the database and the cache together. Reads are always fresh; writes cost double.
  • Write-behind writes the cache now and the database later in batches. Very fast, but a crash loses unflushed writes.
  • Write-around writes only the database, so write-once, rarely read data never pollutes the cache.

How to pick

  • Users must see their own write immediately? Write-through, or delete the key and read your own write from the primary.
  • The database can't keep up with writes and losing a few seconds is fine? Write-behind, for view counters and analytics.
  • Audit logs and old orders: write-around. A handful of very hot keys whose misses hurt: refresh-ahead.

Watch out

  • Never use write-behind for money or orders. Flushing on shutdown shrinks the loss window, but a crash still loses the buffer.
  • Read-through behaves like cache-aside. The difference is who runs the miss path: the cache's loader, not your code.
Go deeperCaching Techniques
CS fundamentals · Threads

A process owns memory; threads share it

Say what is shared and what is private before the interviewer asks, and the rest of the answer follows.

  • A process has its own address space, open files and PID. Another process cannot touch its memory.
  • Threads inside it share the heap, code and open files, but each has its own stack and registers.
  • Sharing makes threads cheap to start and talk to. It also makes counter++ a race.
  • A crash in one thread kills the whole process. A crash in one process leaves the others alone.

Pick one

  • Processes when failure or security must stay contained: browser tabs, Postgres connections.
  • Threads when work shares state and is CPU-bound.
  • An event loop when thousands of connections mostly wait.
Go deeperProcesses, Threads and Context Switches
JavaScript · Truthiness

Index 0 is a real answer, and it is falsy

Many wrong answers in JavaScript aren't algorithm bugs but a 0 read as "not found", or a fallback that throws away a real 0.

  • Exactly eight values are falsy: false, 0, -0, 0n, '', null, undefined and NaN. [] and '0' are truthy.
  • if (idx) after indexOf skips index 0. Compare with idx !== -1, or use includes for a yes or no.
  • if (map.get(k)) is false when the stored count or index is 0. Ask map.has(k).
  • count || 1 throws away a real 0. count ?? 1 only falls back on null or undefined.

Equality

  • Always use ===: == converts types, so 0 == '' is true. The one useful exception is x == null.
  • typeof null and typeof [] are both 'object'. Test x === null and Array.isArray(x).

References

  • Arrays, objects, Maps and Sets are references: res.push(path) stores the live array. Push [...path].
  • nums = nums.filter(…) only rebinds your local name. To change the input in place, write into nums[i].
const nums = [7, 3, 9];
const i = nums.indexOf(7);          // 0: found at index 0
if (i) console.log("never runs");   // 0 is falsy
if (i !== -1) console.log("found"); // test what you mean

const count = new Map([["a", 0]]);
console.log(count.get("a") || 5);   // 5: the real 0 is lost
console.log(count.get("a") ?? 5);   // 0: ?? skips null/undefined only
console.log(count.has("a"));        // true
Go deeperVariables, types and equality
System design · Estimation

10M daily users is about 3,500 requests a second at peak

You need the right order of magnitude in under five minutes, because every number you say should justify a design choice later.

  • Average QPS = DAU × actions ÷ 86,400. Round 86,400 to 100,000 and say "about".
  • Peak is 2–5× the average for normal daily traffic, and 10× or more for sports, sales and launches.
  • Storage = writes/day × size × retention × replicas. Forgetting replicas is the most common mistake.
  • Servers = peak ÷ (per-server QPS × 0.6), so each instance runs about 60% busy at peak.
  • Split reads and writes: reads scale with caches and replicas, writes with sharding and queues.

Worked example

  • 10M DAU × 10 actions at 10:1 reads is ~1,160/s on average: about 1,050 reads and 105 writes.
  • At a 3× peak that's ~3,500/s: 6 servers at 1,000 req/s each, 60% busy.
  • Storage is ~50 TB over 5 years at 1 KB per write with 3 replicas. Ignore replicas and you'd say 17 TB.
  • Caching 20% of a day's reads at 1 KB each takes ~18 GB: it fits on one Redis node.

Watch out

  • Mixing bits and bytes: Gbps vs GB/s is a factor of 8.
  • Spending 10 minutes on arithmetic. Round, state the result, move on.
  • Never using the numbers afterwards. Every estimate should end in "so we need…".
const qps = (dau, actions) => (dau * actions) / 86_400;
const servers = (peak, perServer = 1_000, busy = 0.6) =>
  Math.ceil(peak / (perServer * busy));

const avg = qps(10e6, 10); // ~1,157/s
const peak = avg * 3;      // ~3,472/s
servers(peak);             // 6
Go deeperRequirements and Estimation
DSA · Two pointers

Two indices, one pass: every step rules an element out

On sorted or mirrored input, two pointers replace an O(n²) nested loop with one O(n) pass and usually O(1) extra space.

  • Spot it: the input is sorted (or you may sort it) and you need a pair or triplet with a target sum.
  • Spot it: the answer must be built in place, or you compare a string with its mirror image.
  • If nums[left] + nums[right] is too small, nums[left] is too small for every remaining partner: drop it with left++.
  • Each comparison discards one index for good. If you can't say what is ruled out, two pointers is the wrong tool.

Variants

  • Opposite ends for sorted pairs and palindromes, read/write pointers to compact in place, one per input to merge.
  • Unsorted input and you need the original indices: use a hash map instead.

Complexity

  • O(n) time and O(1) space on sorted input, O(n log n) if you sort first. 3Sum (fix one, then walk) is O(n²).

Watch out

  • nums.sort() without a comparator compares strings: [10, 9, 1] becomes [1, 10, 9].
  • left <= right lets an element pair with itself. Pairs need left < right.
  • Forgetting to move a pointer after recording a match means the loop never ends.
function pairSum(nums, target) { // nums sorted ascending
  let left = 0, right = nums.length - 1;
  while (left < right) {
    const sum = nums[left] + nums[right];
    if (sum === target) return [left, right];
    if (sum < target) left++; // nums[left] is too small for all
    else right--;             // nums[right] is too big for all
  }
  return [-1, -1];
}
Go deeperTwo Pointers
Building blocks · Gossip

Tell a few random peers each second, and soon every node knows

Every node in a cluster needs to know who's alive, without a central server that can fail or become a bottleneck.

  • Every second or so, each node picks a few random peers and swaps what it knows: members, heartbeats, versions.
  • Each entry carries a version number, so a merge keeps the newer one and old news never overwrites new.
  • News spreads like a rumour and reaches every node in O(log n) rounds: 100x the nodes costs only a few more rounds.
  • Each node sends a fixed few messages per round, so its load stays flat as the cluster grows. There's no leader to lose.
  • It's eventually consistent: for a few rounds, different nodes hold different views of the cluster.

Where it's used

  • Cassandra gossips node state and schema versions every second with up to three other nodes.
  • Consul agents gossip through Serf, which is built on the SWIM protocol, for membership and failure detection.
  • Redis Cluster nodes gossip over a cluster bus to share slot ownership and reports of failed nodes.
  • Amazon's Dynamo paper used gossip to spread membership changes around the ring.

Watch out

  • Gossip can't settle what everyone must agree on now, like who leads. Consul gossips membership but uses Raft for its catalog.
  • State grows with the cluster, so nodes first swap digests (node, version) and then send only what the other lacks.
  • A removed node can be gossiped back to life by a peer with a stale view, unless removals are versioned too.
function gossipRounds(n, fanout = 3) { // node 0 has news to spread
  const knows = new Set([0]);
  const randomPeer = () => Math.floor(Math.random() * n);
  for (let round = 1; ; round++) {
    const sends = knows.size * fanout; // everyone who knows tells 3
    for (let i = 0; i < sends; i++) knows.add(randomPeer());
    if (knows.size === n) return round;
  }
}
gossipRounds(1_000); // about 8; with 100_000 nodes, about 13
System design · Caching

An in-process cache answers in microseconds, but 50 copies go stale

A map inside each app instance absorbs hot keys before they reach Redis. The catch: every instance holds its own copy to invalidate.

  • L1 is an in-process map in front of L2, the shared Redis. L1 answers in microseconds; a Redis hit costs about 1 ms.
  • Every instance holds its own L1 copy, so a write must fan out invalidations to all of them over pub/sub.
  • Pub/sub is fire-and-forget: an instance that misses a message keeps its stale copy until the L1 TTL.
  • So keep L1 TTLs short, or use L1 only for data that tolerates seconds of staleness.

Watch out

  • An in-process cache with no TTL, trusting pub/sub alone, is a classic mistake.
  • Single flight collapses concurrent misses inside one process only. With 50 instances, up to 50 rebuilds can still hit the database.
  • Across instances, combine single flight with a Redis SET lock NX.

Only when you've measured

  • Refresh-ahead: once 80% of a key's TTL has passed, the next read refreshes it in the background, so a hot key never expires.
  • Bloom filter: a set of every real id that answers "definitely not present" in memory, so lookups for missing ids stop early.
  • A Bloom filter with 100K keys in 1M bits measured about 0.65% false positives and zero false negatives.
async function get(key, load) {
  const e = l1.get(key);
  if (e && e.exp > Date.now()) return e.value;  // L1: microseconds
  const cached = await redis.get(key);          // L2: about 1 ms
  const value = cached ? JSON.parse(cached) : await load(key);
  if (!cached) await redis.set(key, JSON.stringify(value), 'EX', 300);
  l1.set(key, { value, exp: Date.now() + 5000 }); // short L1 TTL
  return value;
}
Go deeperCaching Techniques
CS fundamentals · Context switch

A context switch costs more than the registers

Saving registers takes microseconds. The slow part is the cold cache and TLB the next thread inherits.

  • The kernel saves the running thread's registers, including program counter and stack pointer, then loads the next thread's.
  • A switch between processes also changes the page tables, so the TLB may be flushed.
  • A switch between threads of one process keeps the address space, so it is cheaper.
  • With a 50 µs slice and a 5 µs switch, about 9% of the CPU is spent switching.

Why slices are milliseconds

  • Long slices hide the switch cost but make the system feel slow to respond.
  • Thousands of runnable threads mean constant switching and cache misses, so cap the pool.
const overhead = (slice, sw, refill = 0) =>
  (sw + refill) / (slice + sw + refill);

overhead(4000, 5);   // 0.00125
overhead(50, 5);     // 0.09
Go deeperProcesses, Threads and Context Switches
JavaScript · Numbers

There is no integer division, and -7 % 3 is -1

Midpoints, ring buffers and hash buckets need integer maths, and JavaScript gives you floats and a signed remainder instead.

  • a / b always returns a float: 7 / 2 is 3.5. Pick the rounding: Math.floor rounds down, Math.trunc toward zero.
  • They differ on negatives: Math.floor(-7 / 2) is -4, Math.trunc(-7 / 2) is -3. Round toward zero means trunc.
  • % is a remainder, not a modulo: -7 % 3 is -1. Use ((a % m) + m) % m for indices and buckets.
  • Test odd numbers with x % 2 !== 0: -3 % 2 is -1, so === 1 misses negatives.

32-bit shortcuts

  • x | 0, ~~x and >> 1 truncate too, but wrap around above 2^31 − 1: 3e9 | 0 is negative.
  • For a midpoint over a big answer range, use Math.floor((lo + hi) / 2), not (lo + hi) >> 1.

Ceiling division

  • Groups of k for n items: Math.ceil(n / k), or Math.floor((n + k - 1) / k) in integers.
console.log(7 / 2, Math.floor(7 / 2));              // 3.5 3
console.log(Math.floor(-7 / 2), Math.trunc(-7 / 2)); // -4 -3
console.log(-7 % 3);                                // -1, not 2
const mod = (a, m) => ((a % m) + m) % m;
console.log(mod(-7, 3));                            // 2
const ring = ["a", "b", "c", "d", "e"];
console.log(ring[mod(0 - 1, ring.length)]);         // "e"
console.log(3e9 | 0);                               // -1294967296
Go deeperNumbers, Math and integer tricks
System design · Estimation

One cross-continent round trip costs 10 to 100 database reads

You don't need exact values, just the orders of magnitude. The latencies matter most, because they shape the design.

  • A main memory reference takes ~100 ns. An SSD random read takes ~100 µs, about 1,000× slower.
  • A round trip inside a data centre is ~0.5 ms; a Redis GET over the network is ~0.5–1 ms.
  • An indexed database read takes ~1–10 ms; an HDD seek ~10 ms.
  • A cross-continent round trip is ~100–150 ms.

Throughput to assume

  • A typical web API instance: hundreds to a few thousand req/s. Say "I'd measure".
  • One Redis instance: ~100K simple ops/s.
  • One Postgres primary: thousands to tens of thousands of simple queries/s, depending heavily on the query.

Shortcuts

  • A day is 86,400 s ≈ 10⁵, so 1M requests a day ≈ 12/s.
  • A month is ≈ 2.6M s, so 100M a month ≈ 40/s.
  • 2¹⁰, 2²⁰, 2³⁰, 2⁴⁰ ≈ a thousand, million, billion, trillion: KB, MB, GB, TB.
Go deeperRequirements and Estimation
DSA · Sliding window

Update the window as it slides instead of recomputing it

Each element enters the window once and leaves once, so an O(n · k) or O(n²) scan over subarrays becomes O(n).

  • Spot it: the answer is a contiguous subarray or substring, and you want the longest, the shortest or a count.
  • Spot it: the rule has a bound (at most k distinct, sum at least target, no repeats) or a fixed size k.
  • Keep [left, right] plus a running summary (a sum, a Map of counts, a Set) and update it as elements enter and leave.
  • A variable window needs a monotonic rule: shrinking a valid window keeps it valid, so left never moves back.

When left moves

  • Fixed: once wider than k. Longest: shrink while invalid, then record. Shortest: shrink while valid, recording each time.
  • For a window maximum or minimum, a monotonic deque of indices keeps the scan O(n).

Complexity

  • O(n): neither pointer moves backwards, so the while inside the for makes at most 2n moves in total.

Watch out

  • Shrinking with if instead of while leaves the window invalid when several elements must go.
  • Decrementing a count without deleting it at zero makes count.size overstate the distinct values.
  • Negative numbers break sum windows: use prefix sums instead.
function longestNoRepeat(s) {
  const inWindow = new Set();
  let left = 0, best = 0;
  for (let right = 0; right < s.length; right++) {
    while (inWindow.has(s[right])) inWindow.delete(s[left++]);
    inWindow.add(s[right]);
    best = Math.max(best, right - left + 1); // record after shrinking
  }
  return best;
}
Go deeperSliding Window
Building blocks · TCP vs UDP

TCP never loses a byte; UDP never waits for one

Almost every protocol you'll name sits on one of these two, and the choice decides what a single lost packet costs you.

  • TCP is an ordered, reliable byte stream: it numbers every byte, tracks acknowledgements and retransmits anything lost.
  • You pay a handshake (1 round trip) before any data, and congestion control slows the sender when packets drop.
  • Head-of-line blocking: one lost packet holds back every byte behind it until the retransmit arrives, at least a round trip later.
  • UDP sends independent datagrams: no connection, no order, no retransmits. Just ports, a length and a checksum in 8 bytes.
  • Use TCP when every byte matters (APIs, payments, databases) and UDP when late is as bad as lost (games, voice, live video).

Where UDP fits

  • Voice and video calls: a retransmitted frame would arrive too late to play, so the app skips it and hides the gap.
  • Games send state many times a second. The next update replaces a lost one, so waiting for a retransmit is pointless.
  • DNS fits a query and its answer in one datagram each: one round trip, no handshake. Large answers fall back to TCP.
  • QUIC, under HTTP/3, builds reliable, encrypted streams on top of UDP, so a lost packet stalls only its own stream.

Watch out

  • UDP has no congestion control. The app must pace itself, or it floods the link and loses even more.
  • Need some reliability over UDP? You rebuild acks and retransmits yourself, only for the messages that need them.
  • Some networks block or throttle UDP, so UDP protocols need a TCP fallback: browsers drop from HTTP/3 to HTTP/2.
Related lessonConnections: Pooling, Keep-Alive and Limits
System design · Sharding

Add a fifth node and hash % N moves 80% of your keys

For a cache, that's an 80% miss storm. Consistent hashing moves only the new node's share: about 20%.

  • With hash % N, a key stays put only if hash mod N equals hash mod (N+1), so N/(N+1) of all keys move.
  • Consistent hashing puts nodes and keys on one circle. A key belongs to the first node clockwise.
  • Add a node and only its neighbours' keys move: about 1/(N+1) of the total, all of them to the new node.
  • Give each node many virtual nodes so the arcs, and the load, even out.

Why virtual nodes

  • With one point per node the arcs differ a lot, so one node can own twice its fair share.
  • When a node fails, its keys spread across many survivors instead of doubling one neighbour's load.
  • In a test with 20K keys, adding a 5th node moves 19% of them, every one to the new node.

Alternatives

  • Rendezvous hashing: each key scores every node and picks the highest. No ring, fine for tens of nodes.
  • Fixed logical partitions: hash % 1024 never changes, and you move whole partitions. Redis Cluster has 16,384 slots.
function nodeFor(ring, key) {     // ring: [{ pos, node }], sorted
  const h = hash(key);
  let lo = 0, hi = ring.length;
  while (lo < hi) {                   // first point at or after h
    const mid = (lo + hi) >> 1;
    if (ring[mid].pos < h) lo = mid + 1; else hi = mid;
  }
  return ring[lo % ring.length].node; // wrap around the ring
}
Go deeperSharding Strategies
CS fundamentals · Scheduling

No scheduler wins on wait, fairness and overhead together

The classic example: jobs of 24, 3 and 3 ms give averages of 17, 3 or 5.7 ms depending on the policy.

  • First come, first served: simple, but a long job in front makes every short job wait (17 ms average).
  • Shortest job first: the lowest average wait (3 ms), but it needs burst times and can starve long jobs.
  • Round robin: each job gets a quantum, so nobody waits long and no burst times are needed (about 5.7 ms).
  • A quantum that is too small wastes time switching. Too large and it becomes first come, first served.

Real schedulers

  • A multilevel feedback queue demotes jobs that burn their whole quantum and favours those that block on I/O.
  • Priority inversion: a low-priority lock holder blocks a high-priority waiter. Priority inheritance fixes it.
Go deeperScheduling and Memory: Virtual Memory, Paging and the TLB
JavaScript · Numbers

Past 2^53 a product loses digits without any error

Sums of interview-sized values stay exact, but a product of two values near 10^9 does not, so (a * b) % MOD quietly goes wrong.

  • JavaScript has one number type: a 64-bit float that holds integers exactly up to 2^53, about 9 × 10^15.
  • Sums are rarely a problem: 10^5 values up to 10^9 add up to 10^14. Products are: two values near 10^9 make 10^18.
  • For "modulo 1e9 + 7", reduce after every addition so values stay small and exact.
  • For (a * b) % MOD, multiply as BigInt, take the remainder, and convert back.

BigInt rules

  • BigInt is exact at any size but slower, can't be mixed with numbers in arithmetic, and doesn't work with Math.*.
  • Factorials past 18!, Fibonacci past F(78) and 2 ** n past n = 53 lose precision the same way.

Sentinels

  • Infinity is the natural start for a running minimum and for unreached DP cells: Infinity + 5 is still Infinity.
  • Convert on the way out: best === Infinity ? -1 : best.
const MOD = 1_000_000_007;
const mulMod = (x, y) =>
  Number((BigInt(x) * BigInt(y)) % BigInt(MOD));
const a = 999_999_999;
console.log((a * a) % MOD); // wrong: a * a is past 2^53
console.log(mulMod(a, a));  // exact
let ways = 1; // reduce after every step so values stay small
for (let i = 0; i < 100; i++) ways = (ways * 2) % MOD;
console.log(ways); // 976371285, which is 2^100 mod 1e9 + 7
Go deeperNumbers, Math and integer tricks
System design · Load balancing

Round robin keeps feeding a slow server. Check two instead

The algorithm decides which healthy instance gets the next request, and there's no single best one: it depends on your servers and requests.

  • Round robin ignores load: with four servers, a slow one still gets one request in four, so its queue keeps growing.
  • Least connections picks the instance with the fewest in-flight requests: good for uploads and streaming.
  • Power of two choices (P2C) samples 2 servers at random and takes the less loaded one.
  • P2C gets almost the balance of least connections with no global scan and no shared state between balancers.
  • Consistent hashing gives affinity for per-key caches and shards, but a hot key overloads one server.

Other picks

  • Weighted round robin suits mixed instance sizes or a 5% canary, but static weights don't react to slowness.
  • Smooth weighted round robin (nginx) turns weights 5, 1, 1 into a a b a c a a, not five as in a row.
  • Envoy's least-request policy and NGINX's random two least_conn both work like P2C.

L4 or L7

  • L4 forwards TCP by IP and port: very cheap, any protocol.
  • L7 reads HTTP, so it can route by path, terminate TLS, retry and run canaries.
  • Most web stacks use both: an L4 balancer in front of a fleet of L7 proxies.

Before any algorithm

  • Balancing only works if no instance owns state: sessions in Redis, files in object storage, data in the database.
  • Sticky sessions can help warm caches or WebSockets, but never as the only place state lives.
function pickP2C(servers) {
  const healthy = servers.filter((s) => s.healthy);
  if (healthy.length < 2) return healthy[0];
  const i = Math.floor(Math.random() * healthy.length);
  let j = Math.floor(Math.random() * (healthy.length - 1));
  if (j === i) j = healthy.length - 1; // j !== i, uniformly
  const a = healthy[i], b = healthy[j];
  return a.active <= b.active ? a : b;
}
Go deeperClient, API and Load Balancing
DSA · Prefix sums

Any range sum is one subtraction of two prefixes

Build running totals once in O(n) and every range-sum question after that costs O(1), even with negative numbers.

  • Spot it: the sum of a range asked many times, or subarray sums to k on data that may hold negatives.
  • Spot it: many range updates (add 5 to seats 3 to 9) followed by one read: that's a difference array.
  • With prefix[0] = 0, sum(l..r) = prefix[r + 1] - prefix[l]. The leading 0 handles ranges that start at index 0.
  • A subarray ending at j sums to k when an earlier prefix equals sum - k: a hash map lookup.

Template

  • Counting subarrays: map prefix sum to count, seeded 0 → 1. Longest: map to the first index, seeded 0 → -1.
  • Difference array: +delta where a range starts, -delta just after it ends, then one running sum rebuilds the values.

Complexity

  • O(n) to build, then O(1) per range query. Prefix sum + hash map is one O(n) pass with O(n) memory.

Watch out

  • Forgetting the seed misses subarrays that start at index 0.
  • Inserting the current sum before looking up sum - k counts empty subarrays when k is 0.
  • Reading past either end returns undefined, and 5 - undefined is NaN, not an error.
function countSubarrays(nums, k) { // subarrays summing to k
  const seen = new Map([[0, 1]]); // the empty prefix, seen once
  let sum = 0, count = 0;
  for (const x of nums) {
    sum += x;
    count += seen.get(sum - k) ?? 0; // look up first...
    seen.set(sum, (seen.get(sum) ?? 0) + 1); // ...then record
  }
  return count;
}
Go deeperPrefix Sums and Difference Arrays
Building blocks · HyperLogLog

Count a billion unique visitors in 12 KB of memory

An exact distinct count has to remember every id it has seen. A dashboard only needs to be within a percent or so.

  • Hash every item. About 1 in 2^k hashes starts with k zero bits, so the longest run of leading zeros hints at how many items you've seen.
  • One run is noisy, so the first bits of each hash pick one of m registers. Each keeps its longest run, and a harmonic mean combines them.
  • A repeat hashes to the same value and changes nothing, so it counts distinct items, not events.
  • In Redis, PFADD adds items and PFCOUNT estimates. A key takes at most 12 KB, with a standard error of about 0.81%.
  • Registers merge by taking the max, so daily counts roll up into a weekly one: PFMERGE, or PFCOUNT on several keys.

Why not a Set

  • An exact Set of 100M 16-byte ids is at least 1.6 GB before any overhead, and you'd need one per page per day.
  • A HyperLogLog stays at 12 KB however many items you add, and Redis stores small ones in a sparse, smaller encoding.

Numbers to know

  • Standard error is 1.04 / √m, so 4× the registers halves the error.
  • At 1M uniques, 0.81% is a typical error of about ±8,000; at 1B, about ±8 million. Fine for a dashboard, not for billing.

Watch out

  • It can't list members or tell you whether one user was counted. Use a Set or a Bloom filter for that.
  • You can't remove an item, and intersections worked out by inclusion-exclusion are badly off when the overlap is small.
// Redis HyperLogLog: 2^14 registers of 6 bits each
const m = 2 ** 14;                    // 16384 registers
const bytes = (m * 6) / 8;            // 12288 bytes = 12 KB
const stdError = 1.04 / Math.sqrt(m); // 0.008125, about 0.81%
Related lessonHash Maps and Sets
System design · Sharding

The shard key matters more than the sharding algorithm

It decides which queries stay on one shard and whether the load spreads evenly. Pick it from your main query.

  • High cardinality: country has about 200 values, so you can never have more than 200 useful shards.
  • Even spread: no single value should own a large share of rows or traffic.
  • Match the dominant query: if most queries are "this user's orders", shard by user_id so they hit one shard.
  • No hot ranges: range-shard on a timestamp or auto-increment id and every new write lands on the newest shard.
  • When one key can't serve two big access patterns, store the data twice: orders by buyer and by seller.

Queries without the key

  • They must ask every shard. Scatter-gather is fine for admin and reporting; its latency is the slowest shard's.
  • A global secondary index (email → user_id) points other lookups at the right shard.
  • Keep data that changes together on one shard. When a workflow must span shards, use a saga with compensating actions.

Hot keys

  • A good key can still have one hot value. Salt it: write to a random sub-key (likes:42:0 … likes:42:15), sum on read.
  • Reads get N times more expensive, so salt only the keys you've measured as hot.

Usual answers

  • user_id, or tenant_id in SaaS, with a directory so the few huge tenants can get their own shards.
  • Time series: hash by sensor and keep time as the sort key inside each shard, or use (sensor_id, day).
Go deeperSharding Strategies
CS fundamentals · Virtual memory

Virtual memory: translate the page number, keep the offset

Each process gets a private address space. A tiny cache, the TLB, keeps the translation from slowing every access.

  • Memory is cut into pages (usually 4 KiB). A page table maps each page to a physical frame.
  • An address is a page number plus a 12-bit offset. Only the page number is translated: 0x3A7C is page 3, offset 0xA7C.
  • The TLB caches recent translations. A hit costs about a nanosecond; a miss walks the page table.
  • A page fault means the page is not in RAM yet: the kernel loads it and retries the instruction.

Thrashing

  • If the working set exceeds RAM, pages are evicted just before they are needed and the machine waits on disk.
  • A loop over 5 pages with 4 frames faults on every access under LRU.
Go deeperScheduling and Memory: Virtual Memory, Paging and the TLB
JavaScript · Strings

Strings are immutable, so build them in an array and join once

Every string "change" makes a new string, and knowing what copies is what keeps a character loop O(n) instead of O(n²).

  • s[i] = 'x' never changes a string: strict mode throws, sloppy mode silently ignores it. Edit a character array.
  • result += ch copies in principle. Engines often optimise plain appends, but you can't rely on it for Big-O.
  • The idiom that is linear in every engine: push pieces into an array and call join('') once at the end.
  • slice, includes and indexOf are O(n): calling them in a loop over the same string is a hidden nested loop.

Characters

  • s.charCodeAt(i) - 97 maps a to z onto 0 to 25: an array of 26 counters beats a Map for lowercase letters.
  • [...s] and for...of split by character; prefer slice over substring and the deprecated substr.

Watch out

  • Strings compare by code unit: 'Z' < 'a' and '10' < '9'. Use localeCompare for dictionary order.
  • '7' + 1 is '71'. Convert digits with Number(ch).
  • s.split(' ') on repeated spaces gives empty strings: use s.trim().split(/\s+/).
function removeAdjacentPairs(s) { // "abbaca" -> "ca"
  const stack = []; // an array of characters, joined once
  for (const ch of s) {
    if (stack.at(-1) === ch) stack.pop();
    else stack.push(ch);
  }
  return stack.join("");
}
const chars = [..."stressed"]; // edit an array, not the string
console.log(chars.reverse().join("")); // "desserts"
Go deeperStrings and characters
System design · Load balancing

One wrong health check can take your whole fleet out at once

The balancer can only route around failures it can see, so liveness and readiness must ask different questions.

  • Liveness asks "is the process alive?" A failure means restart it.
  • Readiness asks "should it get traffic right now?" A failure means stop routing here.
  • Failing liveness because the database is down causes restart loops.
  • Failing readiness on an optional dependency, like recommendations, takes the whole fleet out at once.

Active and passive checks

  • Active checks call /readyz every few seconds, for example every 5 s with a 2 s timeout.
  • Use thresholds so one blip doesn't flap an instance: 3 failures to mark it down, 2 successes to mark it up.
  • Passive checks watch real traffic and eject an instance after, say, 5 consecutive 5xx responses, for 30 s. They react faster.

Deploys

  • Connection draining: on SIGTERM, readiness fails, the balancer stops sending new requests, in-flight ones finish, then the process exits.
  • Without draining, every deploy drops requests.
let shuttingDown = false;
app.get("/readyz", async (_req, res) => { // send traffic here?
  if (shuttingDown) return res.sendStatus(503);
  try { await pool.query("SELECT 1"); res.sendStatus(200); }
  catch { res.sendStatus(503); }
});
process.on("SIGTERM", () => {
  shuttingDown = true; // readiness fails, the LB stops routing
  setTimeout(() => server.close(() => process.exit(0)), 10_000);
});
Go deeperClient, API and Load Balancing
DSA · Binary search

Halve the range that must contain the answer

Halving means a million candidates need about 20 checks, and it works on anything monotonic, including the answer itself.

  • Spot it: the input is sorted or rotated, or you need the first or last position or an insert point.
  • Spot it: "minimum speed or capacity such that…", "minimise the maximum", or answers as large as 10^9.
  • Invariant: if the answer exists it is inside [lo, hi], and each step proves one half can't hold it.
  • Learn one template, first true: ok(x) goes false then true, loop lo < hi, set hi = mid or lo = mid + 1.

Beyond sorted arrays

  • On the answer: search a range of values with a feasibility check, from the smallest that could work to one that surely does.
  • Rotated array: one half is always sorted. Check whether the target sits inside that half to choose which to keep.

Complexity

  • O(log n) time, O(1) space. On an answer range R with an O(n) check: O(n log R).

Watch out

  • lo <= hi with hi = mid never ends; lo < hi with hi = mid - 1 can skip the answer.
  • (lo + hi) >> 1 turns negative past about 2.1 billion. Use Math.floor((lo + hi) / 2).
  • A lower bound returns a position even when the target is missing: check nums[i] === target.
// First x in [lo, hi) where ok(x) is true, or hi if none.
function firstTrue(lo, hi, ok) {
  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    if (ok(mid)) hi = mid; // mid might be the first true
    else lo = mid + 1;     // mid and everything before fail
  }
  return lo;
}
const lowerBound = (a, x) => firstTrue(0, a.length, (i) => a[i] >= x);
Go deeperBinary Search
Building blocks · Failure detection

A dead node and a slow one look exactly the same from outside

Failover, leader election and load balancing all start by declaring a node dead, and that verdict is always a guess.

  • Nodes send a heartbeat every second or so. If none arrives within a timeout, the node is suspected dead.
  • Silence could be a crash, a long GC pause, an overloaded node or a broken network. From outside you can't tell which.
  • A short timeout detects failures fast, but false alarms trigger failovers and rebalancing that add load at the worst time.
  • A long timeout raises fewer false alarms, but traffic keeps flowing to a dead node until it fires.
  • Some verdicts will be wrong: a node declared dead may still be running, so fence it before anything takes over.

Phi accrual detectors

  • Instead of a yes or no, a phi accrual detector outputs a suspicion level from the heartbeat gaps it has observed.
  • Phi is −log10 of the chance a healthy node's heartbeat is this late: phi 3 means about 0.1%, phi 8 one in 100 million.
  • On a jittery network the usual gap is longer, so the same silence raises phi more slowly. The detector adapts.
  • Cassandra and Akka use phi accrual detectors, and both default to a threshold of 8.

In practice

  • Act only after several missed heartbeats, or when a quorum of observers agrees, so one blip doesn't trigger a failover.
  • When a direct ping fails, SWIM (Consul, Serf) asks a few other nodes to ping the suspect, so one bad link can't condemn it.
  • A Raft follower's election timeout is its failure detector: no heartbeat from the leader in time, start an election.
// Phi accrual, simplest model: heartbeat gaps are exponential
function phi(msSinceLast, gaps) { // gaps: recent intervals in ms
  const mean = gaps.reduce((a, b) => a + b) / gaps.length;
  return msSinceLast / (mean * Math.LN10); // -log10(P(this late))
}
phi(1_000, [1_000, 1_000]); // 0.43: on time
phi(7_000, [1_000, 1_000]); // 3.04: a 0.1% chance it's just late
phi(7_000, [3_000, 4_000]); // 0.87: normal on a slow network
Related lessonClient, API and Load Balancing
System design · Storage

Choose the database from the access pattern, not the product

No store is best at everything. Three questions about each kind of data pick the store, and most products end with Postgres.

  • Writes: how many per second, single-row or multi-row, and must they be atomic?
  • Reads: by key, by range, by text, or aggregated over millions of rows?
  • Correctness: is a lost or stale value an annoyance, like a view count, or a bug, like a balance?
  • Ask about transactions first: they are the hardest property to add later. Default to Postgres for anything transactional.

Principles

  • In key-value and wide-column stores the key is the query: a new access pattern needs a new table or index.
  • One source of truth per fact. Search, analytics and caches are fed by CDC or the outbox, and can always be rebuilt.
  • OLTP and OLAP don't share a database: a dashboard scanning 100M rows starves checkout on the same primary.
  • Boring by default: every extra store is another thing to back up, monitor, secure and wake up for.

Watch out

  • Never write to the database and the search index from the same request: one of the two writes will eventually fail.
  • Don't choose a NoSQL store "for scale" before you know the access patterns.
Go deeperStoring Data: Choosing the Right Store
CS fundamentals · Deadlock

Deadlock needs four conditions; break one and it is gone

Thread 1 holds A and wants B, thread 2 holds B and wants A. The interview answer is the four conditions and the cheapest fix.

  • Mutual exclusion: a resource is held by one thread at a time.
  • Hold and wait: a thread keeps what it has while it waits for more.
  • No preemption: nobody can take a held resource away.
  • Circular wait: a ring of threads each waits for the next one.
  • Cheapest prevention: take locks in one global order, which makes a ring impossible.

Other ways out

  • Timeouts: give up, release and retry. Simple, but the work may repeat.
  • Detection: find a cycle in the wait-for graph and abort one victim. Databases do this.
// one global order: lock the lower id first
const inLockOrder = (a, b) => (a < b ? [a, b] : [b, a]);

inLockOrder(7, 3); // [3, 7]
inLockOrder(3, 7); // [3, 7]
Go deeperLocks, Semaphores and Deadlock
JavaScript · Arrays

Array(n).fill([]) puts one shared row in every slot

Writing grid[0][0] then changes every row, one of the most common bugs in DP tables, grids and adjacency lists.

  • fill(value) evaluates value once and stores it in every slot. When it is an array, every row is one object.
  • So new Array(m).fill(new Array(n).fill(0)) and new Array(n).fill([]) both share a single inner array.
  • Array.from({ length: m }, () => …) calls the factory once per slot, so each row is its own array.
  • new Array(n) alone makes holes that map skips: new Array(n).map(…) does nothing useful. fill first.

Copies are shallow

  • [...grid] copies only the outer array. Copy a grid row by row: grid.map((row) => [...row]).
  • slice copies a range; splice mutates in place and returns what it removed.

Ends and costs

  • push, pop and at(-1) are O(1); shift and unshift move every element: O(n).
  • arr[-1] is undefined, a property named "-1". Use arr.at(-1).
const bad = new Array(3).fill(new Array(2).fill(0));
bad[0][0] = 1;
console.log(bad); // [[1, 0], [1, 0], [1, 0]]: one shared row

const grid = Array.from({ length: 3 }, () => new Array(2).fill(0));
grid[0][0] = 1;
console.log(grid); // [[1, 0], [0, 0], [0, 0]]

const adj = Array.from({ length: 4 }, () => []); // 4 separate lists
Go deeperArrays: create, read, update
System design · API design

A retried POST must not charge the card twice

Networks fail after the server has done the work, so clients retry. Without an idempotency key, the retry is a second payment.

  • The client generates one UUID per logical operation and sends it as Idempotency-Key on every retry.
  • The server claims the key atomically with SET NX, stores the response, and replays it for repeats.
  • A duplicate that arrives while the first is still running gets 409.
  • A 5xx releases the key, so the client can try again.
  • Never retry a POST automatically unless it carries an idempotency key.

Which methods are safe

  • GET, PUT and DELETE are idempotent by definition, so balancers and clients may retry them.
  • PATCH is not necessarily idempotent.
  • A POST that creates returns 201 with a Location header, and needs a key to be retried safely.

Watch out

  • If a handler crashes without responding, the key stays pending until its TTL expires, so keep TTLs modest.
async function claim(req, res) { // true: go ahead and do the work
  const key = `idem:${req.user.id}:${req.get("Idempotency-Key")}`;
  if (await redis.set(key, "pending", "EX", 86400, "NX")) return true;
  const saved = await redis.get(key);
  if (saved === "pending") { res.sendStatus(409); return false; }
  const { status, body } = JSON.parse(saved);
  res.status(status).json(body); // replay the first response
  return false;
}
// Then store { status, body } under the key, or DEL it on a 5xx.
Go deeperClient, API and Load Balancing
DSA · Stacks

A stack answers next-greater for every index in one pass

Brackets, undo steps and next-greater lookups all resolve the most recent unfinished item first, which is exactly what a stack hands back.

  • Spot it: things open and close in nested order (brackets, tags, .. in a path), or you undo the latest action.
  • Spot it: for each element, the next or previous greater or smaller element, or a span bounded by one.
  • Matching: push what opens; a closer must match the top. Counting isn't enough: ([)] has one of each bracket.
  • Monotonic stack: keep indices still waiting for an answer. A bigger value pops every smaller one: it is their answer.

Template

  • Push indices, not values: you need them to fill answer slots and to measure distances like i - j.
  • Flip the comparison for next smaller. Ties depend on < or <=, so read the problem for "strictly".

Complexity

  • O(n) amortised: every index is pushed once and popped at most once, despite the while inside the for.

Watch out

  • stack.at(-1) on an empty stack is undefined, and comparisons with it are silently false. Check stack.length.
  • Items left on the stack never got an answer: keep the default (-1), or flush them with a sentinel.
  • The top of a JavaScript stack is the end of the array. shift and unshift are O(n).
function nextGreater(nums) { // index of next bigger value, or -1
  const answer = new Array(nums.length).fill(-1);
  const stack = []; // indices still waiting for an answer
  for (let i = 0; i < nums.length; i++) {
    while (stack.length && nums[stack.at(-1)] < nums[i])
      answer[stack.pop()] = i; // nums[i] answers every smaller top
    stack.push(i);
  }
  return answer;
}
Go deeperStacks and Monotonic Stacks
Building blocks · HTTP versions

Each HTTP version removes the bottleneck the last one left behind

The version you serve decides how many connections a page needs and what one lost packet costs every request on them.

  • HTTP/1.1 handles one request at a time per connection, so browsers open about 6 connections per origin.
  • HTTP/2 multiplexes many streams over one TCP connection and compresses repeated headers with HPACK.
  • But TCP delivers bytes in order, so one lost packet stalls every stream on it: head-of-line blocking moves down a layer.
  • HTTP/3 runs over QUIC on UDP. Each stream recovers its own losses, so a dropped packet stalls only that stream.
  • QUIC combines the transport and TLS 1.3 handshakes into 1 round trip, and a resumed connection can send data in 0-RTT.

What HTTP/3 adds

  • Connection migration: a connection has its own ID, not just an IP and port, so it survives a switch from Wi-Fi to mobile.
  • Encryption is mandatory: QUIC always uses TLS 1.3 and also encrypts packet numbers and acks, which TCP sends in the clear.
  • Browsers learn a server speaks HTTP/3 from the Alt-Svc header or a DNS HTTPS record, and fall back to HTTP/2 if UDP fails.

Trade-offs

  • On a clean wired network HTTP/2 and HTTP/3 perform about the same. HTTP/3 wins on lossy mobile links and on first connect.
  • QUIC runs in user space, not the kernel, so it typically costs servers more CPU per byte than TCP.
  • 0-RTT data can be replayed by an attacker, so servers should accept only safe, idempotent requests like GET in it.
  • Domain sharding, an HTTP/1.1 trick for more connections, hurts under HTTP/2: it splits one multiplexed connection into several.
Related lessonConnections: Pooling, Keep-Alive and Limits
System design · Storage

Money in Postgres, videos in S3, search in Elasticsearch

At scale you end up with several stores, each strong at one access pattern and weak at others. Match each kind of data to a strength.

  • Relational (Postgres): money, orders, users. Transactions, constraints, joins and ad-hoc queries.
  • Key-value (Redis, DynamoDB): sessions, counters and single-key lookups at huge scale. No queries except by key.
  • Wide-column (Cassandra): write-heavy, time-ordered data such as chat messages, partitioned by conversation.
  • Object storage (S3): images and video. Keep only the key in your database; clients upload with presigned URLs.
  • Search (Elasticsearch) and columnar (ClickHouse) stores are derived: fed from the change stream, never the source of truth.

Why not blobs in Postgres

  • Large blobs bloat the database, backups and replication, and you can't serve them from a CDN.

DynamoDB or Postgres

  • DynamoDB when access patterns are few, known and key-based and scale is large or spiky: carts, sessions, device state.
  • Not when you need joins, ad-hoc reporting or multi-entity invariants.
  • A composite key (pk = USER#42, sk = ORDER#…) turns "latest orders for a user" into one range read.

Derived stores

  • Lose or corrupt Elasticsearch or ClickHouse and you rebuild it by replaying Kafka or re-snapshotting the source.
  • Keep Elasticsearch in sync with CDC (Debezium) or the outbox, and an indexer that consumes changes idempotently.
Go deeperStoring Data: Choosing the Right Store
CS fundamentals · Locks

Mutex, semaphore, condition variable: three jobs

Reaching for the wrong tool is the common mistake. Each one answers a different question.

  • A mutex lets one thread into a critical section. The thread that locked it unlocks it.
  • A semaphore counts permits: at most N holders. A pool of 10 connections is a semaphore of 10.
  • A condition variable lets a thread sleep until something is true, always paired with a mutex.
  • Wait in a loop: while (empty) wait(cond), because wake-ups can be spurious or stolen.

Keep locks small

  • Lock, touch the shared data, unlock. Never do I/O or call unknown code while holding a lock.
  • Even single-threaded JavaScript races across an await: read, await, write back a stale value.
Go deeperLocks, Semaphores and Deadlock
JavaScript · Sorting

sort() without a comparator sorts numbers as strings

It passes the sample tests with single digits and fails the hidden ones the moment a 10 shows up.

  • The default sort compares elements as strings: [10, 9, 1] becomes [1, 10, 9], and -1 lands before -5.
  • Always pass (a, b) => a - b for ascending, or (a, b) => b - a for descending.
  • A comparator returns a negative number to put a first, positive for b, 0 for a tie. A boolean breaks it.
  • Multi-key: a[0] - b[0] || a[1] - b[1]. A tie is 0, which is falsy, so || moves on to the next key.
  • sort mutates and returns the same array. Use toSorted or [...nums].sort(…) to keep the input.

Strings and stability

  • a - b on strings gives NaN, so nothing sorts. Use localeCompare or a three-way compare.
  • Since ES2019 sort is stable: elements that compare equal keep their original order.

Costs

  • O(n log n) comparisons. Compute expensive keys once, outside the comparator.
  • Values from a small range (letters, ages, scores): counting sort in O(n + k).
console.log([10, 9, 1, 100, 25].sort()); // [1, 10, 100, 25, 9]
console.log([10, 9, 1].sort((a, b) => a - b)); // [1, 9, 10]

const intervals = [[5, 8], [1, 4], [1, 2]];
intervals.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
console.log(intervals); // [[1, 2], [1, 4], [5, 8]]

const nums = [3, 1, 2];
const sorted = nums.toSorted((a, b) => a - b); // nums unchanged
Go deeperSorting and comparators
System design · Databases

Two doctors check the rota and both go off call: write skew

The default isolation levels in Postgres and MySQL both let two valid transactions commit and break a rule together.

  • Isolation decides what concurrent transactions can see of each other. Weaker levels allow more anomalies.
  • Write skew: two transactions read the same rows, each writes a different row, both commit, and a rule breaks.
  • Read committed (Postgres default) and repeatable read (MySQL InnoDB default) both allow it.
  • For invariants across rows, use SERIALIZABLE or SELECT … FOR UPDATE, and retry the aborted transactions.
  • Often a constraint is simpler: UNIQUE (room_id, slot) makes a double booking impossible at any level.

The other anomalies

  • Dirty read: you see another transaction's uncommitted change, which might still roll back.
  • Non-repeatable read: you read the same row twice and get different values.
  • Phantom: you re-run a range query and new rows appear that another transaction inserted.

Example: booking a slot

  • A check-then-insert at READ COMMITTED races: both transactions see the slot free, and both insert.
  • Fix it with a unique constraint, or a conditional UPDATE … WHERE status = 'available' that checks the row count.
  • SERIALIZABLE with a retry also works: rerun the whole transaction when Postgres aborts it.
Go deeperDatabases and Scaling
DSA · Intervals

Sort intervals by start and only neighbours can overlap

Comparing every pair of intervals is O(n²), but after one sort a single sweep merges, inserts or counts overlaps.

  • Spot it: a list of [start, end] pairs (meetings, bookings, ranges) and a question about overlap.
  • Spot it: "the minimum number of rooms, platforms or arrows", or "remove the fewest so none overlap".
  • Sorted by start, a group can never grow again once an interval starts after its end: close it at the first gap.
  • Sort by start to merge, insert or detect clashes; sort by end to keep as many intervals as possible.

Counting overlaps

  • Sweep line: +1 at each start, -1 at each end, sorted by time with ends first on ties. Track the running maximum.
  • Or keep a min-heap of end times, one per busy room: it also tells you which room each meeting gets.

Complexity

  • O(n log n) for the sort, then O(n). Inserting into a list that is already sorted is O(n): skip the sort.

Watch out

  • intervals.sort() compares arrays as strings, so [10, 12] lands before [9, 10].
  • Extend with Math.max(last[1], end), not end: a nested interval would shrink the group.
  • Decide whether touching intervals overlap: <= or < decides [1, 3] and [3, 5].
function merge(intervals) {
  const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
  const merged = [];
  for (const [start, end] of sorted) {
    const last = merged.at(-1);
    if (last && start <= last[1]) last[1] = Math.max(last[1], end);
    else merged.push([start, end]); // a gap: start a new group
  }
  return merged;
}
Go deeperIntervals and Sweep Line
Building blocks · Count-min sketch

Find trending hashtags without a counter for every hashtag

A firehose of events has too many distinct keys to count each one exactly, and you only care about the biggest ones.

  • Keep a grid of d rows × w counters, each row with its own hash function. Adding a key bumps one counter in every row.
  • To estimate a key's count, read its d counters and take the minimum: the row where it collided least.
  • Collisions only ever add, so it never underestimates. It can overestimate, by a small share of the whole stream.
  • Memory is fixed by d and w, however many distinct keys show up.
  • For top-k, pair it with a size-k min-heap: when a key's new estimate beats the heap's smallest, it takes that place.

Sizing

  • With w = ⌈e/ε⌉ and d = ⌈ln(1/δ)⌉, an estimate is at most εN too high (N = all events) with probability 1 − δ.
  • ε = 0.1% and δ = 1% gives 5 rows × 2,719 counters: about 54 KB of 32-bit counters, for any number of keys.
  • The error is a share of N, so heavy hitters come out accurate and rare keys are mostly noise.

In practice

  • Trending means recent: keep one sketch per minute and sum the last few, or old hits drown out new ones.
  • Sketches with the same shape and hashes add cell by cell, so per-server or per-minute sketches merge.
  • A key already in the heap just gets its count updated: keep a map from key to heap entry to find it.
  • Conservative update cuts overcounting: raise each of the key's counters only up to its new estimate, not all by one.
  • It's for ranking and spotting hot keys. Never use it where an overcount costs someone money.
const d = 5, w = 2719; // ε = 0.1%, δ = 1%: about 54 KB
const rows = Array.from({ length: d }, () => new Uint32Array(w));
function slot(key, row) { // FNV-1a, seeded differently per row
  let h = 2166136261 ^ row;
  for (const c of key) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
  return (h >>> 0) % w;
}
const add = (key) => rows.forEach((r, i) => r[slot(key, i)]++);
const estimate = (key) =>
  Math.min(...rows.map((r, i) => r[slot(key, i)]));
Related lessonHeaps: Top-K, K-way Merge and Two Heaps
System design · Real-time

Dashboards poll, feeds use SSE, chat uses WebSockets

HTTP is request and response, but live updates need the server to speak first. Each workaround trades simplicity against latency and cost.

  • Short polling is a timer and a fetch: fine for a dashboard that can be a few seconds stale.
  • SSE streams one-way events over plain HTTP. EventSource reconnects by itself and resumes with Last-Event-ID.
  • WebSockets when the client talks back often: chat, games, collaboration. The price is state on the server.
  • Webhooks for server-to-server events: the provider POSTs to your endpoint and retries until you answer 2xx.
  • Whatever you pick, reconnect with jittered backoff and design for resume, because connections always drop.

Numbers to know

  • 100K clients polling every 5 s is 20,000 requests per second, most of which return nothing.
  • Short polling's delay averages about half the interval.
  • A request with cookies often carries 500 bytes to 2 KB of headers; a WebSocket frame header is 2 to 14 bytes.

SSE gotchas

  • Over HTTP/1.1 browsers allow about 6 connections per origin, so a seventh SSE tab hangs. Serve SSE over HTTP/2.
  • Some proxies buffer the stream. Send X-Accel-Buffering: no for nginx and a : ping comment every 15 s.

Polling done right

  • Schedule the next poll after the reply, not with setInterval, pause hidden tabs, and answer unchanged data with 304.
Go deeperPolling, SSE and WebSockets
CS fundamentals · Thread pools

Size a thread pool from the work, then bound its queue

A thread per request is unbounded, and a pool with an unbounded queue only hides the overload until memory runs out.

  • CPU-bound tasks: about one worker per core. More only adds context switches.
  • Tasks that wait: cores × (1 + wait / compute). Eight cores, 90 ms wait, 10 ms compute gives about 80.
  • Bound the queue and choose what happens when it is full: reject, block the caller, or run on the caller.
  • Amdahl: at 90% parallel work no number of workers beats 10x, and 8 workers give about 4.7x.

How pools freeze

  • Tasks that wait for sibling tasks in the same pool can use up every worker and deadlock.
  • Swallowed exceptions: read the future or the error vanishes.
const poolSize = (cores, wait, compute) =>
  Math.round(cores * (1 + wait / compute));

poolSize(8, 0, 10);   // 8
poolSize(8, 90, 10);  // 80
Go deeperConcurrency in Practice: Pools, Queues, Atomics and the Event Loop
JavaScript · Map and Set

Count with a Map, not an object, so keys stay numbers

Objects turn every key into a string and inherit keys like constructor, while a Map keeps key types and an O(1) size.

  • obj[3] and obj['3'] are the same entry: Object.keys hands back strings, so includes(3) fails.
  • Objects inherit keys: 'constructor' in {} is true, so a word list containing "constructor" breaks counting.
  • A Map keeps 1 and '1' apart, inherits nothing, and m.size is O(1) instead of an O(n) Object.keys.
  • Keys compare by reference: set.has([1, 2]) is always false. Encode pairs as a string or as r * cols + c.

Idioms

  • Count with m.set(x, (m.get(x) ?? 0) + 1). Use ??, not ||, when 0 is a real stored value.
  • Test with has: get returns undefined for a missing key and 0 for a real zero.

Watch out

  • m[key] = v sets a property, not an entry: m.get(key) won't see it.
  • m.size() throws: size is a property.
  • JSON.stringify(m) gives {}. Log the map directly, or spread it: [...m].
const count = {};
for (const n of [3, 10, 3]) count[n] = (count[n] ?? 0) + 1;
console.log(Object.keys(count)); // ["3", "10"]: strings

const m = new Map();
for (const n of [3, 10, 3]) m.set(n, (m.get(n) ?? 0) + 1);
console.log([...m.keys()], m.size); // [3, 10] 2

const seen = new Set([[0, 1], "0,1"]);
console.log(seen.has([0, 1]), seen.has("0,1")); // false true
Go deeperMap and Set
System design · Databases

An index on (user_id, created_at) can't serve created_at alone

Indexes make reads fast but tax every write. Index for the queries you actually run, not for the columns you have.

  • A composite index is sorted by its first column, then by the next within each value: the leftmost-prefix rule.
  • (user_id, created_at) serves WHERE user_id = ?, with or without ORDER BY created_at.
  • Put equality columns first, then the range or sort column.
  • Every INSERT updates every index on the table, and each index competes for memory.

Smaller, faster indexes

  • Covering: INCLUDE (total) stores extra columns, so the query can run as an index-only scan.
  • Partial: WHERE status = 'pending' indexes only the rows you query: smaller and cheaper to maintain.
  • Drop the indexes pg_stat_user_indexes says are never used.

Reading the plan

  • Run EXPLAIN ANALYZE and look for a Seq Scan on a big table.
  • A large gap between estimated and actual rows means stale statistics: run ANALYZE.
  • Watch for sorts spilling to disk.

In production

  • A plain CREATE INDEX blocks writes for the whole build. On a large table use CREATE INDEX CONCURRENTLY.
  • A failed concurrent build leaves an invalid index to drop and retry. Watch replication lag while it builds.
Go deeperDatabases and Scaling
DSA · Fast & slow

Find a cycle or the middle of a list in O(1) space

A visited set finds a cycle with O(n) memory, while a pointer moving twice as fast answers the same question with two references.

  • Spot it: a linked list that might loop back on itself, or one you must split in half without counting its length.
  • Spot it: a sequence defined by a rule (sum of digit squares, i → nums[i]) may repeat, and O(1) space is required.
  • slow takes one step and fast two. When fast runs out of list, slow is at the middle.
  • In a loop, fast gains exactly one node per step, so the gap shrinks to 0 and they meet instead of skipping past.

Finding the entry

  • After the meeting, restart one pointer at the head and step both by one: they meet at the loop's entry.
  • while (fast && fast.next) stops on the second middle of an even list; fast.next && fast.next.next on the first.

Complexity

  • O(n) time and O(1) space for the middle, cycle detection and the cycle's entry.

Watch out

  • Comparing slow === fast before the first move: both start on the head, so every list looks cyclic.
  • Compare nodes, not values: different nodes can hold equal values.
  • The meeting point is somewhere inside the loop, not necessarily its entry.
function cycleStart(head) { // entry node of the loop, or null
  let slow = head, fast = head;
  do {
    if (!fast || !fast.next) return null; // fell off: no loop
    [slow, fast] = [slow.next, fast.next.next];
  } while (slow !== fast);
  slow = head; // phase 2: from the head and from the meeting point
  while (slow !== fast) [slow, fast] = [slow.next, fast.next];
  return slow;
}
Go deeperFast and Slow Pointers
Building blocks · Raft

How Raft picks one leader, and why a write needs a majority

etcd, Consul and CockroachDB keep their replicas in agreement through crashes and partitions with Raft. The core fits on one card.

  • Time is split into numbered terms, each with at most one leader. A node that sees a higher term steps down.
  • Followers expect heartbeats. After a random 150–300 ms of silence, one becomes a candidate and asks for votes.
  • Each node votes once per term, and a candidate needs a majority. Two majorities always overlap, so only one can win.
  • The leader appends each client command to its log and replicates it to the followers.
  • An entry is committed once a majority stores it, so it survives the loss of any minority of nodes.

Why it's safe

  • A node votes only for a candidate whose log is at least as up to date as its own, so a new leader has every committed entry.
  • Random timeouts mean one node usually times out first and wins before others start. A split vote simply retries.
  • An old leader cut off in a minority can't commit anything. When it rejoins, it sees the higher term and steps down.
  • Its uncommitted entries are overwritten by the new leader's log, and clients retry, so make commands idempotent.

Where it's used

  • etcd (all of Kubernetes' state), Consul servers, Kafka KRaft, and CockroachDB and TiKV, which run Raft per data range.
  • Every write goes through the leader, so one group's writes are capped by one machine. Scale out with many groups.
  • The Raft paper suggests 150–300 ms timeouts. etcd defaults to a 100 ms heartbeat and a 1 s election timeout.
const majority = (n) => Math.floor(n / 2) + 1; // 2 of 3, 3 of 5
// The highest log index a majority stores (the leader counts too)
function commitIndex(matchIndex) {
  const sorted = [...matchIndex].sort((a, b) => b - a);
  return sorted[majority(sorted.length) - 1];
}
commitIndex([7, 7, 5, 4, 3]); // 5: three of five nodes have entry 5
// Raft also requires that entry to be from the leader's current term
Related lessonConsistency and CAP
System design · Real-time

Alice is on node 1, Bob on node 2: how does her message reach him?

Each WebSocket lives on one server, so running more than one node means routing messages, finding dead peers and deploying without a herd.

  • A pub/sub backplane routes messages: node 1 publishes to user:bob, and whichever node holds Bob's socket delivers it.
  • When every message goes through pub/sub, you need no sticky sessions: a reconnect can land on any node.
  • Heartbeats find dead peers, which are silent: ping every ~30 s and terminate sockets that miss a pong.
  • Every socket is a file descriptor, and the default soft limit is often 1,024. Raise ulimit -n; after that, memory is the limit.

Deploying without a herd

  • Fail readiness and send close code 1001 (going away), so clients reconnect to other nodes.
  • Clients wait a random full-jitter delay, or 50K of them come back in the same second and knock over the rest.

Numbers to know

  • One Node process holds tens of thousands of idle sockets. At ~50K per node, 1M sockets is 20 nodes plus headroom.
  • Stay under load balancer idle timeouts: 60 s by default on AWS ALB.

Authenticating sockets

  • Browsers can't set Authorization on a WebSocket, so authenticate the connect: a cookie, or a short-lived token in the first message.
  • With a cookie, check the Origin header, or any website could open a socket as your user.
// Full jitter: a random wait between 0 and a growing cap
function backoffDelay(attempt, baseMs = 1000, capMs = 30000) {
  return Math.random() * Math.min(capMs, baseMs * 2 ** attempt);
}
Go deeperPolling, SSE and WebSockets
CS fundamentals · Event loop

One thread can serve thousands of connections, until it blocks

An event loop is great at waiting and terrible at computing. Know which your workload is.

  • The loop takes callbacks from a queue. While a request waits on the network it runs something else.
  • Order: current code, then promise callbacks (microtasks), then the next timer or I/O callback.
  • A busy callback delays everything: a 0 ms timer fired 40 ms late after a 40 ms busy loop.
  • Idle sockets are cheap. CPU-heavy work belongs in worker threads or separate processes.

Related tools

  • CAS (compare-and-swap) updates one shared number without a lock, retrying if someone else wrote first.
  • A bounded buffer between producer and consumer gives backpressure instead of unbounded growth.
Go deeperConcurrency in Practice: Pools, Queues, Atomics and the Event Loop
JavaScript · Recursion

About 10,000 nested calls, then a RangeError

Recursion over a linked list, a skewed tree or a big grid can go n levels deep, and the stack runs out long before n = 10^5.

  • V8's stack is about 1 MB: roughly 10,000 frames for a tiny function, fewer when each frame holds more locals.
  • Going over throws RangeError: Maximum call stack size exceeded, and you can't raise the limit on a judge.
  • Balanced trees are safe at O(log n) depth. A linked list, skewed tree or grid path can be n deep: use a loop.
  • Tail calls aren't optimised in V8 or Firefox: return f(n - 1, acc) still adds a frame. Rewrite it as a while.

Memoise safely

  • Test the cache with memo.has(i) or a sentinel like -1, never if (memo[i]): a cached 0 is falsy.
  • Create the memo inside the main function, or it carries answers into the next test case.

Pass indices

  • solve(arr.slice(1)) copies O(n) per call, O(n²) overall. Pass the whole array and an index instead.
const countDown = (n) => (n === 0 ? 0 : countDown(n - 1));
console.log(countDown(1000)); // 0: a depth of 1000 is fine
try {
  countDown(1e6);
} catch (e) {
  console.log(e.message); // Maximum call stack size exceeded
}
let n = 1e6; // the same steps as a loop: O(1) stack
while (n !== 0) n -= 1;
console.log(n); // 0
Go deeperRecursion, the call stack and memoization
System design · Databases

Replicas scale reads, shards scale writes, and you shard last

Data is the hardest part to scale, so take the cheapest moves first. Interviewers listen for whether you take them in order.

  • The order: indexes → a bigger machine → a cache → read replicas → sharding. Each step adds operational cost.
  • Read replicas add read capacity and failover, but async replicas lag behind the primary.
  • Shard when one primary can't keep up with writes or data size: typically several TB or tens of thousands of writes/s.
  • Sharding is a one-way door: every query now has to know its shard.
  • Pick a high-cardinality shard key that matches the main query: user_id, not country.

Replica lag

  • Read-your-writes: send a user's reads to the primary for a few seconds after they write.
  • Read at a log position: the client keeps its write's LSN, and the replica waits until it has replayed that far.
  • Monotonic reads: pin each user to one replica, so data never goes back in time.

Shard keys

  • hash(key) % N spreads evenly, but going from 4 to 5 shards moves about 80% of users.
  • Consistent hashing, or many virtual shards on fewer machines, moves only a small slice.
  • Never range-shard on a monotonically increasing key: every new write lands on the last shard.
  • Cross-shard queries become scatter-gather. Avoid cross-shard transactions, or use sagas.
const STICKY_MS = 5_000; // longer than typical replica lag
async function query(sql, params, { userId, write = false }) {
  if (write) {
    const result = await primary.query(sql, params);
    await redis.set(`rw:${userId}`, Date.now(), "PX", STICKY_MS);
    return result;
  }
  const recent = await redis.exists(`rw:${userId}`);
  return (recent ? primary : pickReplica()).query(sql, params);
}
Go deeperDatabases and Scaling
DSA · List reversal

Save the next link before you cut it

Linked list problems are solved by rewiring next pointers in O(1) space, and one overwritten link loses the rest of the list.

  • Spot it: reverse all or part of a list, merge or reorder lists in place, or remove nodes where the head may go.
  • Spot it: pair the front of a list with its back read backwards (reorder, palindrome check) in O(1) memory.
  • Every step runs in one order: save the link you still need, overwrite the pointer, then advance.
  • A dummy node in front of the head gives every real node a predecessor, so the head stops being a special case.

Templates

  • Reverse a sublist after before, then fix two links: before.next = prev and first.next = curr.
  • Reorder or palindrome: find the middle, cut there, reverse the back half, then walk both halves together.

Complexity

  • O(n) time and O(1) extra space to reverse, splice or weave. Recursive reversal costs O(n) stack.

Watch out

  • Returning head after it moved: return prev after a reversal and dummy.next after a dummy edit.
  • Not cutting before reversing the back half leaves a cycle in the result.
  • Reading node.next.next without checking node.next throws on lists of length 0, 1 or 2.
function reverseList(head) {
  let prev = null, curr = head;
  while (curr) {
    const next = curr.next; // 1. save the rest
    curr.next = prev;       // 2. flip the arrow
    prev = curr;            // 3. advance both pointers
    curr = next;
  }
  return prev; // the old tail is the new head
}
Go deeperIn-place Linked List Manipulation
Building blocks · DNS

DNS is the first load balancer every request passes through

Before your servers see a byte, DNS has picked the region, and its TTL decides how quickly you can move users off a failure.

  • The OS's stub resolver asks a recursive resolver (the ISP's, or a public one like 1.1.1.1) to do the work.
  • On a cold cache, the recursive resolver asks a root server, then the .com TLD server, then the domain's authoritative server.
  • Every answer carries a TTL. Resolvers, OSes and browsers reuse it until it expires, so most lookups never leave a cache.
  • GeoDNS: the authoritative server answers with a different IP by location, sending each user to the nearest healthy region.
  • Anycast: many sites announce the same IP over BGP, and routing sends each user to the closest one in network terms.

TTL trade-offs

  • A low TTL (30–60 s) lets you fail over a region in about a minute, at the cost of more lookups and more DNS load.
  • A high TTL (hours) makes lookups nearly free, but a wrong record lingers in caches until it expires.
  • Some resolvers and clients cache longer than the TTL, so never rely on DNS alone for instant failover.
  • Lower the TTL well before a planned move: caches can hold the old record for as long as the old TTL.

GeoDNS vs anycast

  • GeoDNS sees the resolver's location, not the user's, unless it sends EDNS Client Subnet, so a far-off resolver can pick the wrong region.
  • Anycast fails over by routing: a broken site withdraws its route and traffic moves elsewhere, with no TTL to wait out.
  • Root servers, public resolvers and CDNs all use anycast: the 13 root server names answer from well over 1,000 sites.
Related lessonClient, API and Load Balancing
System design · Connections

Go from 10 to 30 app instances and Postgres refuses connections

Every instance opens its own pool, so autoscaling multiplies connections until the total blows past max_connections.

  • Size pools from the total: instances × pool size ≤ max_connections − headroom for admin, migrations and cron.
  • 200 max, 20 reserved and 12 instances gives a pool of 15 per instance: (200 − 20) ÷ 12.
  • Little's law checks it's big enough: 1,000 queries/s × 5 ms = 5 busy connections on average.
  • A bigger pool isn't faster: past the database's cores, extra connections add context switching and lock contention.
  • For connections doing useful work at once, the heuristic is (cores × 2) + effective spindles: about 17 on 8 cores.

When instances multiply

  • Put PgBouncer in transaction mode in front: apps open many cheap connections, the database sees only a few dozen.
  • Transaction mode drops session state between transactions: use SET LOCAL, not SET, and avoid LISTEN and temp tables.

Three timeouts per pool

  • An acquire timeout (about 2 s, then fail fast), an idle timeout, and a server-side statement_timeout for runaway queries.

Why connections cost

  • Every PostgreSQL connection is a separate process using a few MB, and max_connections defaults to 100.
  • At a 50 ms round trip, DNS, TCP and TLS cost about 150 ms before you've asked anything.
Go deeperConnections: Pooling, Keep-Alive and Limits
CS fundamentals · Request journey

Typing a URL: four stages, each at least one round trip

DNS, TCP, TLS, then HTTP. At a 50 ms round trip a cold request needs about 200 ms before any server work.

  • DNS turns the name into an address. Answers are cached for their TTL at the browser, OS and resolver.
  • TCP opens the connection with SYN, SYN-ACK, ACK: one round trip.
  • TLS 1.3 agrees a key and checks the certificate in one more round trip. TLS 1.2 needs two.
  • Then the HTTP request. A kept-alive connection skips every earlier stage: one round trip.

Cut the setup

  • Reuse connections, serve from a CDN near the user, resume TLS sessions.
  • Lower the DNS TTL a day before a planned move, or caches keep the old address.
Go deeperThe Request Journey: DNS, TCP, TLS and HTTP
JavaScript · Queues

shift() is O(n), so give your BFS queue a head index

A BFS built on shift() passes the small examples and times out on the large hidden tests, and two lines fix it.

  • shift() removes the first element and moves every other element down a slot: O(n) per call, O(n²) for a BFS.
  • V8 hides this below roughly 16,000 items, which is why small tests pass. unshift() costs the same.
  • Never remove from the front: keep a head index and read queue[head++] in O(1).
  • Loop on head < queue.length. while (queue.length) never ends, because nothing is ever removed.

Levels and visited

  • Level order: save size = queue.length - head before the inner loop.
  • Mark nodes visited when you enqueue them, or duplicates flood the queue.

Stacks and deques

  • An array is already a stack: push, pop and at(-1) all work on the end in O(1).
  • Sliding window max needs only pop() plus a head index. A ring buffer is for pushes at both ends.
function bfsOrder(adj, start) {
  const queue = [start], seen = new Set([start]);
  let head = 0;
  while (head < queue.length) {
    const node = queue[head++]; // O(1) dequeue: nothing moves
    for (const next of adj[node])
      if (!seen.has(next)) { seen.add(next); queue.push(next); }
  }
  return queue; // every node, in BFS order
}
Go deeperStacks, queues and deques
System design · Caching

Default to cache-aside: if the cache dies, you only get slower

A cache with a high hit ratio cuts latency and protects the database, which is usually the hardest part of the system to scale.

  • The app checks the cache first. On a miss, it reads the database and fills the cache with a TTL.
  • Cache data that is read often, changes rarely, and is expensive to compute.
  • Pick the TTL by the cost of a stale read: seconds for prices, minutes for product pages, hours for config.
  • Measure the hit ratio: going from 90% to 99% cuts database load by 10×.

Other patterns

  • Write-through: write the database and the cache together. Always fresh, but writes cost double.
  • Write-behind: write the cache now, flush to the database in batches. Fast, but a crash loses unflushed data.
  • Write-around: writes skip the cache. Good for data that is written once and rarely read.
  • Read-through: like cache-aside, but the cache library or CDN loads from the origin itself.

Keys and sizing

  • Put everything the value depends on in the key: product:42:v3:en-IN.
  • Keep values small: a 2 MB value turns a 1 ms cache hit into a 20 ms one.
  • Size for the working set: often the hottest 20% of keys serve 80% of reads.
  • A falling hit ratio with rising evictions means the cache is too small.
async function getProduct(id) {
  const hit = await redis.get(`p:${id}`);
  if (hit) return JSON.parse(hit);
  const row = await db.product(id); // miss: load the truth
  const ttl = 300 + Math.floor(Math.random() * 60); // jitter
  await redis.set(`p:${id}`, JSON.stringify(row), "EX", ttl);
  return row;
}
Go deeperCaching
DSA · Tree DFS

Solve both subtrees, then combine the answers at the node

Most binary tree problems are one recursive function: trust the calls on the children and write only the base case and the combine step.

  • Spot it: a binary tree or BST whose answer depends on whole subtrees: height, size, balance, root-to-leaf paths.
  • Write f(node) for one subtree: a base case for null and a combine step from the left and right answers.
  • Answers flow up as return values (heights, sums) or down as parameters (running sums, BST bounds).
  • Paths that bend (diameter, max path sum): record left arm + right arm in an outer best, return only the longer arm.

BST and depth

  • Validate a BST by carrying (low, high) down: a node can suit its parent yet break a bound set higher up.
  • V8 holds roughly 10,000 frames. A skewed tree of 10^5 nodes needs an explicit stack instead of recursion.

Complexity

  • O(n) time, O(h) stack: O(log n) when balanced, O(n) when skewed. Recomputing heights at every node is O(n²).

Watch out

  • A node with one child is not a leaf: a leaf has both children null.
  • Starting a maximum at 0 when values can be negative: use -Infinity.
  • Pushing the shared path array into the results instead of a copy.
function isBalanced(root) {
  const check = (node) => { // height, or -1 if unbalanced below
    if (node === null) return 0;
    const left = check(node.left), right = check(node.right);
    if (left === -1 || right === -1) return -1;
    if (Math.abs(left - right) > 1) return -1;
    return 1 + Math.max(left, right); // combine on the way up
  };
  return check(root) !== -1;
}
Go deeperTree Depth-First Search
Building blocks · Skip list

Coin flips give a skip list O(log n) search with no rebalancing

Redis sorted sets, behind leaderboards and delay queues, use a skip list instead of a balanced tree, and the reasons are worth knowing.

  • A sorted linked list plus express lanes: each level up links roughly half the nodes of the level below.
  • Search starts in the top lane: move right while the next key is smaller, otherwise drop down a level.
  • A new node's height is random: flip a coin until tails, one level per heads. That keeps search and insert at expected O(log n).
  • Insert only rewires the pointers next to the new node: no rotations, unlike a red-black or AVL tree.
  • Range queries are cheap: find the start in O(log n), then walk the bottom level for the next k items.

Why Redis uses one

  • A sorted set pairs a hash map (member to score, O(1) ZSCORE) with a skip list ordered by score.
  • Each link records how many nodes it skips, so ZRANK and ranges by rank are O(log n) too.
  • Small sorted sets (by default up to 128 short members) use a compact flat list instead; bigger ones switch to the skip list.
  • Its author's reasons: simpler code than a balanced tree, cheap range walks, and fewer pointers per node.

Numbers to know

  • Promoting with p = 1/2 means 2 pointers per node on average. Redis uses p = 1/4: about 1.33, with similar search cost.
  • The bounds are expected, not worst case. A long run of unlucky flips could slow it, but that's vanishingly unlikely.

Where else

  • LevelDB and RocksDB use a skip list for the in-memory memtable of their LSM trees.
  • Skip lists are easier to make lock-free than balanced trees: Java's ConcurrentSkipListMap is one.
// Start in the top lane: go right while the next key is smaller,
// otherwise drop down a level. Level 0 holds every node.
function find(head, key) {
  let x = head;
  for (let lvl = head.next.length - 1; lvl >= 0; lvl--)
    while (x.next[lvl] && x.next[lvl].key < key) x = x.next[lvl];
  x = x.next[0];
  return x && x.key === key ? x : null;
}
System design · Connections

Without keep-alive, you run out of ports at ~470 connections a second

Every new connection pays handshakes before the first byte, and every closed one holds a port for a minute. Reuse them.

  • A cold HTTPS request pays DNS, TCP, TLS 1.3 and the request: about 4 round trips. On a kept-alive connection it's 1.
  • Without keep-alive, every closed outgoing connection sits in TIME_WAIT for 60 s on Linux.
  • The default ephemeral range has 28,232 ports: about 470 new connections per second to one destination, then connect fails.
  • Node's fetch (undici) keeps connections alive by default, and since Node 19 so does the default HTTP agent.
  • Keep the server's keep-alive timeout longer than the load balancer's, or the LB reuses a closing socket and users get a 502.

The 502 race

  • Node's default keep-alive timeout is 5 s; AWS ALB's idle timeout is 60 s. Set the server's above the LB's.

HTTP/2 and HTTP/3

  • HTTP/2 runs many concurrent streams over one connection, but one lost TCP packet stalls every stream.
  • HTTP/3 runs over QUIC: independent streams, combined handshakes, and it survives a switch from Wi-Fi to mobile data.
  • gRPC keeps one long-lived HTTP/2 connection, so behind a layer-4 balancer all of a client's traffic lands on one backend.
// Behind a load balancer, keep idle sockets open LONGER than it does
// (AWS ALB: 60 s), or Node closes one as the LB reuses it: a 502.
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;   // keep above keepAliveTimeout
Go deeperConnections: Pooling, Keep-Alive and Limits
CS fundamentals · HTTP/3

HTTP/1.1, 2 and 3 differ in what one lost packet does

Same semantics, three transports. The interesting difference is head-of-line blocking.

  • HTTP/1.1: one request at a time per connection, so browsers open about six.
  • HTTP/2: many streams on one TCP connection with compressed headers.
  • But TCP delivers one ordered byte stream, so a single lost packet stalls every HTTP/2 stream.
  • HTTP/3 runs on QUIC over UDP: streams are independent and TLS 1.3 is built into the handshake.

QUIC extras

  • A connection is identified by an ID, not an IP address, so it survives a switch from Wi-Fi to mobile.
  • Some networks block UDP, so clients fall back to HTTP/2 over TCP.
Go deeperThe Request Journey: DNS, TCP, TLS and HTTP
JavaScript · Priority queue

JavaScript has no priority queue, so bring a 40-line heap

Top k, k-way merge and Dijkstra all need one, and re-sorting an array after every insert costs O(n log n) per operation.

  • A binary heap is a complete tree in an array: the parent of i is (i - 1) >> 1, its children 2i + 1 and 2i + 2.
  • push appends a leaf and sifts it up; pop moves the last leaf to the root and sifts it down. Both are O(log n).
  • One class with a comparator covers everything: (a, b) => b - a for a max-heap, a[0] - b[0] for tuples.
  • A heap can't change a priority in place. Push a new entry and skip the stale one when it pops.

Heap or sort?

  • All items known and all needed in order: sort once. Items arriving over time, or only k of them: a heap.
  • Heapify builds a heap from n items in O(n); n separate pushes cost O(n log n).

Watch out

  • A comparator that returns a boolean never returns a negative number and silently breaks the heap.
  • The default a - b on tuples computes NaN. Always pass a comparator for arrays and objects.
  • pop() on an empty heap returns undefined: check size before destructuring the result.
function heapPush(heap, x, cmp = (a, b) => a - b) { // min-heap
  heap.push(x); // add as the last leaf, then sift it up
  for (let i = heap.length - 1; i > 0; ) {
    const parent = (i - 1) >> 1;
    if (cmp(heap[i], heap[parent]) >= 0) break; // parent first: done
    [heap[i], heap[parent]] = [heap[parent], heap[i]];
    i = parent;
  }
}
Go deeperHeaps and priority queues
System design · Caching

Delete the cache key on write, don't update it

Every cached value is a copy, and copies go stale. How you invalidate decides how stale, and for how long.

  • Order matters: write the database first, then DEL the key, and let the next read load the truth.
  • Updating the cache instead can leave the older value last when two writers race.
  • Delete-on-write still has one race: a slow reader loads the old row, the writer deletes, then the reader writes the old value back.
  • Keep a short TTL as a safety net, so any staleness is bounded.

Closing the race

  • Do a delayed second delete, about 1 s after the write.
  • Use versioned keys like user:42:v7, so old entries are simply never read.
  • Drive invalidation from the database's change stream (CDC) instead of application code.

Watch out

  • Caching without a TTL.
  • Caching per-user responses at the CDN. Mark them Cache-Control: private.
  • Treating the cache as the source of truth.
async function updatePrice(id, price) {
  await db.query("UPDATE products SET price = $1 WHERE id = $2",
    [price, id]);
  await redis.del(`p:${id}`); // the next read loads the truth
  setTimeout(() => redis.del(`p:${id}`), 1_000); // second delete
}
Go deeperCaching
DSA · Tree BFS

A queue reads a tree row by row, shallowest first

Level averages, side views and minimum depth are all the same BFS loop with a different line of per-level work.

  • Spot it: the answer is per level or per row, or what is visible from one side of the tree.
  • Spot it: the nearest or shallowest something, such as the minimum depth or the closest leaf.
  • Children join the queue behind every node still waiting on the current level, so nodes come out in depth order.
  • When a level starts, the queue holds exactly that level: snapshot its size, dequeue that many, and the rest is the next level.

Variants

  • Right side view: keep the node at i === size - 1. Zigzag: write values at size - 1 - i on alternate levels.
  • Minimum depth: return at the first leaf you dequeue. Nothing later can be shallower.

Complexity

  • O(n) time and O(w) space, where w is the widest level: up to about n / 2.

Watch out

  • queue.shift() moves every element, so a big BFS becomes O(n²). Use a head index.
  • Looping on the live queue.length inside a level merges every level into one.
  • Return [] for an empty tree, never [[]], and never enqueue null children.
function rightSideView(root) {
  const view = [];
  let level = root ? [root] : []; // exactly one level at a time
  while (level.length) {
    view.push(level.at(-1).val); // the last node on this level
    level = level.flatMap((n) => [n.left, n.right]).filter(Boolean);
  }
  return view;
}
Go deeperTree Breadth-First Search
Building blocks · Split brain

Two leaders after a network split: how quorums prevent it

If both sides of a partition accept writes, the data diverges, and merging it later can lose orders or sell a seat twice.

  • A partition cuts the cluster in two. With naive failover, each side thinks the other died and promotes its own leader.
  • The fix: a node may lead only with votes from a majority of the whole cluster, and only one side can have that.
  • The minority side stops taking writes until the network heals. Its reads are refused or may be stale.
  • Use odd sizes: 3 nodes tolerate 1 failure and 5 tolerate 2. A 4th node adds cost but tolerates no more than 3.
  • An old leader may not know it was replaced, so storage rejects its writes with a fencing token.

Why 2 nodes are dangerous

  • With 2 nodes the majority is 2, so losing either node, or just the link between them, stops writes.
  • If each node instead takes over when it can't see the other, a broken link gives you two primaries.
  • Add a third voter that holds no data, such as a MongoDB arbiter or a quorum witness, to break the tie.

Other defences

  • STONITH: power off the old primary, or cut its network or storage access, before promoting a new one.
  • Spread 3 nodes over 3 zones, so losing a whole zone still leaves a majority.
  • Leases: a leader serves only until its lease expires, and a new one waits it out. This assumes clocks drift only a little.
const majority = (n) => Math.floor(n / 2) + 1;
const tolerates = (n) => n - majority(n); // failures it survives
[2, 3, 4, 5].map(tolerates); // [0, 1, 1, 2]
// Split 5 nodes into 3 + 2: only the side with 3 can elect a leader
const canLead = (side, n) => side >= majority(n);
canLead(3, 5); canLead(2, 5); // true, false
Related lessonDatabases and Scaling
System design · Request flows

Ten parallel calls at 1% slow each make 10% of page loads slow

Tail latency compounds across hops. Split the user-facing p99 into a budget per hop, and enforce each one with a timeout.

  • Start from the user-facing p99 target and split it across the hops on the critical path, keeping headroom.
  • A 200 ms API budget might give the database 50 ms, a downstream service 45 ms, Redis 5 ms, and keep 40 ms of headroom.
  • Slow calls compound: 1 − 0.99¹⁰ ≈ 9.6%, so per-hop budgets must be tighter than the page's.
  • Enforce each budget with per-call timeouts and deadline propagation, and watch per-hop p99s on a tracing dashboard.

Numbers to know

  • With an 80% CDN hit ratio and a 90% Redis hit ratio, only 2% of reads reach the database.
  • Headroom pays for what you can't plan: retries, GC pauses and noisy neighbours.

Finding the slow hop

  • Propagate the W3C traceparent header everywhere, pull the slowest traces, and the widest span is the culprit.
  • Tail-based sampling keeps the slowest traces, which are the ones you need.
  • Put the trace id in every log line, and copy the header into queue messages so async consumers join the trace.
Go deeperRequest Flows End to End
CS fundamentals · TCP windows

Flow control protects the receiver, congestion control the network

Two windows, two jobs. The sender may have at most the smaller of the two unacknowledged.

  • Flow control: the receiver advertises how much buffer it has (rwnd) in every ACK.
  • Congestion control: the sender computes its own window (cwnd) from loss and delay.
  • Slow start doubles cwnd every round trip, from 10 segments, until ssthresh. Then it adds one per round trip.
  • Three duplicate ACKs halve cwnd. A timeout resets it to 1 and restarts slow start.

Rule of thumb

  • Throughput is about one window per round trip: 64 segments of 1,460 bytes at 50 ms is about 15 Mbit/s.
  • Linux defaults to CUBIC; BBR models bandwidth and delay instead of reacting to loss.
// cwnd per round trip
// slow start: 10, 20, 40, 64 (ssthresh)
// then: 65, 66, 67 ...
// 3 dup ACKs at 66: 33, 34, 35 ...
// timeout at 66: 1, 2, 4, 8 ...
Go deeperTCP in Depth: Flow Control, Congestion Control and Loss
JavaScript · Bitwise

Bitwise operators squeeze numbers into 32 signed bits

Numbers are 64-bit floats, but &, |, ^, ~, << and >> work on a 32-bit signed copy, so big values wrap around.

  • Fractions are truncated and bits above 31 are dropped: (2 ** 32 + 5) | 0 is 5, 3.9 | 0 is 3.
  • Bit 31 is the sign: 1 << 31 is negative, and 1 << 32 is 1 because shift counts wrap at 32.
  • x >>> 0 reads the same 32 bits as unsigned. Use it on results that may have bit 31 set.
  • Bitwise operators bind looser than ===: x & 1 === 0 means x & (1 === 0). Parenthesise.

Wider values

  • Past 31 bits, use arithmetic (Math.floor(x / 2), 2 ** k) or BigInt: 1n << 40n.

Watch out

  • (-5).toString(2) prints -101. Use (x >>> 0).toString(2) to see the two's complement bits.
  • A popcount loop on n > 0 returns 0 for negatives: loop on n !== 0.
  • XOR swap zeroes the value when both indices are equal: swap with destructuring.
console.log(1 << 30, 1 << 31, 1 << 32); // 1073741824 -2147483648 1
console.log((2 ** 32 + 5) | 0, 3.9 | 0); // 5 3
console.log(-1 >>> 0); // 4294967295: the same bits, unsigned
const x = 6;
console.log(x & 1 === 0, (x & 1) === 0); // 0 true
console.log(1n << 40n); // 1099511627776n: BigInt for wide masks
Go deeperBitwise operators
System design · Caching

One hot key expires and 10,000 requests hit the database

A cache stampede can flatten a healthy database in seconds, and it only ever happens to your most popular data.

  • A stampede happens when a hot key expires and every request misses at the same moment.
  • All of those requests rebuild the same value from the database at once.
  • Fix it with a per-key lock (single flight): one request rebuilds, the rest wait briefly and retry.
  • Add jitter to TTLs so keys written together don't expire together.

Its cousins

  • Penetration: ids that don't exist always miss. Cache "not found" briefly, or put a Bloom filter in front.
  • Avalanche: many keys expire together, or the cache restarts. Use jittered TTLs and a staggered warm-up.
  • Hot key: one key gets 100K+ reads/s. Add an in-process L1 cache, or replicate it as key#1…#N.

Never miss at all

  • Refresh-ahead reloads a few very hot, predictable keys shortly before they expire, so readers never see a miss.
const inflight = new Map();
function singleFlight(key, load) {
  if (!inflight.has(key)) {
    const p = load().finally(() => inflight.delete(key));
    inflight.set(key, p);
  }
  return inflight.get(key); // every caller shares one promise
}
Go deeperCaching
DSA · Trie

Prefix lookups in O(word length), however many words you store

A hash set can't answer "does any word start with ca?" without scanning every word, while a trie walks one edge per character.

  • Spot it: many prefix questions (startsWith, autocomplete, prefix counts), or wildcards like . for any letter.
  • Spot it: a dictionary of words matched against a sentence or a grid, where dead paths should stop early.
  • Every node stands for a prefix: the characters on its path from the root. Words that share a prefix share nodes.
  • An end-of-word flag marks where an inserted word stops: after inserting "cart", only the flag says if "car" is a word.

Template

  • Insert walks the same path, creating missing children, then sets isEnd on the last node.
  • Wildcard .: turn the walk into a DFS that tries every child and returns true at the first match.
  • Grid search: carry a trie node along the DFS and abandon a path when the next letter has no child.

Complexity

  • Insert and lookup cost O(L) for a word of length L. Building from N words costs O(S) for S total characters.

Watch out

  • search returning true because the path exists, without checking the end flag.
  • An array of 26 children breaks on uppercase, digits or other characters. A Map is the safe default.
  • In wildcard DFS, return dfs(child, i + 1) inside the loop gives up after the first child.
// node = { children: Map<char, node>, isEnd: boolean }
function find(root, prefix) { // the node for prefix, or null
  let node = root;
  for (const ch of prefix) {
    node = node.children.get(ch); // one edge per character
    if (!node) return null;       // the path breaks off here
  }
  return node;
}
const search = (root, w) => find(root, w)?.isEnd === true;
Go deeperTries (Prefix Trees)
Building blocks · CDN

Pull CDNs fill on the first request; push CDNs fill before it

A CDN turns a long trip to your origin into a 10–30 ms hop to the edge, but only for what it may cache and knows is fresh.

  • Pull: on a miss the edge fetches from your origin, caches the response and serves everyone after. Almost no setup.
  • Push: you upload files to the CDN ahead of time. Suits big, rarely changing files like video; you manage what's stored.
  • The cache key is usually host plus URL. Add headers or cookies only when the response truly varies on them, or hits collapse.
  • Your origin sets freshness with Cache-Control: s-maxage for shared caches, private to keep per-user data off the edge.

Invalidation

  • Versioned file names (app.3f9a1c.js) never need purging: a new build is a new URL, so cache them a year with immutable.
  • Give HTML a short TTL. It changes in place and points at the versioned assets.
  • A purge removes an object from every edge, but the next requests all miss at once and land on your origin.
  • An origin shield adds a regional cache tier between the edges and your origin, so many edge misses become far fewer origin requests.

Private content

  • Signed URLs carry an expiry and a signature the edge checks itself, so it serves private files without calling your app.
  • Keep signed URLs short-lived (minutes): anyone holding the link can use it until it expires.
  • Never cache per-user responses as public, or the next user gets someone else's page. Mark them private or bypass the CDN.
const crypto = require("node:crypto");
// Sign path + expiry; the edge recomputes the HMAC with the same key
function signUrl(path, key, ttlSeconds) {
  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
  const sig = crypto.createHmac("sha256", key)
    .update(`${path}:${exp}`).digest("base64url");
  return `${path}?exp=${exp}&sig=${sig}`;
}
signUrl("/videos/42.mp4", key, 300); // valid for 5 minutes
Related lessonCaching
System design · Request flows

Slow work leaves the request: answer 202 and hand back a status URL

Reports, video encoding and bulk imports don't fit in a request. Enqueue a job, answer at once, and let the client find out later.

  • Anything slower than a second or two leaves the request: the API enqueues a job and returns straight away.
  • The API answers 202 Accepted with a Location header pointing at a status resource.
  • The client polls that URL, ideally guided by a Retry-After header, or gets pushed the result.
  • Push with a webhook for server-to-server APIs, or SSE or a WebSocket for a browser that's still open.
  • Workers retry failed jobs with exponential backoff; jobs that keep failing land in a dead letter queue.

Watch out

  • A job can run twice, so make it idempotent.
  • The dead letter queue is for a human to inspect: failed jobs stay visible instead of retrying forever.

Where it fits

  • Read answers 200, write answers 201 after the commit, async answers 202: the risky part is the worker, not the request.
app.post('/reports', async (req, res) => {
  const job = await reports.add('build', req.body, {
    attempts: 3, backoff: { type: 'exponential', delay: 5000 },
  });
  res.status(202)
    .location('/reports/' + job.id)   // the status resource
    .json({ jobId: job.id });
});
Go deeperRequest Flows End to End
CS fundamentals · TIME-WAIT

TIME-WAIT limits how fast one client can open connections

Port exhaustion looks like a networking mystery. It is the arithmetic of closing connections.

  • The side that closes first keeps the connection in TIME-WAIT for about 60 seconds on Linux.
  • It makes sure the final ACK arrives and that late packets cannot corrupt a new connection.
  • Each one holds a source port. About 28,000 ports over 60 seconds is roughly 470 new connections a second to one destination.
  • The fix is fewer connections: keep-alive, pools or HTTP/2, not a smaller TIME-WAIT.

Related symptoms

  • Connect errors under load with many sockets in TIME-WAIT point at short-lived connections.
  • Idle connections also die silently behind NATs, so use keepalive or heartbeats.
Go deeperTCP in Depth: Flow Control, Congestion Control and Loss
JavaScript · Complexity

Read n from the constraints to know which Big-O fits

About 10^8 simple steps run per second, so working back from the largest n tells you the complexity to aim for.

  • n up to 10–12: O(n!). Up to 20–25: O(2^n), subsets and bitmask DP. Up to 100–500: O(n³).
  • n up to 1,000–5,000: O(n²). Up to 10^5–10^6: O(n log n), with sorting, heaps or binary search.
  • Budget 10^7 steps per second instead when each step touches a Map or a Set, or builds a string.
  • One hidden scan inside a loop makes O(n) into O(n²): includes, indexOf, shift, slice and spread copies.

Fixes

  • out.includes(x) → keep a Set. queue.shift() → a head index. out = [...out, x] → out.push(x).
  • solve(arr.slice(1)) → pass an index. Sorting inside a loop → sort once, or use a heap.

Watch out

  • Math.max(...arr) throws a RangeError on huge arrays: engines cap the argument count. Loop instead.
  • String keys cost their length to build and hash, so number keys are cheaper.
function uniqueSlow(nums) { // O(n²): includes scans out each time
  const out = [];
  for (const x of nums) if (!out.includes(x)) out.push(x);
  return out;
}
// O(n): a Set answers membership in O(1) on average
const unique = (nums) => [...new Set(nums)];
Go deeperBig-O of built-in operations
System design · Messaging

Queue, pub/sub or log: who gets each message, and for how long

"Queue" covers three different tools, and they differ in who receives each message and whether you can replay it.

  • Work queue (SQS, RabbitMQ): each message goes to one consumer and stays until acked. For jobs and emails.
  • Pub/sub (SNS, Redis Pub/Sub): every subscriber gets it right now, with no retention. Fire-and-forget fan-out.
  • Log (Kafka): every consumer group reads it, it's kept for days, and it's replayable. For event streams and CDC.
  • A common pairing: SNS fanning out to one SQS queue per subscriber, so each gets a durable copy with retries.

Kafka in brief

  • A topic splits into partitions. The message key picks the partition, so one key always lands in the same one.
  • Order is guaranteed only within a partition, so key by the entity whose events must stay in order.
  • A group can't use more consumers than partitions: with 6 partitions and 8 consumers, 2 sit idle.
  • An offset is just a number, so a group can rewind and reprocess after a bug fix.

When to go async

  • Go async when work is slow, unreliable, spiky or fans out. Keep it sync when the user needs the answer right now.
  • For long jobs, answer 202 Accepted with a job id. The client polls, or gets a push or webhook when it's done.
Go deeperAsync Processing and Messaging
DSA · Heaps

Keep the k largest in a min-heap that never grows past k

A heap gives you the best item in O(1) and updates in O(log n), so top k costs O(n log k) instead of a full sort.

  • Spot it: "the k largest, smallest, most frequent or closest", or merge k sorted lists.
  • Spot it: a running answer over a stream (the median so far), or "always process the cheapest next".
  • A heap is a complete tree in an array where every parent comes before its children, so the best item sits at the root.
  • For the k largest, keep a min-heap of size k: its root is the weakest of the top k, the one to evict.

Variants

  • K-way merge: one head per sorted input in a min-heap; pop the smallest, push its successor.
  • Running median: a max-heap for the lower half and a min-heap for the upper half, sizes equal or one apart.

Complexity

  • peek O(1), push and pop O(log n). Top k of n: O(n log k). K-way merge of N items: O(N log k).

Watch out

  • Wrong direction: a max-heap of size k keeps the k smallest, not the largest.
  • A comparator that returns a boolean, like (a, b) => a > b, breaks the heap. Return a number.
  • Re-sorting an array after every insert costs O(n log n) per operation.
// Heap: the comparator heap class from Template 1
function kthLargest(nums, k) {
  const heap = new Heap((a, b) => a - b); // a MIN-heap
  for (const x of nums) {
    heap.push(x);
    if (heap.size() > k) heap.pop(); // evict the smallest
  }
  return heap.peek(); // the weakest of the top k
}
Go deeperHeaps: Top-K, K-way Merge and Two Heaps
Building blocks · LSM tree

LSM trees buy fast writes with slower reads; B-trees do the reverse

The storage engine decides whether a database shines at ingest or at lookups. It's the real question behind "Cassandra or Postgres?"

  • B-tree: sorted fixed-size pages, updated in place. A read walks 3–4 levels, even for billions of rows.
  • LSM tree: a write goes to the WAL and an in-memory memtable. When that fills, it's flushed as a sorted, immutable SSTable.
  • Background compaction merges SSTables, dropping overwritten values and deleted keys.
  • A read checks the memtable, then SSTables from newest to oldest. A Bloom filter per file skips most that can't hold the key.
  • LSM suits write-heavy ingest (events, messages, time series); a B-tree suits read-heavy and mixed workloads.

Amplification

  • Write: a B-tree rewrites a whole page for a small change; an LSM rewrites the same data again at each compaction.
  • Read: an LSM may check several files per lookup; a B-tree follows one path down the tree.
  • Space: an LSM keeps stale versions until compaction; a B-tree leaves pages partly empty after splits.
  • Levelled compaction favours reads and space; size-tiered favours writes. No engine minimises all three.

Who uses which

  • B-tree: Postgres, MySQL InnoDB, SQL Server. LSM: Cassandra, RocksDB, ScyllaDB, HBase, LevelDB.

Watch out

  • A delete in an LSM is a write: a tombstone hides the key until compaction drops it. Mass deletes slow reads.
  • Compaction competes with live traffic for disk I/O. When it falls behind, files pile up and reads slow down.
Related lessonDatabases and Scaling
System design · Parking lot

Two gates, one free spot: the real test in the parking lot question

One small process could run the whole lot, so scale isn't the problem. Never handing out the same spot twice is.

  • Each floor keeps a free set per spot type: a spot is free exactly when it's in its set, so allocation never scans.
  • FITS lists the spot types each vehicle may use, smallest first, so a bike tries bike spots before large ones.
  • In one Node process, take() runs to completion without interleaving, so allocation is atomic for free.
  • With several gate servers, one SQL statement picks and locks a free row with FOR UPDATE SKIP LOCKED.
  • Insert the ticket in the same transaction, so a crash can't leave a taken spot with no ticket.

The classes

  • ParkingLot → Floor → Spot, plus thin Vehicle and Ticket classes. Fewer, sharper classes score better.
  • Pricing is a strategy with one method, fee(durationMs, vehicle). A daily cap or peak hours is a decorator around it.
  • Display boards are observers: the lot emits changed(floor) after every park and exit, and doesn't know boards exist.

Extending it

  • EV charging is a spot feature, a selector filter and an EnergyFee decorator. No existing class changes.
  • A new vehicle type is a new enum value plus its row in FITS.
const { rows } = await db.query(`
  UPDATE spots SET ticket_id = $1, taken_at = now()
  WHERE id = (SELECT id FROM spots
              WHERE lot_id = $2 AND type = ANY($3)
                AND ticket_id IS NULL
              ORDER BY array_position($3, type), floor, id
              LIMIT 1 FOR UPDATE SKIP LOCKED)
  RETURNING id, floor`, [ticketId, lotId, fits]);
Go deeperDesign a Parking Lot (LLD)
CS fundamentals · HTTP

HTTP methods carry retry rules; ETags carry caching rules

Two small features explain most HTTP interview questions: which requests are safe to repeat, and what a 304 means.

  • GET, PUT, DELETE are idempotent: repeating them leaves the same state. POST is not.
  • A timed-out POST needs an idempotency key so a retry returns the first result instead of charging twice.
  • Cache-Control: max-age lets the browser reuse a response with no request at all.
  • After that it revalidates with If-None-Match, and an unchanged resource returns 304 with no body.

Status codes to know

  • 401 not logged in, 403 logged in but not allowed, 409 conflict, 429 slow down (with Retry-After).
  • 502 and 504 mean a gateway got a bad or no answer from upstream.
Go deeperHTTP and API Styles: Caching, REST, gRPC and GraphQL
System design · Messaging

Your message will arrive twice. Make the consumer not care

At-least-once is the practical default, so "exactly once" in practice means at-least-once plus deduplication.

  • Ack after processing. A crash after the work but before the ack delivers the message again.
  • Record a processed message id under a unique constraint, in the same transaction as the business change.
  • Prefer idempotent writes: SET status = 'shipped' can run twice safely, balance = balance + 10 can't.
  • Pass the message id to external APIs as an idempotency key. Most payment and email providers accept one.
  • Mark a message done only after the work succeeds, or a failed send is skipped as a duplicate on every retry.

Retries and the DLQ

  • Retry transient errors (timeouts, 503s, deadlocks) with exponential backoff and jitter: 1 s, 2 s, 4 s, 8 s.
  • Send permanent errors (validation, malformed payloads) straight to the dead letter queue: retrying can't fix them.
  • Without a DLQ, a poison message is redelivered forever. In Kafka it blocks the whole partition.
  • Alert on DLQ depth above zero and on the age of the oldest message, which catches a stuck consumer.

Watch out

  • Kafka transactions give exactly-once only for Kafka-to-Kafka pipelines. An email or a card charge needs its own idempotency.
  • A consumer group rebalance redelivers messages that were processed but not yet committed.
async function handle(msg) {
  const done = `done:${msg.id}`, lock = `processing:${msg.id}`;
  if (await redis.exists(done)) return; // already delivered
  if (!(await redis.set(lock, 1, "EX", 60, "NX"))) return; // taken
  try {
    await sendEmail(msg); // pass msg.id to the provider too
    await redis.set(done, 1, "EX", 7 * 86_400); // only now: done
  } finally { await redis.del(lock); } // a failed send can retry
}
Go deeperAsync Processing and Messaging
DSA · Graph traversal

BFS finds the fewest steps, DFS floods a region

Grids are graphs in disguise, and both searches visit every node once in O(V + E) if you mark each node when you push it.

  • Spot it: a grid of cells, or islands, regions and connected groups to count, measure or fill.
  • Spot it: the fewest steps when every move costs the same, or something that spreads from several places at once.
  • A stack continues from the newest node (depth-first); a queue finishes distance d before d + 1 (breadth-first).
  • Mark a node visited when you push it: then nothing is queued twice, and BFS reaches each node first by a shortest path.

Variants

  • Count regions: scan every cell, and each unmarked one starts a new component. Flood it before moving on.
  • Multi-source BFS: queue every source at distance 0 and run once. Each cell is reached from its nearest source.

Complexity

  • O(V + E) on an adjacency list, O(m · n) on an m × n grid, however many sources you start from.

Watch out

  • new Array(rows).fill(new Array(cols)) makes every row the same array. Build rows with Array.from.
  • Recursive DFS on a big grid overflows the stack at around ten thousand nested calls. Use a stack array or BFS.
  • queue.shift() is O(n) per call. Walk a head index instead.
function bfs(adj, start) { // edges from start, -1 = unreachable
  const dist = new Array(adj.length).fill(-1), queue = [start];
  dist[start] = 0; // mark when queued, never when popped
  for (let head = 0; head < queue.length; head++) {
    const u = queue[head]; // head index: no O(n) shift()
    for (const v of adj[u])
      if (dist[v] === -1) { dist[v] = dist[u] + 1; queue.push(v); }
  }
  return dist;
}
Go deeperGraph BFS and DFS
Building blocks · Logical clocks

Server clocks drift, so order events with counters instead

If one server's clock runs a few milliseconds fast, its writes look newer, and last-writer-wins silently keeps the wrong one.

  • NTP keeps clocks roughly in sync, often within milliseconds, but they drift and can jump. They can't order close events.
  • Lamport clock: each node keeps a counter, adds 1 on every event, and sends the counter with every message.
  • On receiving, set it to max(local, received) + 1. If A happened before B, A's number is smaller.
  • The reverse doesn't hold: a smaller number doesn't prove A came first. Lamport clocks can't spot concurrent writes.
  • Vector clock: one counter per node. If each vector is ahead in some slot, neither saw the other: they're concurrent.

In Dynamo-style stores

  • The Dynamo paper and Riak attach a vector clock to each value and keep concurrent versions as siblings.
  • The next read returns every sibling and the app merges them. Dynamo's shopping cart took the union of the versions.
  • A union merge can bring back a deleted item, as the Dynamo paper notes for the cart.
  • Cassandra skips all this: each cell has a timestamp, and the latest write wins.

Watch out

  • A vector grows with the number of writers. Dynamo dropped the oldest entry once a clock reached about 10 entries.
  • Vector clocks detect conflicts; they don't resolve them. Something still has to merge, such as app logic or a CRDT.
  • Hybrid logical clocks (CockroachDB) combine wall time with a counter: close to real time, and they respect causality.
function compare(a, b) { // a, b: { nodeId: counter }
  let less = false, more = false;
  for (const id of new Set([...Object.keys(a), ...Object.keys(b)])) {
    if ((a[id] ?? 0) < (b[id] ?? 0)) less = true;
    if ((a[id] ?? 0) > (b[id] ?? 0)) more = true;
  }
  if (less && more) return "concurrent"; // keep both as siblings
  return less ? "before" : more ? "after" : "equal";
}
compare({ A: 2, B: 1 }, { A: 1, B: 2 }); // "concurrent"
Related lessonConsistency and CAP
System design · Vending machine

A vending machine is a state machine: one outcome per event

Every coin, button press and sensor signal needs exactly one outcome in each state, or the machine gives away colas or eats dollars.

  • Four states: Idle, HasMoney, Dispensing, OutOfService. Sold out belongs to a slot, not to the machine.
  • Write a transition table: rows are states, columns are events, and every cell has one outcome.
  • An event a state doesn't list is rejected and changes nothing. A refused coin drops straight to the cup.
  • Dispensing is a real state: it waits for the drop sensor, so coins, selects and cancels are all refused.
  • Every input goes through one queue, handled one event at a time.

State objects

  • Each state is an object mapping event names to handlers that return the next state.
  • Rejections happen before any mutation, so a rejected event can't half-apply.
  • A test loops over all 32 (state, event) pairs: 13 are accepted, and the other 19 must leave everything unchanged.

Watch out

  • A SoldOutState would stop the other 29 slots selling. SOLD OUT is a message in HasMoney; the credit stays.
  • Nested if/else hides missing cases. A table shows them, and rejects them by default.
hasMoney: {
  coin: (m, { coin }) => { m.escrow.push(coin); return 'hasMoney'; },
  cancel: m => { m.refund('cancelled'); return 'idle'; },
  // select: sold out or short -> rejected; no change -> refund
},
dispensing: {                       // only the drop sensor gets in
  dropped: m => { m.commit(); return 'idle'; },
  dropFailed: m => { m.refund('nothing dropped'); return 'idle'; },
},
Go deeperDesign a Vending Machine (LLD)
CS fundamentals · API styles

REST, gRPC or GraphQL: pick by who calls it

Each style fixes one problem and brings another. Name both in the interview.

  • REST: resources and verbs over HTTP/JSON. Universal, cacheable by URL, easy to debug. The default.
  • gRPC: typed .proto contracts, binary protobuf, HTTP/2 streaming and deadlines. Best between internal services.
  • GraphQL: the client names the fields it wants, so one round trip fits each screen.
  • GraphQL's trap is N+1: one query per item. A loader batches the lookups in one tick into a single call.

Costs

  • gRPC needs a proxy for browsers and is not human-readable.
  • GraphQL needs query cost limits, per-field authorization and client-side caching by object ID.
Go deeperHTTP and API Styles: Caching, REST, gRPC and GraphQL
System design · Messaging

Never write the database and then publish. Use an outbox

The database and the broker can't share a transaction, so "save, then publish" either loses events or announces orders that never existed.

  • Crash between the two: the order exists, the event is lost, and nobody ships the parcel.
  • Publish first: if the transaction then rolls back, consumers act on an order that doesn't exist.
  • The outbox: insert the order and an outbox row in the same local transaction.
  • A separate relay reads unpublished rows, publishes them, and marks them published.
  • A relay crash after publishing sends the row twice, so consumers dedupe by event id.

Running the relay

  • Select pending rows with FOR UPDATE SKIP LOCKED, so several relays can run side by side without publishing the same row.
  • A partial index on outbox (id) WHERE published_at IS NULL keeps finding pending rows cheap.

The alternative: CDC

  • CDC (Debezium) tails the write-ahead log and publishes every committed change, with no polling.
  • CDC is more infrastructure, and raw row changes leak your table schema to consumers.
  • Many teams point CDC at the outbox table itself, so consumers never see raw rows.
await c.query("BEGIN");
const { rows } = await c.query(`SELECT id, topic, payload FROM outbox
  WHERE published_at IS NULL ORDER BY id LIMIT 100
  FOR UPDATE SKIP LOCKED`); // several relays can run side by side
for (const r of rows) await publish(r.topic, r.payload, { id: r.id });
const ids = rows.map((r) => r.id);
await c.query(
  "UPDATE outbox SET published_at = now() WHERE id = ANY($1)", [ids]);
await c.query("COMMIT");
Go deeperAsync Processing and Messaging
DSA · Topological sort

Order tasks by repeatedly taking one with no prerequisites

Course schedules and build orders are directed graphs, and Kahn's algorithm orders them in O(V + E) while detecting cycles for free.

  • Spot it: prerequisites or dependencies, "must happen before", or "is it possible to finish?"
  • Spot it: the minimum number of rounds or semesters when independent tasks can run in parallel.
  • A graph without a cycle always has a node with no incoming edges. Place it, remove its edges, repeat.
  • Kahn's algorithm tracks each node's in-degree and queues the ones at 0. Fewer than V placed means a cycle.

DFS version

  • List nodes as they finish, then reverse. An edge into a gray node (still on the current path) is a cycle.
  • Process Kahn's queue in rounds: the number of rounds is the minimum number of stages.

Complexity

  • O(V + E) time and space, for Kahn's and for DFS: each node is queued once and each edge used once.

Watch out

  • Edge direction: in Course Schedule, [a, b] means b before a, so the edge is b → a.
  • A plain visited set can't find cycles in a directed graph: reaching a finished node isn't a cycle.
  • Nodes without edges still need a place in the order and count towards V.
function topoSort(n, edges) { // [from, to]: from comes first
  const adj = Array.from({ length: n }, () => []);
  const deg = new Array(n).fill(0); // in-degree of each node
  for (const [from, to] of edges) { adj[from].push(to); deg[to]++; }
  const order = []; // doubles as the queue
  for (let v = 0; v < n; v++) if (deg[v] === 0) order.push(v);
  for (let i = 0; i < order.length; i++) // order[i] is placed
    for (const v of adj[order[i]]) if (--deg[v] === 0) order.push(v);
  return order.length === n ? order : []; // short means a cycle
}
Go deeperTopological Sort
Building blocks · API styles

REST for the public, gRPC between services, GraphQL for many clients

Interviewers want an API style picked per boundary, with its cost named, not one style declared the winner.

  • REST models resources with URLs and HTTP verbs. GETs cache in browsers and CDNs, and every tool and language speaks it.
  • gRPC sends Protobuf over HTTP/2: typed contracts, generated clients, compact binary messages and streaming both ways.
  • GraphQL serves one typed schema at one endpoint. The client asks for exactly the fields a screen needs, in one round trip.
  • Many systems use all three: GraphQL as a BFF for the apps, calling services over gRPC, with REST for partners.

What each costs

  • REST over- and under-fetches: a screen may need three calls, or get 40 fields to use 4.
  • gRPC isn't native in browsers: it needs gRPC-Web and a proxy. Binary payloads are also harder to inspect with curl.
  • GraphQL usually POSTs every query to one URL, so plain HTTP caching doesn't apply. Persisted queries sent as GET restore it.

GraphQL pitfalls

  • N+1 resolvers: 50 posts call the author resolver 50 times. Batch those lookups per request with a DataLoader.
  • A client can send a deeply nested query that fans out to millions of rows. Enforce depth and cost limits.
  • Errors usually come back as 200 with an errors array, so alert on the response body, not just status codes.

gRPC tips

  • Evolve Protobuf schemas safely: add fields with new numbers, and never reuse or renumber an old one.
  • Set a deadline on every call and pass it on to downstream calls, so the whole chain gives up together.
Related lessonClient, API and Load Balancing
System design · Vending machine

Greedy change-making fails when the coin tubes run low

Largest coin first is optimal with unlimited coins. A real machine has a handful per tube, and greedy never backtracks.

  • Owe 30¢ from one 25¢ and three 10¢: greedy takes the 25¢, then needs a 5¢ that isn't there.
  • A bounded DP over the coins actually in the tubes finds 10¢ + 10¢ + 10¢, with the fewest coins.
  • Count the escrow too: the customer's own coins can be part of their change.
  • No change possible? Refuse the sale and give back the same coins. Never sell short.
  • Keep money in integer cents: in floating point 0.1 + 0.2 is 0.30000000000000004.

Escrow, then commit

  • Inserted coins stay in escrow; select only reserves the change.
  • Only the sensor's dropped moves the escrow into the tubes, pays the change and takes stock, exactly once.
  • Invariant after every event: escrow + tubes + paid out = opening float + inserted + refills.

Cost

  • The DP runs once per sale over a few hundred cents: microseconds, even on a microcontroller.
function fewestCoins(amount, counts, denoms = [100, 25, 10, 5]) {
  let best = [0, ...Array(amount).fill(Infinity)];
  for (const d of denoms)
    best = best.map((min, a) => {     // min starts as "no coin d"
      for (let j = 1; j <= (counts[d] ?? 0) && j * d <= a; j++)
        min = Math.min(min, best[a - j * d] + j);
      return min;
    });
  return best[amount]; // Infinity: no change, so refund
}
Go deeperDesign a Vending Machine (LLD)
CS fundamentals · Sessions & JWT

Sessions revoke instantly; JWTs verify without a lookup

The trade is where state lives, and what it costs to take access away.

  • A session keeps data on the server. The cookie is just a random ID: Set-Cookie: sid=...; HttpOnly; Secure; SameSite=Lax.
  • Delete the session and access ends on the next request. The price is one lookup per request.
  • A JWT is header, payload and signature. Any service with the key verifies it with no lookup.
  • But it is valid until exp, so use a lifetime of minutes plus a refresh token, or keep a deny-list.

Do not forget

  • The JWT payload is signed, not encrypted. Anyone holding the token can read it.
  • Verify with the algorithm you choose, never the one the token claims (the alg: none attack).
  • A token in localStorage is readable by XSS; an HttpOnly cookie is not.
Go deeperSessions, JWT, CORS, Load Balancers and Proxies
System design · Consistency

CAP isn't pick 2 of 3. It's C or A while the network is split

Partitions aren't optional, so the real question is what users see when the network breaks, and what you pay when it doesn't.

  • During a partition, a replicated system either refuses some requests or answers with data that may be stale.
  • Consistency: the side that can't reach a majority refuses or blocks, so every read sees the latest write.
  • Availability: every live node answers, possibly with stale data, and writes on both sides may conflict.
  • A bank ledger leans CP; a social feed leans AP. A stale like count harms nobody, an error page does.
  • CAP's C means linearizability, which has nothing to do with the C in ACID.

PACELC

  • Else, with no partition, you trade latency for consistency on every request.
  • A write that waits for a replica across the Atlantic adds about 70–100 ms to every request.
  • Cassandra and Dynamo-style stores are PA/EL; Spanner, etcd and CockroachDB are PC/EC.
  • Many stores tune it per request: Cassandra QUORUM reads and writes, DynamoDB's strongly consistent reads.

Choose per feature

  • Strong: account balances, payments, inventory at checkout.
  • Read-your-writes: a user's own profile edit.
  • Per-conversation order (causal): chat messages.
  • Eventual: likes, view counts, follower counts.
Go deeperConsistency and CAP
DSA · Union-find

Same group? Compare roots, in near-constant time

When links arrive one at a time, union-find answers "are these connected?" without re-running a graph search after every edge.

  • Spot it: count groups (provinces, components), or links that arrive one at a time.
  • Spot it: "does this edge create a cycle?" in an undirected graph, or items equivalent through chains of links.
  • Each group is a tree whose root names the group. find climbs to the root; union hangs one root under the other.
  • Path compression and union by rank keep the trees almost flat, so both operations are effectively O(1).

Template

  • union returns false when both ends already share a root: in a graph, that edge closes a cycle.
  • Union by rank: hang the shorter tree under the taller, so no tree grows taller than log₂ n.
  • Count groups: start at n and subtract one for every union that merges.

Complexity

  • O(α(n)) amortised per operation with both tricks, effectively constant. With neither, trees become O(n) chains.

Watch out

  • Comparing parent[a] === parent[b] instead of find(a) === find(b): a parent is not always a root.
  • Linking the items instead of their roots leaves the rest of the old tree behind.
  • A recursive find on a long chain overflows the stack. The loop version never recurses.
const parent = Array.from({ length: n }, (_, i) => i); // all roots
function find(x) { // path halving: point x at its grandparent
  while (parent[x] !== x) x = parent[x] = parent[parent[x]];
  return x;
}
function union(a, b) { // false: already in one group (cycle edge)
  const ra = find(a), rb = find(b);
  if (ra !== rb) parent[rb] = ra; // link the roots, never the items
  return ra !== rb;
}
Go deeperUnion-Find (Disjoint Set Union)
Building blocks · Write-ahead log

Log the change before touching the data, and a crash can't lose it

Updating data pages in place is slow and random, and a crash halfway through would leave them half-written. Logging first fixes both.

  • Every change is appended to a sequential log and flushed with fsync before the commit is acknowledged.
  • The data pages change in memory and reach disk later, in the background. A checkpoint marks how far they've caught up.
  • After a crash, the database replays the log from the last checkpoint to redo the changes the pages missed.
  • Appends are sequential, so one log flush costs far less than writing every changed page at commit time.
  • Group commit: one fsync covers many concurrent transactions, so throughput isn't capped by fsync latency.

One log, many uses

  • Replication: Postgres replicas replay the primary's WAL as it streams in.
  • CDC: Debezium reads the log (Postgres WAL, MySQL binlog) and publishes every committed change.
  • Point-in-time recovery: a base backup plus the archived WAL restores the database to any moment.
  • The same idea runs LSM stores, Kafka partitions and Raft: an ordered, append-only log that can be replayed.

Watch out

  • Postgres synchronous_commit = off acknowledges before the flush: faster, but a crash can lose the last few commits.
  • Records carry checksums, so recovery stops cleanly at a record the crash only half wrote.
  • Checkpoints let old log segments be recycled or archived. Without them the log grows and recovery takes longer.
import fs from "node:fs";
const fd = fs.openSync("wal.log", "a");
function commit(change) {
  fs.writeSync(fd, JSON.stringify(change) + "\n");
  fs.fsyncSync(fd); // on disk now: safe to acknowledge
  apply(change);    // update the table in memory; pages flush later
}
// After a crash, rebuild the state by replaying the log in order
const recover = () => fs.readFileSync("wal.log", "utf8").split("\n")
  .filter(Boolean).forEach((line) => apply(JSON.parse(line)));
Related lessonDatabases and Scaling
System design · Elevator LLD

An elevator is two decisions: which car, and in what order

Split dispatch from scheduling and the elevator question gets easy: each car runs LOOK, and one controller gives every hall call one car.

  • Each car is a state machine: IDLE, MOVING_UP, MOVING_DOWN, DOORS_OPEN, OUT_OF_SERVICE. Doors open only when it's stopped at a floor.
  • LOOK: keep the sweep while any stop lies ahead, turn around only when none does. No floor waits more than two sweeps.
  • FCFS zig-zags past floors it will need later; nearest-stop-first can starve a far floor.
  • Nearest car must be direction-aware: a car 2 floors away heading the wrong way can be the slowest to arrive.
  • One controller loop owns every hall call, so pressing ▲ twice is still one call and one car comes.

Watch out

  • Put the safety rules inside the car: openDoors() throws while moving, and a car never moves with its doors open.
  • A failed car hands its hall calls to the other cars; its riders get out at the next floor.
  • A full car (about 80% load) skips hall calls and hands them back to the dispatcher.

Other strategies

  • Collective: only an idle car, or one already passing in the right direction, takes a call. Simple, but cars bunch.
  • Destination dispatch: riders key in their floor in the lobby and are grouped by floor, so cars make fewer stops.
// LOOK, the whole stop policy for one car
if (ahead(dir)) return here(dir) ? { serve: dir } : { move: dir };
if (here(dir)) return { serve: dir };          // last stop this way
if (here(flip(dir))) return { serve: flip(dir) }; // turn around here
return ahead(flip(dir)) ? { move: flip(dir) } : {}; // else go idle
Go deeperDesign an Elevator System (LLD)
CS fundamentals · CORS

CORS is the browser asking permission, not a server defence

A script on one site cannot read replies from another origin unless that server opts in.

  • An origin is scheme + host + port. The same-origin policy blocks cross-origin reads by default.
  • The server opts in with Access-Control-Allow-Origin. With cookies it must name the origin, never *.
  • A PUT, a JSON body or an Authorization header triggers an OPTIONS preflight first.
  • curl and other servers ignore CORS, so the API still needs real authentication.

Common mistakes

  • Reflecting any origin with credentials allowed lets every site read user data.
  • Forgetting Vary: Origin lets a cache serve one origin's headers to another.
Go deeperSessions, JWT, CORS, Load Balancers and Proxies
System design · Consistency

R + W > N: why a quorum read sees the latest write

Leaderless stores let you tune every read and write. One inequality tells you whether a read can be stale.

  • With N replicas, a write waits for W acks, and a read asks R replicas and keeps the newest version.
  • If R + W > N, every read set overlaps the latest write set.
  • N=3, W=2, R=2 is the balanced default: reads and writes each survive 1 node down.
  • 3, 3, 1 favours reads but no write survives a dead node. 3, 1, 1 is fast, but reads can be stale.
  • 5, 3, 2 is a trap: 3 + 2 is not greater than 5.

Sloppy quorums

  • If a key's home replicas are unreachable, other nodes accept the write and keep a hint to hand back later.
  • Writes stay available, but R + W > N no longer guarantees overlap: the acks may come from stand-ins.
  • Read repair and anti-entropy (comparing Merkle trees) clean up the divergence.

Watch out

  • Even a strict quorum isn't fully linearizable: concurrent writes and clock-based conflict resolution leak. Use consensus.
const quorum = (N, W, R) => ({
  strong: R + W > N,     // every read set overlaps the write set
  writeTolerates: N - W, // replicas down while writes succeed
  readTolerates: N - R,
});
quorum(3, 2, 2); // strong, each side survives 1 down
quorum(5, 3, 2); // not strong: 3 + 2 is not greater than 5
Go deeperConsistency and CAP
DSA · Shortest paths

Let the edge weights pick the shortest-path algorithm

BFS, Dijkstra and Bellman-Ford all repeat one move, relaxing an edge, and the edge weights decide which order of relaxing is correct.

  • Spot it: the minimum cost, time or effort, with weighted edges, or "using at most k stops".
  • Relax u → v with weight w: if dist[u] + w < dist[v], a cheaper route to v goes through u.
  • Equal weights: BFS. Non-negative weights: Dijkstra with a min-heap. Negative weights or an edge limit: Bellman-Ford.
  • Dijkstra always expands the closest unsettled node, so a distance is final when popped, not when pushed.

In JavaScript

  • No decrease-key: push a new [dist, node] on every improvement and skip stale entries where d > dist[u].
  • At most k stops: run k + 1 rounds of Bellman-Ford, each reading the previous round's distances.

Complexity

  • BFS O(V + E). Dijkstra with a binary heap O(E log V). Bellman-Ford O(V · E), or O(k · E) for k + 1 rounds.

Watch out

  • Negative edges break Dijkstra: with A→B 4, A→C 5 and C→B -3, it settles B at 4 though 2 exists.
  • Returning when the target is pushed instead of popped.
  • Relaxing in place in Bellman-Ford chains several edges in one round and breaks an edge limit.
function bellmanFord(n, edges, src, maxEdges = n - 1) {
  const dist = new Array(n).fill(Infinity);
  dist[src] = 0;
  for (let round = 1; round <= maxEdges; round++) {
    const old = dist.slice(); // read last round, write this round
    for (const [u, v, w] of edges)
      if (old[u] + w < dist[v]) dist[v] = old[u] + w;
  }
  return dist; // cheapest paths using at most maxEdges edges
}
Go deeperShortest Paths: BFS, Dijkstra, Bellman-Ford
Building blocks · CRDTs

Edit any replica, even offline, and they still converge

Multi-region writes and offline apps need replicas that accept writes without asking anyone, then agree without a human merging.

  • Each replica updates its own copy with no locks or coordination, then ships its state or operations to the others.
  • The merge is commutative, associative and idempotent: any order, and any repeats, give the same result.
  • G-counter: one slot per replica. Each increments its own slot, merge keeps the max per slot, and the value is the sum.
  • PN-counter: two G-counters, one for increments and one for decrements. The value is P minus N.
  • LWW-register: keep the value with the highest timestamp. Simple, but the other concurrent write is silently lost.

Sets and text

  • OR-set: each add gets a unique tag, and a remove deletes only the tags it has seen, so a concurrent add survives.
  • That suits a cart: an item added on your phone isn't lost to a remove made on your laptop at the same moment.
  • Sequence CRDTs give every character a unique id, so concurrent edits merge in the same order on every replica.

Where it's used

  • Collaborative editing and offline-first apps: Yjs and Automerge sync edits made on different devices.
  • Multi-region databases: Redis Enterprise Active-Active and Riak's data types are built on CRDTs.
  • Counters such as likes and views: each region increments its own slot, with no cross-region round trip per write.

Watch out

  • No coordination means no invariants: two regions can each sell the last item. Stock and balances need a strong check.
  • Metadata grows: tags, tombstones and per-replica slots pile up and need compaction.
// G-counter: one slot per replica; merge keeps each slot's max
const inc = (c, id) => ({ ...c, [id]: (c[id] ?? 0) + 1 });
function merge(a, b) {
  const out = { ...a };
  for (const id in b) out[id] = Math.max(out[id] ?? 0, b[id]);
  return out;
}
const value = (c) => Object.values(c).reduce((sum, n) => sum + n, 0);
const eu = inc(inc({}, "eu"), "eu"), us = inc({}, "us");
value(merge(eu, us)); // 3 in any order, merged once or twice
Related lessonConsistency and CAP
System design · Elevator LLD

Lifts are slow because they stop, not because floors are far

In a 20-floor office at the morning rush, a car's 148 s round trip is 52 s of travel and 72 s of stopping. That sets how many cars you need.

  • Round trip = travel up and back + every stop + every person getting in and out.
  • 20 floors and 10 people a trip: about 8 stops, and the car reaches floor 18.6 on average.
  • At 2.5 m/s and 8 s a stop the round trip is about 148 s, so one car moves about 20 people in 5 minutes.
  • Four cars move about 81 in 5 minutes. 1,000 people needing 12% (120) moved want six cars.
  • Halve the stops (destination dispatch) and the trip drops to about 116 s: four cars then move about 103.

The inputs

  • Stops per trip: N × (1 − (1 − 1/N)^P) = 20 × (1 − 0.95^10) ≈ 8.0.
  • A stop costs about 8 s: doors close 3 s, open 2 s, and about 3 s is lost braking and accelerating.
  • Each person takes 1.2 s to get in and 1.2 s to get out; a floor takes 1.4 s at 2.5 m/s.

What to say next

  • Interval = round trip ÷ cars: 37 s with four cars, 25 s with six. Offices aim for under 30 s.
  • The controller is tiny: 120 presses in 5 minutes is 0.4 a second. The hard part is correctness.
Go deeperDesign an Elevator System (LLD)
CS fundamentals · L4 vs L7

L4 balances connections; L7 balances requests

The layer decides what the balancer can see, and so what it can do.

  • L4 picks a backend from IPs and ports and forwards packets unread: fast, protocol-agnostic.
  • L7 terminates the connection, parses HTTP, and can route by path, host or header.
  • L7 also does TLS termination, retries, cookie stickiness and X-Forwarded-For, at more CPU cost.
  • A reverse proxy acts for servers; a forward proxy acts for clients.

Practical split

  • L4 in front for raw throughput and non-HTTP traffic, L7 behind it for smart routing.
  • Trust X-Forwarded-For only when your own proxy set it.
Go deeperSessions, JWT, CORS, Load Balancers and Proxies
System design · Consistency

A lock with a lease isn't enough: the holder can pause past it

A garbage-collection pause can outlast a lock's lease, and the paused client wakes up still believing it holds the lock.

  • Client 1 takes a lock with a 10 s lease, then hits a 15 s GC pause. The lease expires.
  • Client 2 takes the lock and writes. Client 1 wakes up and overwrites Client 2's work.
  • Client 1 can't detect its own pause, so the fix lives in the storage.
  • Every grant carries a strictly increasing fencing token, and storage rejects writes with an older one.

In practice

  • etcd revisions and ZooKeeper's zxid work as tokens.
  • A Redis lock is fine to avoid doing work twice. When two writers would corrupt data, fence the storage.
  • Storage check: UPDATE … WHERE id = $3 AND fence <= $2. Zero rows updated means a newer holder already wrote.

Consensus underneath

  • Raft and Paxos elect one leader by majority and agree on an ordered log. etcd provides this for locks and config.
  • Run odd-sized clusters: 4 nodes tolerate 1 failure, the same as 3. It takes 5 to tolerate 2.
async function acquire(redis, resource, ttlMs) {
  const token = await redis.incr(`fence:${resource}`); // never reused
  const ok = await redis.set(`lock:${resource}`, token,
    "PX", ttlMs, "NX");
  return ok ? token : null;
}
// Storage enforces it on every write:
//   UPDATE files SET body = $1, fence = $2
//   WHERE id = $3 AND fence <= $2
Go deeperConsistency and CAP
DSA · Backtracking

Choose, explore, un-choose: one path walks the whole tree

Subsets, permutations and puzzles share one template, and the craft is producing each answer exactly once while pruning dead branches early.

  • Spot it: "return all subsets, combinations or permutations", or "generate every valid" board or string.
  • Spot it: small limits like n ≤ 10 or a 6 × 6 board, where an exponential search is expected.
  • Choose an option, explore recursively, then un-choose it, so one shared path serves the whole search.
  • Avoid duplicates by construction: a start index when order doesn't matter, a used[] array when it does.

Pruning

  • Ask "can this branch still succeed?" before recursing, not on finished answers.
  • Sort the input first, so one failed check lets you break instead of continue.

Complexity

  • Exponential: nodes × work per node. Subsets O(n · 2^n), permutations O(n · n!), including the copies.

Watch out

  • result.push(path) stores the live array, which is empty by the end. Push [...path].
  • Forgetting to undo every change: the pop(), used[i] = false, the restored grid cell.
  • Recursing with start + 1 instead of i + 1 creates duplicate combinations.
function subsets(nums, start = 0, path = [], result = []) {
  result.push([...path]); // record a COPY at every node
  for (let i = start; i < nums.length; i++) {
    path.push(nums[i]);                 // choose
    subsets(nums, i + 1, path, result); // explore items after i
    path.pop();                         // un-choose
  }
  return result;
}
Go deeperBacktracking
Building blocks · JWT vs sessions

A JWT is easy to issue and hard to take back

Sessions and tokens both keep users logged in. Where the truth lives decides how fast you can log someone out.

  • A session is a random, opaque id in a cookie. The server looks it up in a store such as Redis on every request.
  • Logging out or banning a user is one delete, effective on the next request. The price is a lookup per request.
  • A JWT carries signed claims (user id, roles, expiry). Any service with the key checks it locally, with no lookup.
  • A stolen JWT stays valid until it expires, so keep access tokens short-lived: minutes, not days.
  • It's signed, not encrypted: anyone can base64-decode the payload, so never put secrets in it.

Refresh tokens

  • Pair a short access token with a long-lived refresh token checked against a store, so a ban takes effect within minutes.
  • Rotate the refresh token on every use. If an old one is replayed, it was stolen: revoke the whole chain.
  • Need instant revocation? A denylist of token ids (jti) kept until they expire works, but brings back the lookup.

Where to keep tokens

  • In an HttpOnly, Secure cookie: page scripts can't read it, so an XSS bug can't copy the token out.
  • Not in localStorage: any script on the page can read it, so one XSS hole leaks every token.
  • Cookies travel automatically, so defend against CSRF: SameSite=Lax or Strict, plus CSRF tokens on risky forms.

Watch out

  • Pin the algorithm when verifying. Accepting alg: none, or letting the header switch RS256 to HS256, are classic JWT bugs.
  • With many verifying services, sign with a private key (RS256, ES256) so they only ever hold the public key.
// A JWT is three base64url parts: header.payload.signature
const [, payload] = token.split(".");
JSON.parse(Buffer.from(payload, "base64url").toString());
// { sub: "42", role: "admin", exp: 1767225600 }
// Anyone can read this; the signature only stops changes
Related lessonClient, API and Load Balancing
System design · Rate limiter

A fixed window can let through twice the limit in a couple of seconds

The algorithm decides whether bursts are allowed, how much state each key costs, and whether the limit holds at window edges.

  • A fixed window resets on the minute, so a client can send the full limit just before the edge and again just after.
  • A token bucket holds up to capacity tokens, refills at rate per second, and each request spends one.
  • Bursts up to the capacity pass, but the average rate holds. Tokens plus a timestamp per key: the default for APIs.
  • A sliding window counter weights the previous window by its overlap: about exact, with 2 counters per key.
  • Sliding log is exact but stores every timestamp; leaky bucket smooths output but adds queueing latency.

Example

  • A capacity-3 bucket at 2 tokens per second: 3 quick requests drain it, and the 4th must wait 500 ms.
  • Retry-After comes from the token deficit: (1 − tokens) / rate seconds.

Which one

  • Token bucket for general APIs; sliding window counter for strict "100 per minute" quotas such as billing tiers.
function take(b, now = Date.now()) { // { tokens, last, rate, cap }
  const refill = ((now - b.last) / 1000) * b.rate;
  b.tokens = Math.min(b.cap, b.tokens + refill);
  b.last = now;
  if (b.tokens < 1) return false;        // reject with 429
  b.tokens -= 1;
  return true;
}
Go deeperDesign a Rate Limiter
CS fundamentals · B-tree vs LSM

B-trees rewrite pages in place; LSM-trees append and merge later

Both pay for fast lookups somewhere. The question is whether you pay on writes or on reads.

  • A B-tree page holds hundreds of keys, so 4 levels reach a billion rows: about 4 page reads per lookup.
  • Its writes update pages in place, which is random I/O, and a full page splits in two.
  • An LSM-tree appends to a log and a sorted memtable, then flushes immutable sorted files. Writes are sequential.
  • Reads may check several files, so Bloom filters skip files that cannot hold the key. Compaction merges files in the background.

Pick by workload

  • Read-heavy OLTP and range scans: B-tree (Postgres, MySQL).
  • Write-heavy ingest and time series: LSM-tree (RocksDB, Cassandra).
  • A delete in an LSM-tree is a tombstone that compaction removes later.
Go deeperIndexes: B-trees, LSM-trees and Query Plans
System design · Reliability

At 99.9%, 10,000 of 10M requests may fail. That's your budget

An error budget turns reliability into a number you spend: on risky changes while it lasts, on fixes once it's gone.

  • An SLI is a measured ratio, like the share of requests that succeed in under 300 ms.
  • An SLO is the internal target for it over a window, like 99.9% over 30 days.
  • An SLA is the contract with penalties. Keep it looser than the SLO, as a safety margin.
  • The error budget is 100% − SLO. Budget left: ship faster. Budget spent: freeze risky changes, fix reliability.

Numbers to know

  • 99.9%: 8.77 h of downtime a year, 43.2 min per 30 days.
  • 99.99%: 52.6 min a year, 4.32 min per 30 days. Less than it takes a human to get paged and log in.
  • Each extra nine cuts allowed downtime by 10× and costs far more than the last.
  • A request through 10 services, each 99.9% available, succeeds only about 99% of the time.

Alert on burn rate

  • A burn rate of 14.4 on a 99.9% SLO (1.44% errors) spends 2% of the month's budget in an hour: page.
  • Burn rate 6 for six hours spends 5%: page. Burn rate 1 for three days spends 10%: open a ticket.
  • Alert on symptoms users feel, errors and latency against the SLO, not on CPU.
const errorBudget = (slo, requests) =>
  Math.round((1 - slo) * requests);
const burnRate = (errorRate, slo) => errorRate / (1 - slo);
const budgetSpent = (rate, hours, days = 30) =>
  (rate * hours) / (days * 24);

errorBudget(0.999, 10e6); // 10,000 requests may fail
burnRate(0.0144, 0.999);  // 14.4
budgetSpent(14.4, 1);     // 0.02: 2% in one hour, page
Go deeperReliability and Observability
DSA · Greedy

Take the best choice now, but only if you can prove it

Greedy is usually a sort plus one pass in O(n log n), and it is only correct when the local choice provably never hurts.

  • Spot it: "maximum or minimum number of…" with a natural rule of thumb: earliest end, cheapest so far, furthest reach.
  • Spot it: scheduling or assignment where sorting first puts the obvious candidate in front, with n up to 10^5 or more.
  • Exchange argument: swap the greedy choice into any optimal answer and show it stays valid and no worse.
  • Greedy stays ahead: after every step, the greedy partial answer is at least as good as any other.

Common rules

  • Keep the most non-overlapping intervals: sort by earliest end, not by start or length.
  • Jump Game: keep the furthest reachable index. An index beyond it is a gap nothing can cross.

Complexity

  • Sort then one pass: O(n log n). One pass with a running best: O(n) time and O(1) space.

Watch out

  • Coins 4, 3, 1 for amount 6: greedy takes 4 + 1 + 1, but 3 + 3 needs two. A counterexample means DP.
  • Ties: start > lastEnd instead of >= rejects intervals that only touch.
  • sort works in place: copy with [...arr] or use toSorted if the order matters later.
function maxNonOverlapping(intervals) { // [start, end)
  const sorted = [...intervals].sort((a, b) => a[1] - b[1]);
  let kept = 0, lastEnd = -Infinity;
  for (const [start, end] of sorted) {
    if (start >= lastEnd) { kept++; lastEnd = end; } // fits: keep it
  }
  return kept; // fewest removals = intervals.length - kept
}
Go deeperGreedy
Building blocks · MVCC

Readers don't block writers when every row keeps its old versions

With one copy of each row, a long report would either lock out writers or read half-finished changes. Keeping versions avoids both.

  • An update writes a new version of the row and ends the old one. Each version records the transactions that created and ended it.
  • Each transaction reads a snapshot: only the versions committed when it started, whatever commits later.
  • So reads never block writes and writes never block reads. Only two writers to the same row wait for each other.
  • Old versions pile up. Postgres's VACUUM removes the ones no running transaction can still see.
  • This is snapshot isolation: no dirty or non-repeatable reads, but it still allows write skew.

Write skew

  • Two transactions read the same snapshot, each changes a different row, and neither sees the other's write. Both commit.
  • Two doctors each see someone else on call and both go off call. No row conflicted, so nothing stopped it.
  • Fix it with SERIALIZABLE (Postgres detects the pattern and aborts one), SELECT … FOR UPDATE, or a constraint.

How databases do it

  • Postgres's repeatable read is snapshot isolation. At read committed, it takes a fresh snapshot for each statement.
  • Postgres keeps old versions in the table itself; MySQL InnoDB and Oracle rebuild them from undo logs.

Watch out

  • A long-running transaction pins its snapshot, so VACUUM can't clean up behind it and tables bloat.
  • Two writers to the same row still conflict: at repeatable read, Postgres aborts the second with a serialization error.
// Simplified: your snapshot is the set of transactions committed
// when you started. Txn 12 updated the row: v1 ended, v2 began.
const v1 = { balance: 100, createdBy: 10, deletedBy: 12 };
const v2 = { balance: 50, createdBy: 12, deletedBy: null };
const visible = (v, snap) => snap.has(v.createdBy) &&
  !(v.deletedBy !== null && snap.has(v.deletedBy));

[v1, v2].filter((v) => visible(v, new Set([10]))); // [v1]: 100
[v1, v2].filter((v) => visible(v, new Set([10, 12]))); // [v2]: 50
Related lessonDatabases and Scaling
System design · Rate limiter

Fifty servers, one limit: run the token bucket as one Lua script

Separate GET and SET calls race: two servers read "1 token left" and both spend it. One script makes the check atomic.

  • Keep each bucket in a Redis hash (tokens, ts) that every app server shares.
  • One Lua script reads, refills, takes and writes, so the read-modify-write is atomic across all servers.
  • Take the time from redis.call('TIME') inside the script, so clock skew between app servers can't mint free tokens.
  • Set the TTL to the full-refill time (capacity / rate), so idle buckets delete themselves.
  • Return 429 with Retry-After, and send RateLimit-* headers on every response so good clients slow down early.

When Redis fails

  • Fail open with a local in-memory bucket for normal endpoints; fail closed (503) for login, OTP and payments.
  • Make the Redis client fail fast: no offline queue, no retries, a 50 ms command timeout. Otherwise the limiter becomes the outage.

Numbers to know

  • One Redis primary does about 100K ops/s, so 10K requests per second fits with room to spare.
  • 1M active keys is about 200 MB: two small fields each.

Who to limit

  • The user or API key first. Keep per-IP limits loose: a campus or carrier NAT can put thousands behind one address.
Go deeperDesign a Rate Limiter
CS fundamentals · Composite index

Composite index order: equality first, range last

An index on (a, b, c) is sorted by a, then b inside a, then c inside b. That decides what it can serve.

  • It narrows by equality on a leading run of its columns, plus one range column right after.
  • WHERE b = 5 alone cannot use an index on (a, b): the leftmost-prefix rule.
  • A covering index contains every column the query reads, so the table row is never fetched (an index-only scan).
  • Check with EXPLAIN: look for a sequential scan, an index scan or an index-only scan.

When the planner skips it

  • The query matches most rows, so a scan is cheaper.
  • A function on the column (lower(email)) or a leading wildcard (LIKE '%x').
  • Stale statistics: compare estimated and actual rows in the plan.
Go deeperIndexes: B-trees, LSM-trees and Query Plans
System design · Reliability

Three layers retrying three times is 27 calls to a sick service

Retries rescue a single request, but done carelessly they hit a struggling service hardest, exactly when it can least take it.

  • Put a timeout on every call, set from the dependency's p99: a p99 of 80 ms suggests about 250 ms.
  • Back off with full jitter: wait a random time between 0 and base × 2^attempt.
  • Without jitter, clients that failed together retry together, in synchronised waves.
  • Retry only idempotent calls, and never a 400 or a validation error: it will fail the same way.
  • Retry at one layer, and cap retries at about 10% of requests with a retry budget.

The maths

  • 3 layers × 3 attempts each = 27 calls to the bottom service, exactly when it's already struggling.
  • With a retry budget, load grows by about 10% when everything fails, instead of tripling.

Deadlines

  • A call with no timeout can hang forever, holding a thread, a connection and memory.
  • Pass a deadline downstream so inner calls give up when the caller already has: gRPC deadlines, AbortSignal.timeout(ms).
async function retry(fn, { tries = 4, baseMs = 100 } = {}) {
  for (let i = 0; ; i++) {
    try { return await fn(); } catch (e) {
      if (i >= tries - 1 || e.retryable === false) throw e;
      const wait = Math.random() * baseMs * 2 ** i; // full jitter
      await new Promise((r) => setTimeout(r, wait));
    }
  }
}
Go deeperReliability and Observability
DSA · DP 1D

Say what dp[i] means, then build it from smaller indices

Dynamic programming is recursion that never solves the same subproblem twice, which turns an exponential brute force into states × choices.

  • Spot it: the number of ways, the minimum or maximum cost, or "is it possible?" over a series of choices.
  • Spot it: your brute-force recursion calls itself with the same arguments again and again.
  • Write the state in words: dp[i] = the cheapest cost to stand on step i. Most wrong DPs start with a vague sentence.
  • Build dp[i] from the last decision: the last move came from i - 1 or i - 2, the last coin was one of the coins.

Recipe

  • Base cases: counting starts at dp[0] = 1; minimums use Infinity for states that can't be reached.
  • Fill cells in an order where everything a cell reads is already computed. Top-down with a memo finds that order for you.
  • If a cell reads only the last one or two, keep two variables: O(n) space becomes O(1).

Complexity

  • States × work per state. Two-branch recursion is O(2^n); memoised or bottom-up it is O(n).

Watch out

  • if (memo.get(i)) treats a cached 0 as missing: use memo.has(i).
  • Sizing the table n instead of n + 1, then reading the answer from the wrong cell.
  • Returning Infinity to the caller instead of the -1 the problem asks for.
function minCostClimbing(cost) {
  const n = cost.length;
  const dp = new Array(n + 1).fill(0); // dp[0] = dp[1] = 0
  for (let i = 2; i <= n; i++) { // dp[i]: cheapest way onto step i
    const fromOne = dp[i - 1] + cost[i - 1];
    const fromTwo = dp[i - 2] + cost[i - 2];
    dp[i] = Math.min(fromOne, fromTwo); // the last move decides
  }
  return dp[n]; // the top is one past the last step
}
Go deeperDynamic Programming I: 1D
Building blocks · 2PC

Atomic across databases, until the coordinator dies mid-commit

A transfer between two databases must debit and credit together. Two-phase commit does it, at the cost of held locks.

  • Phase 1, prepare: the coordinator asks each participant to do the work, make it durable, keep its locks and vote.
  • A yes vote is a promise: the participant must still be able to commit, even after a crash and restart.
  • Phase 2: if every vote is yes, the coordinator logs commit and tells everyone. Any no or timeout means abort.
  • Locks are held from prepare until the decision arrives, so the slowest participant sets everyone's latency.
  • If the coordinator dies after prepare, yes voters are stuck: they can't commit or abort on their own.

Where it's used

  • XA transactions span databases and message brokers. Postgres exposes it as PREPARE TRANSACTION and COMMIT PREPARED.
  • Spanner and CockroachDB run 2PC internally, with each participant a consensus group, so no single failure strands locks.
  • It's rarely an option across microservices or third-party APIs: they don't speak the protocol. Use a saga there.

Watch out

  • The coordinator must log its decision before phase 2, so it can finish the job after a restart.
  • A forgotten prepared transaction in Postgres keeps its locks and holds back vacuum until someone resolves it.
  • Postgres turns it off by default: max_prepared_transactions is 0.
async function twoPhaseCommit(parts, txId) {
  const votes = await Promise.allSettled( // phase 1: lock and vote
    parts.map((p) => p.prepare(txId)));
  const yes = votes.every((v) => v.status === "fulfilled" && v.value);
  await log.append(txId, yes ? "COMMIT" : "ABORT"); // log it first
  // If we crash here, yes voters wait, still holding their locks
  await Promise.all(parts.map((p) => // phase 2: retried until acked
    yes ? p.commit(txId) : p.abort(txId)));
  return yes;
}
Related lessonConsistency and CAP
System design · Ticket booking

Two people click seat A5 at once. One UPDATE decides who wins.

A seat must be booked at most once, ever. Postgres row locks enforce that without an application lock or a separate read.

  • Seats move available → held → booked, and each hold sets held_by and a held_until time.
  • One conditional UPDATE holds all the requested seats. If rowCount doesn't match, roll back: all or nothing.
  • Bob's update waits on Alice's row lock, re-evaluates the WHERE against her committed row, and matches 0 rows.
  • Only an expired hold counts as free, so correctness never depends on a cleanup timer.
  • Row locks let thousands book different seats at once; a show-level lock would serialise every booking for that show.

Watch out

  • Bracket the condition: status = 'available' OR held_until < now() also matches booked seats with an old held_until.
  • Dedupe the seat list first, or ['A5', 'A5'] can never match a rowCount of 2.
  • Publish the seat change only after the commit, so viewers never see a hold that was rolled back.

Launch day

  • A waiting room admits N users per second with admission tokens; the hold endpoint rejects requests without one.
  • Seat maps come from a 1–2 s CDN snapshot plus SSE pushes, not from database reads.
const { rowCount } = await c.query(
  `UPDATE seats SET status = 'held', held_by = $1,
     held_until = now() + interval '5 minutes'
   WHERE show_id = $2 AND seat_no = ANY($3)
     AND (status = 'available'
          OR (status = 'held' AND held_until < now()))`,
  [userId, showId, seats]);
if (rowCount !== seats.length) await c.query('ROLLBACK');
Go deeperDesign Movie Ticket Booking
CS fundamentals · Isolation

Isolation levels are a list of anomalies you accept

Know the five anomalies and which level stops which, and the isolation question is easy.

  • Dirty read: reading uncommitted data. Read committed stops it.
  • Non-repeatable read: a row changes between two reads. Repeatable read (a snapshot) stops it.
  • Lost update: two read-modify-writes, one write vanishes. A write-conflict check or a locked read stops it.
  • Write skew: two transactions change different rows and together break a rule. Only serializable stops it.

Defaults and cost

  • Postgres and Oracle default to read committed; MySQL InnoDB to repeatable read.
  • Serializable aborts conflicting transactions, so the code must retry the whole transaction.
Go deeperTransactions: ACID, Isolation Levels, MVCC and Locks
System design · Reliability

Stop calling a dependency that's down, and fence off slow ones

Without breakers and bulkheads, one slow dependency eats every worker and connection, and the whole service times out.

  • Closed: calls pass through and failures are counted. After 5 failures, the breaker opens.
  • Open: every call fails at once for a cooldown, say 10 s. The caller uses a fallback; the dependency gets room to recover.
  • Half-open: exactly one trial request goes through. Success closes the breaker; failure opens it for another cooldown.
  • A bulkhead caps concurrent calls per dependency: 20 for payments, 10 for recommendations.
  • When recommendations hangs, only its 10 slots fill up. Checkout still has its 20.

Fallbacks

  • Recommendations down: show bestsellers from a cached list.
  • Reviews down: hide the section. The product page still sells.
  • Database writes down: read-only mode with a banner.
  • Test fallbacks regularly: a fallback that never runs quietly breaks.

Shedding load

  • Shed early: reject at the balancer with 503 and Retry-After, before parsing, auth or database calls.
  • Shed from the bottom up: prefetch and analytics first, critical paths like checkout and login last.
Go deeperReliability and Observability
DSA · DP 2D

Two strings, a grid or a budget: give the table a second index

When one index can't describe a subproblem, dp[i][j] can, with the same recipe as 1D and O(m × n) time.

  • Spot it: two strings compared (common subsequence, edit distance), or grid paths moving only right and down.
  • Spot it: items under a budget, picking a subset whose weights fit a capacity or hit an exact sum.
  • Two strings: dp[i][j] covers the first i letters of a and the first j of b, so the table is (m + 1) × (n + 1).
  • Row 0 and column 0 are the empty prefix, the base cases. Cell (i, j) reads the letters a[i - 1] and b[j - 1].

Knapsack in one row

  • 0/1, each item once: loop capacity from high to low, so dp[c - w] still means "without this item".
  • Unbounded: loop low to high. Counting combinations: coins outside, amounts inside.

Complexity

  • O(m × n) time. Space O(m × n), or one row, but then you can't rebuild the answer by walking back.

Watch out

  • new Array(m).fill(new Array(n).fill(0)) shares one row: build rows with Array.from.
  • Seeding row 0 of edit distance with zeros instead of 0, 1, 2, …, n.
  • Compressing 0/1 knapsack with the capacity loop going up: items get reused.
function lcs(a, b) { // length of the longest common subsequence
  const dp = Array.from({ length: a.length + 1 },
    () => new Array(b.length + 1).fill(0)); // row 0, col 0: empty
  for (let i = 1; i <= a.length; i++)
    for (let j = 1; j <= b.length; j++)
      dp[i][j] = a[i - 1] === b[j - 1]
        ? dp[i - 1][j - 1] + 1 // both last letters are used
        : Math.max(dp[i - 1][j], dp[i][j - 1]); // drop one
  return dp[a.length][b.length];
}
Go deeperDynamic Programming II: Grids, Strings and Knapsack
Building blocks · OAuth

OAuth lets an app act for you without ever seeing your password

"Connect your calendar" and "Log in with Google" both run on OAuth. Get the flow wrong and an intercepted code hands over the user's tokens.

  • OAuth 2.0 is delegated authorisation: the user approves scopes, like calendar.read, and the app gets a token limited to them.
  • Authorisation code flow: the app redirects to the provider, the user consents, and the provider redirects back with a one-time code.
  • The app swaps that code for tokens. PKCE proves the app swapping it is the same one that started the flow.
  • PKCE: send a hash of a random verifier first, then the verifier itself with the code. A stolen code is useless without it.

The tokens

  • An access token is short-lived and sent to APIs. A refresh token is long-lived and only goes to the authorisation server.
  • OpenID Connect adds login on top: an ID token, a signed JWT that tells the app who the user is.
  • Don't send the ID token to APIs as a credential; that's what the access token is for.

Watch out

  • Never use the implicit flow: it returns tokens in the redirect URL, where they leak. RFC 9700 and the OAuth 2.1 draft drop it.
  • Use PKCE for every client. Browser and mobile apps can't keep a client secret, and it's now recommended for server apps too.
  • Use the S256 challenge method, never plain, register exact redirect URIs, and check state against CSRF.
const crypto = require("node:crypto");
// Start: keep the verifier secret, send only its SHA-256 hash
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256")
  .update(verifier).digest("base64url");
// 1. /authorize?code_challenge=<challenge>&code_challenge_method=S256
// 2. POST /token with the code and code_verifier=<verifier>
System design · Ticket booking

The payment succeeds after the seat hold expired. Now what?

The provider's webhook can arrive late or more than once. Confirming must never double-book a seat or charge for one the buyer lost.

  • Create the payment with an Idempotency-Key equal to the hold id, so a double click can't charge twice.
  • The provider's webhook drives confirmation, not the browser's redirect. Verify it, and expect retries.
  • Insert the booking with ON CONFLICT (payment_id) DO NOTHING, so a retried webhook is a no-op.
  • Confirm by updating seats only WHERE status = 'held' AND held_by = me. If they're still yours, a late payment still books.
  • If someone else took them, roll back and refund automatically. The refund must be idempotent too.

Expiry is lazy

  • Expired holds already count as free in the hold query, so no timer is needed for correctness.
  • A sweeper still frees them every 30 s so seat maps look right. If it stalls, seat maps go stale; bookings stay correct.

Details that matter

  • Confirm checks held_by but not held_until: a hold that expired a moment ago and wasn't reclaimed still books.
  • A failed payment releases the seats with the same held_by guard, so it can't free someone else's hold.
  • A visible countdown in the UI makes late payments rare.
Go deeperDesign Movie Ticket Booking
CS fundamentals · MVCC

MVCC: a write adds a version, so readers never wait

The reason a long report does not freeze your updates.

  • A write does not overwrite. It adds a new row version stamped with its commit.
  • Each transaction reads the newest version committed before its snapshot.
  • Two transactions writing the same row conflict: the first committer wins, the other retries.
  • Old versions are cleaned up later (vacuum), so very long transactions bloat storage.

Still needs locks

  • Writers still take row locks, and deadlocks are detected and one transaction aborted.
  • SELECT ... FOR UPDATE locks the rows you read, which also stops write skew on them.
Go deeperTransactions: ACID, Isolation Levels, MVCC and Locks
System design · URL shortener

7 base62 characters give 3.5 trillion codes. Hand them out in ranges

Short codes must be unique, short and cheap to produce, and one shared counter is a hot spot and a single point of failure.

  • Base62 uses 0-9, a-z and A-Z: every character is URL-safe, unlike base64's + and /.
  • Each app server reserves 1,000 ids with one atomic INCRBY and hands them out from memory.
  • At 40 writes/s, the whole cluster calls the counter about once every 25 s.
  • A crashed server wastes the rest of its block. That's fine: ids must be unique, not contiguous.
  • Hashing the URL needs no coordination, but truncating to 7 characters means collisions, and the same URL always gets the same code.

Make codes unguessable

  • Base62 of a counter is sequential: ids 1000, 1001, 1002 become g8, g9, ga.
  • Scramble first: multiply by a constant coprime with 62⁷, modulo 62⁷. Codes stay unique and 7 characters long.
  • It's obfuscation, not security: two consecutive codes reveal the step. For private links, use random codes.

Other options

  • Random + check: retry on a rare collision, about 0.17% once 6B of the 3.5T codes are used.
  • Snowflake-style 64-bit ids are unique, but 11 base62 characters long and partly guessable.
const BLOCK = 1_000; let next = 0, end = 0, refill = null;
async function nextId(redis) {
  while (next >= end) {
    refill ??= redis.incrby("url:id", BLOCK) // the block's top
      .then((top) => { end = top; next = top - BLOCK; })
      .finally(() => { refill = null; });
    await refill; // concurrent callers share one refill
  }
  return next++;
}
Go deeperCase Study: URL Shortener
DSA · Bits

XOR cancels pairs, and n & (n - 1) drops the lowest set bit

Three bit tricks solve most interview questions in O(1) space, as long as you remember JavaScript's 32-bit rule.

  • Spot it: "every element appears twice except one", or a number missing from a range, in O(1) space.
  • Spot it: count set bits, test for a power of two, or list every subset of a small set (n up to about 20).
  • x ^ x is 0 and x ^ 0 is x, so XOR-ing a whole list cancels every pair and leaves the unpaired value.
  • n & (n - 1) clears the lowest set bit, so a loop runs once per set bit. 1 << i is the mask for bit i.

Masks

  • Test (n >> i) & 1, set n | (1 << i), clear n & ~(1 << i), toggle n ^ (1 << i).
  • Subsets: count mask from 0 to (1 << n) - 1; bit i says whether item i is in.
  • Power of two: n > 0 && (n & (n - 1)) === 0, because it has exactly one set bit.

Complexity

  • Each operator is O(1). XOR over n values: O(n) time, O(1) space. All subsets: O(n · 2^n).

Watch out

  • Operators work on signed 32-bit integers: 1 << 31 is negative. Finish with >>> 0 for unsigned results.
  • n & 1 === 0 parses as n & (1 === 0). Parenthesise: (n & 1) === 0.
  • Use >>>, not >>, on unsigned input of 2^31 or more.
function singleNumber(nums) { // every other value appears twice
  let acc = 0;
  for (const x of nums) acc ^= x; // pairs cancel: x ^ x === 0
  return acc;
}
function popcount(n) { // one iteration per set bit
  let count = 0;
  while (n !== 0) { n &= n - 1; count++; } // drop the lowest set bit
  return count;
}
Go deeperBit Manipulation
Building blocks · Inverted index

Full-text search is a map from each word to the documents with it

Scanning every document for a word grows with the size of the corpus. An inverted index reads a few short lists instead.

  • For each term, keep a postings list: the sorted ids of the documents that contain it, often with counts and positions.
  • Text is analysed first: split into tokens, lowercased, often stemmed ("running" → "run"). Queries go through the same steps.
  • An AND query intersects the postings lists, walking them in step; OR merges them. Start from the shortest list.
  • Positions make phrase queries work: "new york" needs "york" one position after "new" in the same document.
  • Hits are ranked by relevance. BM25 scores a term higher when it's frequent in the document and rare across the corpus.

Inside Elasticsearch

  • Elasticsearch, OpenSearch and Solr are built on Lucene. Each shard is a Lucene index made of immutable segments.
  • New documents go into a new segment, and segments merge in the background, much like LSM compaction.
  • Postings are stored as gaps between ids and bit-packed, so long lists compress well.
  • A delete only marks the document; it's dropped for good when its segment is merged.

Watch out

  • New documents become searchable after a refresh, about once a second by default: search is near real time.
  • Leading wildcards (*phone) can't use the sorted term dictionary and have to scan it. Index n-grams instead.
  • It's a derived store: feed it from the source of truth with CDC or the outbox, never as a second write.

Also in Postgres

  • A GIN index on a tsvector column is an inverted index too: often enough for search inside one database.
function intersect(a, b) { // both sorted by document id
  const out = [];
  for (let i = 0, j = 0; i < a.length && j < b.length; ) {
    if (a[i] < b[j]) i++;
    else if (a[i] > b[j]) j++;
    else { out.push(a[i]); i++; j++; }
  }
  return out;
}
intersect([1, 4, 7, 9], [2, 4, 9, 12]); // [4, 9]: has both words
Related lessonStoring Data: Choosing the Right Store
System design · Leaderboards

A sorted set answers "what's my rank?" in O(log n)

A database index finds the top 100 easily, but to rank one player it must count everyone above them. A skip list keeps that count.

  • Each board is a Redis sorted set: a skip list ordered by score, plus a hash from player to score.
  • ZINCRBY adds points, ZREVRANGE 0 99 is the top 100, and 1 + ZCOUNT key (score +inf is a rank: all O(log n).
  • A B-tree stores no counts, so COUNT(*) WHERE points > x reads every entry above you: millions per query.
  • Cache the top 100 for a second, since everyone sees the same list. Compute "my rank" on demand from a replica.
  • 50M players at about 100 bytes each is roughly 5 GB: one Redis primary plus a replica, no sharding yet.

Writes and durability

  • Validate server-side, append to Kafka, then apply to the daily, weekly and all-time keys in one Lua script.
  • Kafka delivers at least once and ZINCRBY isn't idempotent: store the last applied offset in the same script.
  • Redis is a rebuildable index: reload the totals from the database and replay Kafka if it's lost.

Time windows

  • One key per period, derived from when the match was played, so a late event lands on the right day.
  • EXPIRE key ttl NX sets the TTL once. ZUNIONSTORE of seven dailies rebuilds a week, but it's O(N): never per request.
await redis.zincrby('lb:all', 50, 'maya');           // O(log n)
const top = await redis.zrevrange('lb:all', 0, 99, 'WITHSCORES');
const score = await redis.zscore('lb:all', 'ravi');   // O(1)
const above = await redis.zcount('lb:all', `(${score}`, '+inf');
const rank = above + 1;                               // ties share it
Go deeperDesign a Real-Time Leaderboard
CS fundamentals · WAL

Write-ahead log: log first, so a commit survives a crash

Data pages are flushed lazily, so a committed change may exist only in RAM. The log is what makes it durable.

  • Before a change reaches a data page, a small record of it is appended to a sequential log.
  • A commit is acknowledged once that record is flushed (fsync). The only disk wait is the log write.
  • Dirty pages are written later in the background. A checkpoint records how far that has got.
  • After a crash, recovery replays the log from the last checkpoint and drops transactions with no commit record.

Numbers and trade-offs

  • With a 2 ms fsync one commit at a time allows 500 a second. Group commit of 100 allows 50,000.
  • Frequent checkpoints shorten recovery but add write load.
  • synchronous_commit = off can lose the last few milliseconds of commits but never corrupts data.
Go deeperStorage Engine: WAL, Checkpoints and Crash Recovery
System design · URL shortener

301 or 302 decides whether you ever see a click again

Both send the browser to the long URL. The difference is who remembers the answer.

  • A 301 is cached by browsers and CDNs, often for a very long time, so only the first click per browser reaches you.
  • A 302 comes back to you on every click: you can count it, expire the link, or disable it for abuse.
  • Default to 302. Pick 301 only when analytics don't matter.
  • A 301 mistake is cached in browsers you can't reach, so changing the destination later is hard.

Middle ground

  • For very hot links, a 302 with Cache-Control: max-age=60 lets the CDN absorb the load.
  • You still see roughly one request per edge per minute.

Watch out

  • Returning 301 and then promising click analytics.
  • Caching a link longer than it lives: cap the cache TTL at the link's expiry.
Go deeperCase Study: URL Shortener
DSA · Design

Pair a structure that finds with one that orders

Design questions give you a class and an O(1) budget per method. No single structure meets it, so you glue two together.

  • Spot it: "design a class", every method in O(1), "evict the least recently used", or "the value at time t".
  • A hash map finds a key in O(1) but has no order. A list keeps order but needs a scan to find anything.
  • Pair them: the map points at a list node, so a hit unlinks and moves the node to the front in O(1).
  • Name what each method needs and which structure makes it O(1). A method with no answer needs one more structure.

The classics

  • LRU: map + doubly linked list. LFU: map + one bucket of keys per use count, plus minCount.
  • Time-based store: per-key sorted history, then binary search for the last write at or before t.
  • Random member: array + map of positions. Remove by copying the last element into the hole, then pop.
  • Min stack: store the minimum so far with each pushed value, so pop needs no repair.

Complexity

  • LRU and LFU get/put in O(1), space O(capacity). Time-based get is O(log m) for m writes of that key.

Watch out

  • A put on an existing key is also a use: refresh it, do not just overwrite.
  • Evicting from the list but not the map (or the reverse) leaves them disagreeing.
  • After swap-and-pop, update the moved element's index, or the next removal breaks.
class LRU { // map: key -> node, list: newest first
  get(key) {
    const n = this.map.get(key);
    if (!n) return -1;
    this.unlink(n); this.pushFront(n); // a read is a use
    return n.val;
  }
}
Go deeperDesign Data Structures: LRU, LFU and Friends
Building blocks · Sagas

No transaction spans services, so undo each step instead: sagas

Orders, stock and payments live in different services with their own databases, yet an order must never end up half done.

  • Split the action into local transactions, one per service: create the order, reserve stock, charge the card, ship.
  • Pair each step with a compensating action that undoes it: cancel the order, release the stock, refund the card.
  • If a step fails, run the compensations of the completed steps in reverse order.
  • A compensation is a business undo, not a delete: a refund leaves a record, and a sent email can't be unsent.
  • There's no isolation: others see intermediate states like PENDING, so design the UI and the reads for them.

Orchestration or choreography

  • Choreography: each service reacts to the previous one's event. Loosely coupled, but the flow is hard to follow.
  • Orchestration: a coordinator tells each service what to do next, so the flow, timeouts and retries live in one place.
  • Choreography suits 2–3 steps. For longer flows, use an orchestrator such as Temporal or AWS Step Functions.

Watch out

  • Compensations must be idempotent and retryable: a retried refund must not pay out twice.
  • Put the steps most likely to fail first, and those you can't undo, like shipping, last.
  • Persist the saga's progress, so a restarted orchestrator resumes instead of starting over.
  • Publish each step's event through an outbox, so a crash can't silently stall the saga.
async function runSaga(steps, order) { // steps: [{ act, undo }, ...]
  let i = 0;
  try {
    for (; i < steps.length; i++) await steps[i].act(order);
  } catch (err) { // undo the completed steps, newest first
    while (i-- > 0) await steps[i].undo(order); // each idempotent
    throw err;
  }
}
Related lessonAsync Processing and Messaging
System design · Leaderboards

Leaderboard ties and shards: where the interview is won

Anyone can say "use a sorted set". The follow-ups are how ties are numbered and what you do when one node can't hold the board.

  • Shared ranks (1224) are 1 + ZCOUNT above you. Dense ranks (1223) need a second set of distinct scores.
  • First to reach wins: score = points × 2^28 + (MAX_TS − ts). Doubles are exact only to 2^53, so points stay under 2^25.
  • Shard by player hash: each shard returns its own top K and you merge. Not K / N: the whole top K may sit on one shard.
  • My global rank is 1 + the sum of ZCOUNT above my score on every shard. Dense ranks don't add up across shards.
  • For the long tail, a score histogram gives "top 37%" from a few hundred counters instead of exact ranks.

Shard by score range?

  • The top K comes from one shard, and a rank is the size of every higher shard plus your rank inside yours.
  • But the top shard is the hottest, players move between shards, and the boundaries drift as scores inflate.

Watch out

  • ZREVRANK breaks ties by member name, which is arbitrary: use ZCOUNT for shared ranks.
  • Adding the time as a fraction of a point runs out of precision once totals reach about a million.
  • ZADD GT keeps the earliest of two equal best scores for free: the later one encodes smaller.
Go deeperDesign a Real-Time Leaderboard
CS fundamentals · Replication log

Replication is just shipping the log

The log is an ordered record of every change, so another machine can replay it.

  • A replica applying the same records in the same order ends up with the same data.
  • Physical shipping sends page-level records (Postgres streaming). Logical sends row changes (MySQL binlog).
  • Synchronous: the commit waits for the replica. No loss on failover, but network latency on every commit.
  • Asynchronous: fast commits, but the last transactions can be lost if the primary dies.

Watch out

  • A stuck replica or archive job can stop old log from being recycled and fill the disk.
  • Reads from an async replica can be stale: replication lag is a behaviour, not a bug.
Go deeperStorage Engine: WAL, Checkpoints and Crash Recovery
System design · URL shortener

40 writes/s and 4,000 redirects/s: all the work is on the read path

If creating links is down, people retry. If redirects are down, every link ever shared is broken at once.

  • The write path is tiny: one Postgres primary handles 40 inserts/s while barely noticing.
  • The CDN caches 301s and short-max-age 302s at the edge, close to the user.
  • Redis cache-aside with a one-day TTL: at a 95% hit ratio, Postgres sees only 200 reads/s.
  • A viral link (50K redirects/s on one key) gets an in-process L1 with a few seconds of TTL on every server.
  • Click events go onto a queue and are never awaited, so analytics can't slow a redirect down.

The numbers

  • 100M new URLs a month ≈ 40 writes/s. At 100 reads per write, ≈ 4,000 redirects/s, and about 12K/s at a 3× peak.
  • Storage: 6B URLs over 5 years × ~500 B ≈ 3 TB, about 9 TB with 3 replicas.
  • Hot set: 10M codes × ~600 B ≈ 6 GB, one Redis node.

Click analytics

  • A worker consumes click events in batches and bulk-inserts them into a columnar store such as ClickHouse.
  • The stats API reads pre-aggregated rollups, not raw events.
  • Never put a click counter in urls: 4,000 updates/s would make a read-only table write-hot.
  • Filter out bots and link-preview fetchers, and store a country instead of the raw IP address.
Go deeperCase Study: URL Shortener
DSA · Matrix

A grid is coordinates: rotate, spiral, mark in place

Most grid problems are index arithmetic with a thin-grid edge case, and many can be done in O(1) extra space.

  • Spot it: rotate or transpose, spiral or diagonal order, "in place", or every cell changes at the same time.
  • A clockwise turn sends (i, j) to (j, n - 1 - i): transpose, then reverse each row, all in place.
  • A spiral is four walls (top, bottom, left, right) that move inward after each side is read.
  • Write the coordinate rule for one cell first, test it on a 2 by 2 grid, then loop it.

Tricks

  • Set zeroes: use the first row and column as flags, plus two booleans for whether they held a zero.
  • Game of Life: encode 2 = alive then dead, 3 = dead then alive, so cells still see the old grid.
  • Sorted grid: position p is row Math.floor(p / cols), column p % cols. Diagonals share r + c.

Complexity

  • Rotate, spiral, zeroes and life are O(rows × columns) time. In-place versions use O(1) extra space.

Watch out

  • new Array(rows).fill(new Array(cols)) shares one row. Use Array.from.
  • Transposing with j from 0 swaps every pair twice. Start at j = i + 1.
  • Spiral without top <= bottom and left <= right checks reads a single row twice.
function rotate(m) { // 90 degrees clockwise, in place
  const n = m.length;
  for (let i = 0; i < n; i++)
    for (let j = i + 1; j < n; j++)
      [m[i][j], m[j][i]] = [m[j][i], m[i][j]];
  for (const row of m) row.reverse();
}
Go deeperMatrix and Grid Simulation
Building blocks · Passwords

A password hash should be slow on purpose

Databases leak. Whether your users' passwords leak with them depends on how expensive you made each guess.

  • SHA-256 and MD5 are built for speed: one GPU tries billions of guesses a second against a leaked table.
  • Use a slow, memory-hard hash: Argon2id first, scrypt next, bcrypt for older systems.
  • Tune the cost to tens or hundreds of milliseconds per hash. At 50 ms, a billion guesses cost about 580 core-days.
  • A unique random salt per user makes identical passwords hash differently, so precomputed rainbow tables are useless.
  • Compare the computed and stored hashes in constant time, so response timing reveals nothing.

Settings to know

  • Argon2id: OWASP's minimum is 19 MiB of memory, 2 iterations and a parallelism of 1.
  • scrypt: N = 2^17, r = 8, p = 1, which needs 128 MiB per hash. bcrypt: a work factor of 10 or more.
  • Memory-hard means each guess needs real RAM, so GPUs and custom chips can't run thousands in parallel cheaply.
  • The stored string records the algorithm, cost and salt ($argon2id$v=19$m=19456,t=2,p=1$…), so you can raise the cost later.

Watch out

  • bcrypt ignores everything after the first 72 bytes of a password, so long passphrases are silently cut.
  • Re-hash at login when a stored hash uses an old cost: it's the only moment you have the plain password.
  • Slow hashing makes login a cheap DoS target, so rate-limit attempts per account and per IP.
  • Never encrypt passwords: whoever gets the key decrypts them all. A hash can't be reversed, only guessed.
  • A pepper, a secret key kept outside the database, protects the hashes if only the database leaks.
const crypto = require("node:crypto");
const opts = { N: 2 ** 17, r: 8, p: 1, maxmem: 2 ** 28 }; // 128 MiB
function hash(pw, salt = crypto.randomBytes(16)) {
  return { salt, key: crypto.scryptSync(pw, salt, 32, opts) };
}
function verify(pw, { salt, key }) {
  const attempt = hash(pw, salt).key;
  return crypto.timingSafeEqual(attempt, key); // constant time
}
verify("hunter2", hash("hunter2")); // true
System design · Payments

A payment timeout means unknown, not failed

The PSP may have charged the card before its reply got lost. Call that a failure and the customer's retry charges them twice.

  • Claim an idempotency key per checkout attempt under a unique constraint, in the same transaction that creates the payment.
  • Same key and body: replay the stored response. Still running: 409. Same key, different body: 422.
  • On a timeout, mark the payment unknown and pass your own key to the PSP, so any retry returns the original charge.
  • Let the signed webhook, or a recovery job's status query, settle unknown into succeeded or failed.

Watch out

  • Never hold a database transaction open across the PSP call: at 1,160 payments/s that is 1,160 open transactions.
  • A check-then-insert in code races. Let the database's unique key pick the single winner.
  • Store declines too: retrying the key replays the decline, and another card needs a new key.

Retries

  • Retry only timeouts, resets and 5xx, with exponential backoff and full jitter, and always with the same key.
  • Failing over to a second PSP after a timeout can double-charge: the first one may have succeeded.
INSERT INTO idempotency_keys (customer_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (customer_id, key) DO NOTHING;
-- 0 rows: replay if done, 409 if started, 422 if the hash differs
Go deeperDesign a Payment System
CS fundamentals · Normalization

Normalize first; denormalize only a measured hot read

Repeating a fact on every row lets copies disagree. Copying on purpose needs a plan to keep them in sync.

  • Normalization stores each fact once: third normal form means each column depends on the key, the whole key and nothing but the key.
  • Repetition causes update anomalies: a customer's city on ten orders must change ten times, or the data disagrees with itself.
  • Denormalize (a copied column, a precomputed total, a materialized view, an embedded document) to avoid a join on a hot read.
  • Say what keeps the copy correct: the same transaction, a trigger, or an async job that tolerates staleness.

Where it is by design

  • Analytics star schemas and document stores are denormalized because they are read far more than written.
  • In a sharded system, denormalizing can replace a cross-shard join.
Go deeperSchema Design, Normalization and Joins
System design · Notifications

An OTP must never wait behind a marketing blast

In one shared queue, a 1M-message campaign drained at 2,000/s takes about 8 minutes, and the login code expires before it arrives.

  • Split queues by channel and priority: notif.sms.critical, notif.push.high, notif.push.low.
  • Give each queue its own consumer group, so a backlog in one never steals workers from another.
  • Critical lanes get dedicated workers with spare capacity. Marketing is throttled and paused first under load.
  • Reserve provider capacity for critical traffic too: a campaign can exhaust the provider's rate limit.
  • The send API answers 202 Accepted: the request only means queued.

Quiet hours

  • Store an IANA time zone per user: Asia/Kolkata, not +05:30, which breaks when daylight saving changes.
  • Evaluate quiet hours at send time, in the user's zone. Critical messages ignore them.
  • Low-priority messages get a notBefore and wait in a delayed queue, such as a Redis sorted set.

Large campaigns

  • A 10M-user campaign is a batch job: resolve the segment in pages and enqueue in batches of about 1,000.
  • At 5,000 sends/s, 10M messages take about 33 minutes, so plan for it.
  • Support pause and cancel, so a typo can be stopped mid-send.
Go deeperCase Study: Notification System
Building blocks · Geohash

Nearby drivers: match a geohash prefix, then check the 8 neighbours

A normal index can range over latitude or longitude, but not both at once. Mapping positions to cells turns "near me" into a key lookup.

  • A geohash halves the world again and again, alternating longitude and latitude, and writes the cell as a base-32 string.
  • Each extra character shrinks the cell, so a shared prefix means the same cell. Six characters is about 1.2 × 0.6 km.
  • Two points on either side of a cell edge can have very different prefixes, so always search your cell plus its 8 neighbours.
  • Then compute real distances on that short list and keep the ones inside the radius.
  • A quadtree splits a square into 4 only where it holds too many points: small cells downtown, one big cell over the desert.

Numbers to know

  • At the equator, 5 characters is about 4.9 × 4.9 km, 6 about 1.2 × 0.6 km, and 7 about 153 × 153 m.
  • Cells get narrower towards the poles, because lines of longitude converge.
  • Pick the precision whose cell's shorter side is at least the radius, so your cell and its 8 neighbours cover the circle.

Geohash or quadtree

  • Geohashes are plain strings, so any database or cache can index them. Redis's GEO commands keep points in a sorted set scored by geohash.
  • A quadtree adapts to density, but it's a structure you keep in memory and update as points move.
  • Google's S2 maps the sphere onto a cube's faces; Uber's H3 uses hexagons, whose 6 neighbours are all about equally far.

Watch out

  • Drivers report a position every few seconds: keep live locations in memory (Redis or a quadtree), not in a disk-based table.
System design · Payments

Every cent twice: the double-entry ledger

Payments are a bookkeeping problem. Balanced, append-only entries make double postings and missing money visible instead of silent.

  • Every transaction is a set of entries that sums to zero, in integer minor units of one currency: 5000 means $50.00.
  • A $50 charge: debit cash at PSP $48.25 and fees $1.75; credit the merchant $45.00 and revenue $5.00.
  • Entries are append-only. A refund or a correction is a new transaction in the other direction.
  • Balances are derived from the entries. Cache them as a projection, never as the source of truth.

With webhooks

  • Verify the signature, insert the event id under a unique key, then apply only forward state moves.
  • Write the status change, the ledger entries and an outbox row in one transaction, so nothing half-happens.

Balanced is not correct

  • A duplicate webhook posted twice still balances. Reconciliation against the PSP's daily report catches it.
  • Compute one leg as the remainder (merchant share = amount − commission), so rounding can't create or lose a cent.
Go deeperDesign a Payment System
CS fundamentals · Joins

Nested loop, hash join, merge join: same rows, different work

For 100 users and 1,000 orders a naive join does 100,000 comparisons; a hash join does about 1,100.

  • Nested loop: for each outer row, look at the inner side. Cheap only with an index on the inner join column.
  • Hash join: build a hash table on the smaller side, probe it with the other. Needs memory, takes unsorted input.
  • Merge join: walk two inputs already sorted on the key, once. No extra memory.
  • EXPLAIN shows which one ran. The planner chooses from row estimates, so stale statistics can fool it.

Join types

  • INNER matches only. LEFT keeps every left row with NULLs when nothing matches.
  • An anti-join (LEFT JOIN ... WHERE right.id IS NULL, or NOT EXISTS) finds rows with no match.
Go deeperSchema Design, Normalization and Joins
System design · Notifications

Exactly-once to a phone is impossible. Aim for effectively once

Duplicates come from client retries, re-emitted events and queue redelivery, so you defend against them in layers.

  • API idempotency key: the first request stores key → notificationId with SET NX. A retry gets the same id.
  • Deterministic ids: every message is eventId:channel, however many times the event is processed.
  • Check before sending: skip any id with a sent:<id> key, and set that key only after the provider accepts.
  • A crash after the provider accepts but before sent:<id> is written still sends again.
  • Narrow that window by passing the id as the provider's idempotency key, where it supports one.

Providers fail

  • Put each provider behind a circuit breaker, with a failover order per channel.
  • Transient errors (timeout, 429, 5xx): try the next provider; the queue retries with backoff and jitter.
  • Permanent errors (invalid number, hard bounce): don't retry. Mark it failed, delete the token or suppress the address.
  • Track delivery rate per provider: one that accepts but silently doesn't deliver is worse than one that errors.

Delivery webhooks

  • Verify the signature over the raw body, before parsing it.
  • Callbacks arrive out of order: rank the statuses and never move one backwards.
async function deliver(msg, provider) {
  if (await redis.exists(`sent:${msg.id}`)) return "duplicate";
  const id = await provider.send(msg, { idempotencyKey: msg.id });
  // Mark it only after the provider accepts, so a failed send retries
  await redis.set(`sent:${msg.id}`, id, "EX", 7 * 86_400);
  return "sent";
}
Go deeperCase Study: Notification System
Building blocks · Event sourcing

Store every change as an event, and derive the state from them

An UPDATE overwrites history. Keeping the events instead gives you a full audit trail and lets you build new views later.

  • Append each change as an immutable event, such as ItemAdded or OrderPaid. Events are never updated or deleted.
  • Current state is a fold over the events: replay them in order to rebuild an order, an account or a cart.
  • Replaying thousands of events per read is slow, so save a snapshot every N events and replay only what follows.
  • CQRS splits writes from reads: commands append events, and queries read projections shaped for each screen.
  • Projections update asynchronously, so read models are eventually consistent: a write can take a moment to show.

What you get

  • An audit trail for free: who changed what and when, which ledgers and regulated domains often need.
  • Time travel: replay up to any point to see the state back then, or to debug how it went wrong.
  • New read models later: replay the full history into a new projection for a feature nobody planned.

Watch out

  • Two commands on one order can race. Append with an expected version, and retry if another write got there first.
  • Events live forever, so changing their shape needs versioning, or upcasting old events as you read them.
  • Erasing personal data is hard in an immutable log. Keep it outside the events, or encrypt it per user and delete the key.
  • It's a lot of machinery. Use it where history matters, like money or orders, not for every CRUD table.
const apply = (balance, e) =>
  e.type === "Deposited" ? balance + e.amount : balance - e.amount;
const events = [{ type: "Deposited", amount: 100 },
  { type: "Withdrew", amount: 30 }, { type: "Deposited", amount: 5 }];
events.reduce(apply, 0); // 75: the balance now
events.slice(0, 2).reduce(apply, 0); // 70: the balance back then
const snap = { upTo: 2, balance: 70 }; // saved every N events
events.slice(snap.upTo).reduce(apply, snap.balance); // 75, faster
System design · Typeahead

Debounce cuts requests, but it doesn't stop stale results

Pause twice while typing and two requests are in flight. If the older one answers last, the list shows results for the wrong query.

  • Debounce about 250 ms after the last keystroke and skip queries under 2 characters. A fast "react" sends 1 request.
  • Fix the order with one AbortController per request, aborted in the effect cleanup as soon as the query changes.
  • Some clients and servers ignore the abort, so also check the signal before using a result.
  • Cache results per query in a small LRU with a TTL (100 entries, 60 s), checked before the timer: backspace is instant.

On the server

  • A trie that stores the top-K completions at every node (K = 8) answers in O(prefix length), with no subtree search.
  • Rebuild it offline from query logs and swap it in atomically.
  • Lowercase and trim the prefix so users share CDN cache keys. Short prefixes like "re" are the hottest and cache best.

Accessibility

  • Use the WAI-ARIA combobox pattern: focus stays in the input and aria-activedescendant names the active option.
  • Highlight matches with <mark> elements and React text nodes, never innerHTML.
useEffect(() => {
  const ctrl = new AbortController();
  const timer = setTimeout(async () => {
    const items = await load(query, ctrl.signal);
    if (ctrl.signal.aborted) return;   // a newer query took over
    setItems(items);
  }, 250);
  return () => { clearTimeout(timer); ctrl.abort(); };
}, [query]);
Go deeperBuild an Autocomplete (Typeahead)
System design · Interview

5, 5, 15, 15, 5: the shape of a 45-minute design interview

A fixed structure hits every row of the rubric even when you're nervous, and frees your working memory for the actual problem.

  • Minutes 0–5: functional requirements, non-functional ones with numbers, and an out-of-scope list.
  • 5–10: average and peak QPS, storage, the read:write ratio, and the 3–5 endpoints that matter.
  • 10–25: components, labelled arrows, a numbered request flow and the data model. Breadth first.
  • 25–40: two deep dives on the riskiest areas, each with its failure modes.
  • 40–45: trade-offs, the next bottleneck, what you'd do with more time, and a 30-second summary.

Managing the clock

  • Behind at a checkpoint? Say so and move on: "I'll keep the data model brief so we have time for the deep dives."
  • Managing the clock out loud is itself a senior signal.

When it goes wrong

  • Stuck: go back to the requirements and numbers, simplify to one region and one server, then scale step by step.
  • Pushback: treat it as a hint. Restate the concern, then defend with numbers or adapt explicitly.
  • A gap: "I don't know X in detail, but here's how I'd reason about it." Reasoning beats bluffing.

Common mistakes

  • Jumping to tools (Kafka, Cassandra, Kubernetes) before the requirements.
  • Over-engineering: sharding and multi-region for 40 writes/s.
  • Ignoring failure modes: every box can die, and every network call can time out.
Go deeperInterview Playbook
Building blocks · Stream windows

A stream never ends, so windows and watermarks decide when to answer

"Clicks per minute" sounds simple until events arrive late and out of order. Windows and watermarks are how stream processors cope.

  • Tumbling windows are fixed and don't overlap: 12:00–12:01, 12:01–12:02. Each event lands in exactly one.
  • Sliding (or hopping) windows overlap: 5 minutes long, starting every minute, so each event counts in five windows.
  • Session windows close after a gap of inactivity, say 30 minutes, so one user's burst of activity becomes one window.
  • Group by event time (when it happened), not processing time (when it arrived), or a phone that was offline skews the counts.
  • A watermark says "events older than T shouldn't arrive any more". When it passes a window's end, the window fires.

Late data

  • Events behind the watermark are late. Allow some lateness: keep the window's state a little longer and emit a corrected result.
  • After that, drop late events or route them to a side output to reconcile later.
  • A watermark that trails further behind catches more stragglers, but delays every result by as much.
  • One idle partition can hold the watermark back and stall every window, so mark idle sources to skip them.

Where it's used

  • Flink: watermarks that trail the newest event by a bounded delay, allowed lateness, and side outputs for the rest.
  • Kafka Streams: windowed aggregations with a grace period for late records, running as a library inside your service.
  • Spark Structured Streaming: withWatermark sets how late data may arrive before old window state is dropped.
const SIZE = 60_000, DELAY = 5_000; // 1-min windows, 5 s slack
const counts = new Map(); // window start -> count
let watermark = 0;
function onEvent(t) { // t = event time in ms
  watermark = Math.max(watermark, t - DELAY);
  const w = t - (t % SIZE); // tumbling: one window per event
  if (w + SIZE <= watermark) return "late"; // window already fired
  counts.set(w, (counts.get(w) ?? 0) + 1);
}
// emit and delete every window w with w + SIZE <= watermark
System design · Infinite scroll

Page numbers repeat posts the moment new ones arrive

Offset pagination shows duplicates and gaps as soon as the feed changes. A cursor stays correct, and deep pages stay fast.

  • 5 new posts at the top push every item down 5 places, so the next offset page repeats 5 posts you've seen.
  • With offsets, deleted posts make you skip items instead, and deep offsets are slow: the database walks every skipped row.
  • A cursor asks for posts older than the last one you have, such as ?cursor=p_160. Nothing shifts.
  • On the server, WHERE id < $1 ORDER BY id DESC is an index range scan, as fast on page 500 as on page 1.
  • Fetch LIMIT 21 for a page of 20: the extra row says whether another page exists.

The trigger

  • Observe a sentinel near the end with an IntersectionObserver, not a scroll listener. A 600 px rootMargin prefetches.
  • The observer reports only changes: if a short page leaves the sentinel on screen, re-observe after each page.
  • Keep one request in flight and reset the busy flag in finally, or one failed request freezes the feed.

Keep the DOM small

  • Virtualise rows and measure their real heights: with 10,000 posts loaded, the DOM holds about 20 rows.
  • Don't virtualise a 50-item list: it breaks Ctrl+F and buys nothing.
Go deeperBuild an Infinite Scroll Feed
System design · Interview

State every choice as X because Y, at the cost of Z

Interviewers score reasoning, not a perfect answer, and a choice stated as a fact scores lowest on the rubric.

  • Say what you chose, what you gave up, and when you'd choose differently.
  • Template: "I'll use X because Y. The cost is Z. If W changes, I'd switch to V."
  • Template: "We could do A or B. A is simpler, B scales further. Given our 4K QPS, A is enough for now."
  • Tie each choice to a number from your estimate. Without numbers, no choice can be justified.

Filled in

  • "I'll return a 302 because we promised click analytics. The cost is that every click reaches our servers."
  • "I'll put SMS on separate priority queues because an OTP can't wait behind a campaign. The cost is more queues and workers to run."

What the bar looks like

  • Mid: names some trade-offs.
  • Senior: justifies each choice, and says when to switch.
  • Staff: adds cost, complexity and team impact.
Go deeperInterview Playbook
Building blocks · Merkle tree

Two replicas find their differences by swapping hashes, not data

Comparing two copies of a huge dataset row by row costs as much as copying it. A hash tree pins down the differences with a few hashes.

  • Hash each block of data (or range of keys). Each parent stores the hash of its children's hashes, up to a single root.
  • Change any block and every hash on its path to the root changes, so the root is a fingerprint of everything below it.
  • Equal roots mean equal data. Different roots: compare the children and descend only where hashes differ.
  • One changed block among a billion takes about 30 levels of comparisons, not a billion.

Where it's used

  • Anti-entropy in Dynamo-style stores: Cassandra's repair builds trees per token range and streams only the ranges that differ.
  • Git names every commit, directory and file by its hash, so diffs and fetches skip anything whose hash already matches.
  • Block sync: compare chunk hashes top-down and send only the blocks that changed.
  • Proofs: Bitcoin and Certificate Transparency logs prove an item is included with about log₂ n hashes.

Watch out

  • Building the tree means reading all the data. Cassandra builds it during repair, which costs disk I/O and CPU.
  • Leaves usually cover ranges of keys: a smaller tree, but one mismatched key streams its whole range.
Related lessonConsistency and CAP
System design · Chat

Store, then ack, then fan out: the sentence chat interviews want

It answers durability, duplicates and delivery in one breath, and it's what interviewers listen for.

  • Storing before acking means that once the sender sees the tick, the message is durable: an acked message is never lost.
  • Each send carries a client-generated id. A unique (from, clientId) index turns a retry into a re-ack, not a copy.
  • After the ack, publish to each member's user:ID channel on Redis pub/sub; any gateway holding their sockets delivers it.
  • Gateways keep a set of sockets per user, because one user has several devices.
  • The client shows the bubble at once as sending, keyed by its clientId, and merges the ack into the same entry.

The send path

  • Check membership, dedupe by (from, clientId), INCR the conversation's seq, store, ack, then publish.
  • Two retries arriving at once can both pass the lookup; the duplicate-key error (11000) becomes a re-ack.

Watch out

  • Acking before storing is the classic mistake: crash in between and an acked message is gone.
  • Unsubscribe from user:ID when the user's last socket closes, or Redis keeps sending that gateway traffic.

Numbers to know

  • 10M concurrent sockets at ~50K per gateway is about 200 gateway nodes. Connections, not writes, shape the design.
const dup = await db.messages.findOne({ from: userId, clientId });
if (dup) {
  ack(dup.seq);                            // a retry: just ack again
} else {
  const seq = await pub.incr('seq:' + conversationId);
  await db.messages.insertOne({ conversationId, seq, from: userId,
    clientId, text, ts: Date.now() });
  ack(seq);                                // durable first, then ack
  await broadcast(conversationId, { type: 'message', seq, text });
}
Go deeperDesign a Real-Time Chat App
Building blocks · Locking

Two users save the same record: lock it first, or check on write

Read, change, write: without a lock or a version check, the second save silently erases the first.

  • Lost update: two requests read stock = 5, each sells one and writes 4. Two items sold, but stock fell by one.
  • Pessimistic: SELECT … FOR UPDATE locks the row, and anyone else locking it waits until your transaction ends.
  • Optimistic: keep a version column and write WHERE id = ? AND version = ?. Zero rows updated means someone else won.
  • On a conflict, re-read and retry, or show the user the newer version.
  • Choose by contention: rare conflicts suit optimistic (no locks held), frequent ones suit pessimistic (no wasted retries).

Trade-offs

  • A pessimistic lock lasts the whole transaction. Never hold one while waiting on a user or a slow API.
  • Lock rows in a consistent order, or two transactions can deadlock. The database then aborts one of them.
  • Optimistic works across requests: send the version to the client and back with the edit, like HTTP's If-Match with an ETag.
  • Under heavy contention, optimistic retries pile up and most attempts become wasted work.

Simpler still

  • One atomic statement needs neither: UPDATE items SET stock = stock - 1 WHERE id = ? AND stock > 0.
  • DynamoDB condition expressions and Redis WATCH are optimistic checks too.
// Pessimistic: lock the row; others wait until this transaction ends
//   BEGIN; SELECT * FROM docs WHERE id = 7 FOR UPDATE; ...; COMMIT;
// Optimistic: no lock; the write checks that nobody changed the row
const { rowCount } = await db.query(
  `UPDATE docs SET body = $1, version = version + 1
   WHERE id = $2 AND version = $3`, [body, id, versionWeRead]);
if (rowCount === 0) throw new Error("Conflict: re-read and retry");
Related lessonDatabases and Scaling
System design · Chat

Order chat messages per conversation, never globally

Users never compare the order of two different chats, so a global sequence number is a bottleneck nobody needs.

  • INCR seq:CONVERSATION_ID gives each message a per-conversation seq, and clients sort by it.
  • Gaps are fine: an insert that fails after INCR burns a number, which is harmless.
  • On every reconnect the client sends sync with the last seq it has, and the server replays everything after it.
  • Pub/sub is fire-and-forget, so sync also repairs any live message it dropped. The database is the source of truth.
  • Receipts are watermarks: one lastReadSeq per member, not a row per message. Reading 1042 implies all before it.

Receipts and presence

  • Watermark updates are conditional, so they only move forward, and a receipt is broadcast only when something changed.
  • Presence is a Redis key with a 70 s TTL, refreshed by a heartbeat every 30 s: a crashed client simply expires.
  • Typing is throttled to one event per 3 s, never stored, and sent only to people with that conversation open.

Storage and offline users

  • At very large scale, Cassandra fits exactly: one partition per conversation, rows clustered by seq.
  • Members with no presence key get a push notification, and sync when the app opens.

Huge channels

  • For thousands of members, fan out on read: members pull recent messages when they open the channel, with no per-member receipts.
Go deeperDesign a Real-Time Chat App
Building blocks · Batch vs stream

Batch is simple and late; streaming is fast and hard

Most pipelines are one or the other. Pick by how fresh the answer must be, not by which tool is in fashion.

  • Batch processes a bounded dataset, like yesterday's logs, in one job. Results arrive minutes to hours later.
  • Fixing a bug is easy: correct the code and rerun the job over the same input to replace the output.
  • Streaming processes unbounded data as it arrives, so results land in seconds or less.
  • The cost is state that must survive crashes, events that arrive late or out of order, and duplicates after retries.
  • Exactly-once results need checkpoints plus idempotent or transactional writes; at-least-once is the cheap default.

Lambda and kappa

  • Lambda: a batch layer for correct totals plus a speed layer for fresh estimates, merged at query time. Two codebases to keep in sync.
  • Kappa: one streaming pipeline over a replayable log like Kafka. To reprocess, replay the log through a new version.

When to use which

  • Batch: billing runs, nightly reports, ML training, backfills. Stream: fraud checks, alerting, live dashboards, feeds.
  • Micro-batching, Spark Structured Streaming's default mode, sits in between: the stream runs as a series of small batch jobs.
  • Start with batch unless a requirement needs fresh results: it's cheaper to run and far easier to debug.
System design · News feed

Fan-out on write breaks for an account with 30M followers

Feeds are read about 60 times per post, so you pay at write time to make reads cheap, until a celebrity posts.

  • Push (fan-out on write) adds the post id to every follower's cached feed, so a feed read is one lookup.
  • At 100K fan-out writes per second, a post by someone with 30M followers takes 5 minutes to reach everyone.
  • Pull (fan-out on read) writes once, but every feed open merges hundreds of timelines: too slow for a 200 ms p99.
  • Hybrid: push for normal authors, skip fan-out for celebrities (over ~100K followers) and merge their posts in at read time.
  • A user follows only a handful of celebrities, so the read-time merge stays small.

Feed storage

  • Each feed is a Redis sorted set of post ids scored by time, capped at 800. Store ids, never whole posts.
  • Skip followers inactive for 30 days, and rebuild their feed with a fan-out on read when they log in.
  • Page with a score cursor (older than T), not offsets, so new posts at the top can't shift the page.

Numbers to know

  • 300M users opening the feed 10 times a day is about 35K reads/s; fan-out turns 580 posts/s into 116K writes/s.
  • Caching 800 ids for every user is about 9.6 TB of RAM, so cache feeds only for recently active users.

Crossing the threshold

  • An author going from 90K to 110K followers stops being pushed. The merge dedupes by post id, which handles the crossover.
async function publishPost(post) {
  await db.posts.insert(post);
  await redis.zadd('posts:' + post.authorId, post.createdAt, post.id);
  if ((await followerCount(post.authorId)) > 100000) return; // pull
  for await (const to of followerBatches(post.authorId, 1000)) {
    await queue.publish('fanout', { postId: post.id, to });
  }
}
Go deeperDesign a News Feed (Twitter or Instagram)
Building blocks · Columnar storage

Store data by column when queries scan a few fields of every row

A row store reads every column of every row it scans. For "revenue per day" over a billion rows, most of that I/O is wasted.

  • A row store keeps each record together, so one read fetches a whole order. Ideal for OLTP: lookups and updates by key.
  • A column store keeps each column together, so SUM(total) … GROUP BY day reads those two columns and skips the rest.
  • A column holds one type with many repeats, so it compresses well: dictionary, run-length and delta encoding.
  • Engines scan a column in batches with vectorised (SIMD) loops instead of one row at a time.
  • Most formats keep min and max values per block, so a filter skips whole blocks that can't match.

Worked example

  • 1B rows × 50 columns of 8 bytes: summing one column scans 400 GB in a row store and 8 GB in a column store, before compression.

Where it's used

  • Warehouses: BigQuery, Redshift, Snowflake. Real-time analytics: ClickHouse, Apache Druid, Apache Pinot.
  • Parquet and ORC are columnar file formats for data lakes, read by Spark, Trino and DuckDB.

Watch out

  • Fetching one whole record reads from every column. Keep OLTP in a row store and stream changes across with CDC.
  • Updates and deletes are expensive. Column stores want append-only data, inserted in batches of thousands of rows.
Related lessonStoring Data: Choosing the Right Store
System design · Geospatial index

Find nearby drivers by cell, and always read the 8 neighbours

5M drivers report every 4 s. Only the latest fix matters for matching, so it lives in memory, indexed by cell, never as a database row.

  • 1.25M updates/s: overwrite each driver's position in Redis GEO (one key per city) with a TTL, and batch history to Kafka.
  • A geohash interleaves longitude and latitude bits, so a shared prefix means a shared cell. Precision 6 is about 1.2 × 0.6 km.
  • Cells have edges: a driver 10 m away can sit in the next cell, under another prefix. Always read the 8 neighbours too.
  • Widen ring by ring. After ring k, anything unscanned is at least k cells away, so stop once the best are closer than that.
  • Rank the shortlist by ETA from the routing service: the nearest car may be across a river.

Alternatives

  • Quadtrees split cells where drivers are dense, at the cost of a tree to rebalance as they move.
  • H3 hexagons put every neighbour at the same distance, so rings are natural.
  • GEOADD overwrites a member's geohash score, so a moving driver leaves its old cell automatically.

Watch out

  • GEO members never expire: keep a loc: key with a 15 s TTL and skip drivers without one.
  • Two points 31 m apart can hash to s00000 and 7zzzzz: prefixes alone can't find neighbours.
for (; k <= maxRings; k++) {
  for (const cell of ringCells(center, k))
    for (const d of index.inCell(cell)) found.push(near(d));
  found.sort((a, b) => a.dist - b.dist);
  // anything not scanned yet is at least k cells away
  if (found.length >= want && found[want - 1].dist <= k * cell) break;
}
Go deeperDesign Uber (Ride Matching)
Building blocks · Service discovery

Instances come and go all day. How does a caller find a live one?

Autoscaling, deploys and failed hosts keep changing instance addresses, so a hard-coded host list is always going stale.

  • Each instance registers its address with a registry when it starts, and deregisters when it shuts down.
  • The registry runs health checks, or expects heartbeats, and drops instances that stop answering.
  • Client-side: the caller fetches the healthy instances from the registry and load-balances across them itself.
  • Server-side: the caller hits one stable address, a load balancer or proxy, which looks up the instances for it.
  • DNS-based: a name resolves to the healthy instances. Simple and universal, but answers are cached until the TTL expires.

Where it's used

  • Kubernetes: a Service gets a stable DNS name and virtual IP, and routes only to pods that pass their readiness check.
  • Consul keeps a catalog with health checks and answers over DNS or HTTP.
  • In etcd or ZooKeeper, an instance's entry is tied to a lease or session, so it vanishes when the instance stops renewing it.
  • A service mesh sidecar such as Envoy intercepts calls and does discovery and load balancing for the app.

Watch out

  • Resolvers and runtimes cache DNS, some for longer than the TTL. Keep TTLs short for anything that moves.
  • The registry is critical: run it replicated (Consul and etcd use Raft), and let clients keep their last known list if it's down.
  • On shutdown, deregister and drain first, or callers keep hitting an instance that's already gone.
Related lessonClient, API and Load Balancing
System design · Matching

One ride, one driver: reserve with a compare-and-set, then offer

Many matchers race for the same nearby cars. Check-then-write lets two of them offer one driver two rides in the same millisecond.

  • Driver state goes AVAILABLE → OFFERED → ON_TRIP, and every step is a compare-and-set, never a read followed by a write.
  • Reserve first: AVAILABLE → OFFERED succeeds for one matcher. The loser gets 0 and moves to its next candidate.
  • Offer one driver at a time with a 10–15 s countdown. A decline or silence sets the driver back to AVAILABLE.
  • The reservation has a TTL, so a matcher that crashes mid-offer can't strand a driver.
  • The ride side too: UPDATE rides … WHERE state = 'REQUESTED', so a ride never gets two drivers.

How to build it

  • Redis: a Lua script that checks state and writes it in one atomic step.
  • SQL: UPDATE drivers SET state = 'OFFERED' WHERE id = $1 AND state = 'AVAILABLE', then check the row count.
  • Backstop: a partial unique index that allows one active ride per driver.

Watch out

  • An accept that arrives after the reservation expired must fail its compare-and-set: show offer expired.
  • Offering one ride to several drivers at once creates races you then have to break.
const ok = await store.cas(id, { state: 'AVAILABLE' },
  { state: 'OFFERED', rideId: ride.id, ttlMs: 15_000 });
if (!ok) continue;            // another matcher won: next candidate
const answer = await answerWithin(offer(id, ride), 12_000);
Go deeperDesign Uber (Ride Matching)
Building blocks · Safe deploys

Ship to 1% first: a canary turns a bad deploy into a small incident

Most outages start with a change. Blue-green, canaries and flags limit how many users a bad release reaches and how fast you undo it.

  • Blue-green: run the new version beside the old and flip all traffic at the load balancer. Rollback is flipping back.
  • It's instant but costs double capacity, and every user meets the new version at the same moment.
  • Canary: send 1% of traffic to the new version, compare its errors and latency with the old, then step to 10%, 50%, 100%.
  • Rolling: replace instances a few at a time. No spare fleet, but old and new versions serve side by side for a while.
  • Feature flags separate deploy from release: ship code dark, turn it on for staff, then 1%, then everyone, with no deploy.

Database migrations

  • During any rollout old and new code run together, so every schema change must work with both versions.
  • Expand, then contract: add the new column, write to both, backfill, switch reads, then drop the old column in a later release.
  • Blue-green flips the servers, not the database: both colours usually share one, so its changes must stay compatible.

Watch out

  • Automate the canary verdict: roll back when its error rate or p99 is worse than the baseline's, not when someone notices.
  • 1% of a quiet service may be a handful of requests. Hold the canary longer until the numbers mean something.
  • Flags pile up: remove each one once it's fully on, or every check doubles the code paths you must test.
const crypto = require("node:crypto");
// Same user, same answer: hash flag + user into a bucket 0–99
function isOn(flag, userId, percent) {
  const h = crypto.createHash("sha256").update(`${flag}:${userId}`);
  return h.digest().readUInt32BE(0) % 100 < percent;
}
// Raising percent only adds users; nobody flips back and forth
isOn("new-checkout", "user-42", 1);
Related lessonReliability and Observability
System design · Web crawler

A crawler frontier is one queue per host plus a heap of ready times

A plain queue hands one site's thousands of URLs to every worker at once. The frontier's job is speed overall and gentleness per site.

  • Front queues rank URLs by importance. Back queues keep one queue per host, so a delay can be enforced for each site.
  • A min-heap of ready at times says which host may be fetched next. A host leaves the heap while a fetch is in flight.
  • At a 10 s gap one host gives 0.1 page a second, so 386 pages a second needs about 3,860 hosts in play.
  • After each fetch the host rests for the larger of its Crawl-delay and ten times the fetch time. A 429 or 503 doubles it.

How to scale it

  • Hash the host to pick the crawler node, so a host's queue, timer and robots cache live on one machine.
  • Links to another node's hosts travel on a Kafka topic partitioned by host.
  • Persist the frontier, so a restart loses no queued URLs.

Watch out

  • Cap queued URLs per host. Calendars and session ids make endless new URLs that no seen-check catches.
  • A blocking DNS lookup is the usual hidden bottleneck: cache it and resolve asynchronously.
next(now) {
  const top = ready.peek();
  if (!top) return { url: null };
  if (top.at > now) return { url: null, waitMs: top.at - now };
  ready.pop();                 // host is busy until done()
  return { url: hosts.get(top.host).takeBest() };
}
Go deeperDesign a Web Crawler
Building blocks · ID generation

Choose ids by how they sort, not only by how unique they are

Ids look trivial until you shard. Then you need ids that are unique without coordination and that don't wreck your index.

  • Auto-increment is compact and ordered, but one database hands it out. It breaks with several primaries and reveals your volume.
  • UUIDv4 is 122 random bits, unique with no coordination. But random keys land all over a B-tree, splitting pages and missing the cache.
  • Time-ordered ids put the timestamp first, so new keys append at the right edge of the index and sort roughly by creation time.
  • UUIDv7: a 48-bit millisecond timestamp, then random bits, in 128 bits. A standard UUID that indexes like a sequence.
  • Snowflake: 41 bits of milliseconds, 10 of machine id and 12 of sequence in a 64-bit integer: 4,096 ids per ms per machine.

Watch out

  • Clock skew: if a machine's clock jumps back, a Snowflake generator must wait or refuse, or it can repeat ids.
  • Machine ids must be unique. Assign them in config, or claim one from a coordinator (ZooKeeper, etcd) at startup.
  • Time-ordered ids reveal when a row was made, and sequential ones are guessable: never use either as a secret.
  • 64-bit ids exceed JavaScript's safe integers (2^53): send them to browsers as strings.

Trade-offs

  • Across machines, time-ordered ids sort only roughly: clocks drift apart, and ids made in the same millisecond tie on time.
  • Range-sharding on a time-ordered key sends every new write to one shard. Hash the id to pick the shard.
  • Need short numeric ids? Range allocation: each server reserves 1,000 with one INCRBY and hands them out locally.
import { randomBytes } from "node:crypto";
// UUIDv7: a 48-bit Unix time in ms first, then random bits
function uuidv7() {
  const b = randomBytes(16);
  b.writeUIntBE(Date.now(), 0, 6); // timestamp, big-endian
  b[6] = (b[6] & 0x0f) | 0x70;     // version 7
  b[8] = (b[8] & 0x3f) | 0x80;     // variant bits 10
  const h = b.toString("hex");
  return h.replace(/(.{8})(.{4})(.{4})(.{4})/, "$1-$2-$3-$4-");
}
Related lessonSharding Strategies
System design · Web crawler

Remember 5 billion URLs in 6 GB with a Bloom filter

A crawler asks 'have I queued this URL?' for every link it finds. An exact set of hashes costs 40 GB; a Bloom filter costs about 6.

  • Normalize first: lower-case the host, drop fragments and tracking parameters, sort the query. One page, one string.
  • A Bloom filter at 1% needs about 9.6 bits per URL and 7 hashes. A 'no' is always right.
  • A 'yes' is wrong about 1% of the time, so about 1 new URL in 100 is skipped. Another inbound link usually brings it back.
  • Content needs its own check: an exact hash for identical pages, a simhash (a few bits apart) for near-duplicates.

The arithmetic

  • Bits per item is -ln(p) / (ln 2)^2: 9.585 at 1%. For 5B URLs that is 5.99 GB.
  • Exact 8-byte hashes: 5B × 8 B = 40 GB, about 6x more.

Watch out

  • A Bloom filter cannot delete. Rebuild it by epoch from the page table when it fills.
  • Partition it by host, like the frontier, so each node owns its slice.
const { bits, hashes } = bloomSize(5e9, 0.01);
// bits / 8 = 5.99e9 bytes, hashes = 7
if (filter.addIfNew(normalize(link, pageUrl))) {
  frontier.add(link, score(link));
}
Go deeperDesign a Web Crawler
Building blocks · Backpressure

An unbounded queue doesn't absorb overload, it just delays the crash

When work arrives faster than you finish it, something has to give. Choose what, or latency and memory choose for you.

  • 1,000 requests a second in and 800 out grows the backlog by 200 a second, until something breaks.
  • Bound every queue. When it's full, the producer blocks, slows down or gets an error: that's backpressure.
  • At the edge, reject early with 429 or 503 and Retry-After, before spending CPU on auth or the database.
  • Shed by priority: drop prefetch and analytics first, and protect checkout and login.
  • Skip queued work that's past its deadline: the client has already given up.

Why unbounded fails

  • After an hour, 720,000 requests wait. At 800 a second, a new one waits 15 minutes, long after its client timed out.
  • Timed-out clients retry, adding load, until the queue exhausts memory.
  • Serving everyone slowly means everyone times out: the servers run at 100% while goodput falls toward zero.

Backpressure in practice

  • In Node.js streams, write() returns false when the buffer is full: wait for drain before writing more.
  • TCP and HTTP/2 flow control stop a fast sender from overrunning a slow receiver.
  • Kafka consumers pull at their own pace, so the backlog waits on disk in the log, not in a service's memory.
  • Limit concurrency, not just rate, and autoscale consumers on queue depth or age, not CPU.
function admit(queue, job, max) { // false: answer 503 + Retry-After
  const limit = job.priority === "low" ? max * 0.8 : max;
  if (queue.length >= limit) return false; // low priority goes first
  queue.push({ ...job, deadline: Date.now() + 2_000 });
  return true;
}
function next(queue) { // skip jobs whose caller has already given up
  while (queue[0]?.deadline < Date.now()) queue.shift();
  return queue.shift();
}
Related lessonAsync Processing and Messaging
System design · Video

Adaptive bitrate: the player picks quality one segment at a time

A video that buffers loses the viewer. The fix is to cut it into short segments at several qualities and let the player choose.

  • Encode each video at a ladder of qualities (here 0.4, 0.8, 1.2, 2.5 and 5 Mbps), cut into 4-second segments aligned across qualities.
  • The player times each segment download and keeps a smoothed throughput estimate.
  • It picks the best quality under 80% of that estimate. At 4 Mbps measured, that is 720p, not 1080p.
  • Down is fast, up is slow: a thin buffer steps down at once; stepping up needs a healthy buffer and goes one level at a time.

Why it works

  • A master playlist lists the qualities; each quality has its own list of segments. The server stays dumb and cacheable.
  • Segments are plain files, so any CDN can serve them over ordinary HTTP.

Watch out

  • Without a safety margin, every small dip drains the buffer. Without hysteresis, quality flaps between two levels.
  • Short segments adapt faster but add requests; long segments react slowly.
// best quality whose bitrate fits 80% of throughput
let target = 0;
for (let i = 0; i < ladder.length; i++)
  if (ladder[i].bps <= throughputBps * 0.8) target = i;
if (bufferS < 5 && current > 0) return Math.min(target, current - 1);
if (target > current) return bufferS >= 12 ? current + 1 : current;
return target;
Go deeperDesign YouTube (Video Streaming)
Building blocks · Autoscaling

Autoscaling reacts minutes late, so scale on what rises first

Scale on the wrong signal and servers arrive after users have felt the slowdown, or arrive when nothing needed them.

  • Pick the signal by workload: request rate for APIs, queue depth or lag for workers, and CPU only when the work is CPU-bound.
  • Target tracking: set a target, like 60% CPU or 100 messages per worker, and the scaler adds or removes instances to hold it.
  • Cooldowns stop flapping: scale out quickly, and scale in slowly, only after load has stayed low for several minutes.
  • New instances take minutes to boot, warm up and pass readiness, so keep headroom or scale ahead on a schedule.
  • Scale-in must drain: stop routing new requests to the instance, let in-flight ones finish, then terminate it.

Watch out

  • The database doesn't scale with you: going from 10 to 50 instances at 20 connections each takes 200 connections to 1,000.
  • Set a maximum: a retry storm or a bot can scale you into a huge bill or overload a dependency.
  • Don't scale on latency alone: if a slow dependency is the cause, more instances only add load to it.
  • CPU misleads for I/O-bound services: they slow down waiting on the database while CPU stays low.

Predictable peaks

  • Scheduled scaling adds capacity before the 9 am rush or a sale starts, instead of after the queues build.
  • Predictive scaling forecasts from past daily and weekly patterns, but it can't foresee a one-off spike.
// Kubernetes HPA: scale in proportion to how far off target you are
const desired = (replicas, current, target) =>
  Math.ceil(replicas * (current / target));

desired(4, 90, 60); // 6: CPU at 90% against a 60% target
desired(6, 30, 60); // 3, once the scale-down window has passed
Related lessonAsync Processing and Messaging
System design · Video

Transcode a video as 750 small idempotent tasks

One long encode is slow and restarts from zero when it fails. Splitting by segment and quality makes the work parallel and retryable.

  • Cut a 10 minute video into 150 four-second segments. Five qualities make 750 encode tasks, then one task writes the playlists.
  • Each task reads the same source and writes a path from video, quality and index, so running it twice is harmless.
  • A coordinator gives tasks leases. If a worker stops heartbeating, the lease expires and another worker takes the task.
  • A day of uploads (500K videos, 10 minutes, 5 qualities, 2x realtime per core) needs about 8,700 cores.

Uploads feeding it

  • Clients send 8 MiB chunks straight to object storage. 500 MiB is 63 chunks.
  • After a failure the client asks how many bytes arrived in order and continues from there.

Watch out

  • A corrupt source fails every attempt: cap retries, mark the video FAILED with a reason, tell the creator.
  • Show a video only when the status is READY, after the playlists exist.
for (const r of ladder)
  for (let i = 0; i < segments; i++)
    tasks.push({ id: `${v}/${r.label}/${i}`, from: i * 4,
      out: `v/${v}/${r.label}/seg_${i}.m4s` });
tasks.push({ kind: 'package', after: tasks.map((t) => t.id) });
Go deeperDesign YouTube (Video Streaming)
System design · File sync

Name chunks by their hash: sync deltas, resume, dedupe

Uploading a whole file on every save is wasteful. Cut files into chunks named by content and most of that work disappears.

  • Split files into about 4 MiB chunks and name each by its SHA-256. Same bytes, same name.
  • The client sends hashes and asks 'which are new?', then uploads only those. One edit in a 1 GiB file is 1 of 256 chunks: 0.4%.
  • A broken upload resumes at the next unknown chunk. Identical chunks, even from different users, are stored once.
  • With fixed boundaries, one inserted byte shifts every chunk. Content-defined chunking cuts where a rolling hash matches, so most hashes survive.

The numbers

  • 200 PB at 4 MiB is about 47.7 billion chunks; at 64 B each the chunk index is about 3 TB, sharded by hash.
  • In a test, a 100-byte insert kept over 95% of content-defined chunks and under 5% of fixed ones.

Watch out

  • Cross-user dedupe can reveal that someone else has a file: ask the client to prove it holds the bytes.
  • Delete chunks only when the reference count is zero, after a grace period, so an in-flight commit cannot lose one.
const chunks = await fixedChunks(bytes);  // [{ hash, start, length }]
const hashes = chunks.map((c) => c.hash);
const need = await api.unknownChunks(hashes);
for (const c of chunks.filter((x) => need.includes(x.hash)))
  await api.putChunk(c.hash, slice(bytes, c));
await api.commit({ path, baseRev, chunks: hashes });
Go deeperDesign Dropbox (File Sync)
System design · File sync

Sync with revisions and cursors; keep conflicts, never overwrite

Two offline devices edit the same file. A system that quietly picks a winner loses someone's work.

  • A file is a path, a revision number and an ordered list of chunk hashes. A commit names the base revision it started from.
  • If the file is still at that revision, it moves to the next. If not, the commit is refused: a conflict.
  • The loser saves a 'conflicted copy' as a new file, so nothing is lost or silently overwritten.
  • Each accepted commit is appended to a per-user journal. Devices ask 'what changed since cursor N?', which is safe to repeat.

Telling devices

  • An online device holds a connection to a notifier that sends a tiny poke; the device then pulls the journal.
  • A lost poke only delays sync; a slow backstop poll and the cursor fix it. About 50M devices need ~1,000 notifier servers at 50K each.

Watch out

  • Use cursors, not timestamps: clock skew between devices can hide a change.
  • The metadata service must check every chunk exists before accepting a commit.
commit({ path, baseRev, chunks }) {
  const curRev = this.files.get(path)?.rev ?? 0;
  if (baseRev !== curRev) return { ok: false, conflict: true };
  // ... check chunks exist, save rev + 1, append to the journal
  return { ok: true, rev: curRev + 1 };
}
Go deeperDesign Dropbox (File Sync)
System design · Key-value store

Quorums: pick W and R so a read always meets the latest write

A leaderless store keeps each key on N nodes. How many must answer decides how fresh your reads are, and how many failures you survive.

  • Keep each key on N = 3 nodes. A write succeeds after W acks; a read asks R replicas and takes the newest.
  • If R + W > N, every read set overlaps every write set, so a read sees the latest acknowledged write.
  • W = 2, R = 2 survives one dead replica. W = 1, R = 1 is fastest but can read stale: a random read misses a random write 2 times in 3.
  • W = 3 blocks writes when any one node is down; W = 1, R = 3 makes reads slow. Choose per request.

Staying available

  • A sloppy quorum plus hinted handoff lets a stand-in node hold writes for a down replica and hand them over later.
  • Read repair: when a read sees a stale replica, the coordinator writes the newest version back to it.

Watch out

  • Quorums alone are not linearizable: concurrent writes and sloppy quorums can still return old data.
  • Strong consistency needs a consensus group such as Raft per key range, at the cost of some availability.
Go deeperDesign a Distributed Key-Value Store
System design · Key-value store

Vector clocks find conflicts; Merkle trees find drift

With no leader, two clients can write one key at once, and replicas drift apart. Two small structures handle both.

  • A vector clock is a map of node to counter. If one clock includes the other, keep the newer version.
  • If neither includes the other, the writes were concurrent: keep both as siblings and let the client merge them.
  • Last-write-wins picks one by timestamp. It is simple, but clock skew can silently drop the newer write.
  • Merkle trees let two replicas find differing keys: compare root hashes, then walk only the mismatching branches.

How much work

  • With 1,024 leaves, a single stale key is found in 21 comparisons: the root, then two children at each of 10 levels.
  • Only the keys in the mismatching leaf are copied.

Watch out

  • A delete must be stored as a tombstone and kept past the repair interval, or repair brings the value back.
  • Clocks grow with the number of writers; real systems prune old entries.
compare({ A: 2 }, { A: 1, B: 1 });   // 'concurrent'
compare({ A: 2 }, { A: 1 });         // 'after'
const merged = merge({ A: 2 }, { A: 1, B: 1 });   // { A: 2, B: 1 }
siblings([v1, v2, v3]);              // drops superseded versions
Go deeperDesign a Distributed Key-Value Store
System design · ID generation

Snowflake ids: 41 bits of time, 10 of worker, 12 of counter

Unique ids across many servers without asking a database each time: pack time, who made it, and a counter into one 64-bit number.

  • 1 + 41 + 10 + 12 bits: sign, milliseconds since a custom epoch, worker id, per-millisecond sequence.
  • 41 bits of milliseconds last 69.7 years. 12 bits allow 4,096 ids per ms, so 4.096M a second per worker.
  • 10 bits allow 1,024 workers. Two workers can never collide because their worker bits differ.
  • Ids sort by time, so inserts append to the end of a B-tree index, unlike random UUID v4.

How a generator works

  • Read the clock. Same millisecond as the last id: increment the sequence. New millisecond: reset it to 0.
  • If the sequence overflows, wait for the next millisecond.
  • Lease worker ids from etcd or ZooKeeper with a TTL, and stop issuing ids shortly before the lease ends.

Watch out

  • Ids are roughly time-ordered across servers, not strictly: clocks differ by milliseconds.
  • Ids go up to 2^63, beyond JavaScript's 2^53 exact integers: send them as strings in JSON.
// layout: time(41) | worker(10) | sequence(12)
const id = (ts << 22n) | (worker << 12n) | seq;
const time = Number((id >> 22n) + EPOCH);
const wk   = Number((id >> 12n) & 1023n);
const sq   = Number(id & 4095n);
Go deeperDesign a Unique ID Generator
System design · ID generation

When the clock goes backwards, a Snowflake generator must stop

NTP can step a clock back. An earlier timestamp with the same worker and sequence can repeat an id, and a duplicate key corrupts data.

  • Remember the last timestamp used. If now is earlier, the clock moved back.
  • A tiny step (a few ms) is waited out: spin until the clock catches up, and ids stay increasing.
  • A large step is refused: raise an alert and fail over to another instance. A short error beats a duplicate.
  • Prefer NTP slewing (gradual correction) to stepping, and keep every clock monitored for drift.

Two related risks

  • Two live servers with the same worker id also repeat ids: lease worker ids and stop before the lease ends.
  • A crash only wastes the sequence numbers it had not used. Gaps are fine; duplicates are not.

Alternatives

  • Ticket servers hand out blocks of a database counter, in odd and even pairs: no clocks, but a round trip per block.
  • UUID v7 puts a 48-bit millisecond timestamp ahead of random bits: sortable and coordination-free, at 128 bits.
if (ts < last) {
  const behind = last - ts;
  if (behind > maxBackwardMs) throw new Error('clock moved back');
  while (ts < last) ts = now() - EPOCH;   // wait out a small step
}
Go deeperDesign a Unique ID Generator
System design · Collaborative editing

Operational transform: rewrite the later edit to fit the earlier

Two people edit 'cat' at once: one adds an s, one deletes the c. Positions go stale, so plain edits would break the text.

  • Show each edit locally at once, and send it with the revision it was based on.
  • A server owns the document, gives every accepted edit the next revision, and transforms it past edits the sender had not seen.
  • 'cat': A inserts s at 3, B deletes position 0. B's delete stays at 0; A's insert becomes position 2 for B. Both end on 'ats'.
  • Each client keeps one edit in flight and transforms incoming edits past its pending ones, so typing never waits.

Durability

  • An edit is acknowledged only after it is appended to the operation log. The log is the truth and the version history.
  • Snapshot every few hundred edits. Recovery is the latest snapshot plus the log tail, at most about 500 operations.

Watch out

  • Transform functions are subtle: test that random pairs of operations converge in both orders.
  • Give edits ids so a retry after a crash is not applied twice.
if (a.type === 'insert' && b.type === 'delete')
  return a.pos <= b.pos ? a : { ...a, pos: a.pos - 1 };
if (a.type === 'delete' && b.type === 'insert')
  return a.pos < b.pos ? a : { ...a, pos: a.pos + 1 };
Go deeperDesign Google Docs (Collaborative Editing)
System design · Collaborative editing

CRDT text: give every character an id and never rewrite edits

A CRDT merges edits in any order with no central server, which suits offline editing. The cost is memory.

  • Every character has a permanent id (counter and site). An insert says 'after character X'.
  • A delete only marks the character dead: a tombstone. Late inserts that refer to it still find their place.
  • Concurrent inserts at one spot are ordered by id, the same on every replica, so all copies converge.
  • Operations arriving out of order wait until their parent character exists, then apply. Duplicates are harmless.

OT or CRDT?

  • OT: compact, needs a central server to order edits, and transform functions are subtle.
  • CRDT: works peer-to-peer and offline, but ids and tombstones grow until the document is compacted.

Watch out

  • An emptied document may still remember every deleted character: plan compaction.
  • Rich text needs attributes on characters, which add more state to merge.
// insert after the node with id op.after
let i = at + 1;
while (i < nodes.length && cmp(nodes[i].id, op.id) > 0) i++;
nodes.splice(i, 0, { id: op.id, ch: op.ch, dead: false });
Go deeperDesign Google Docs (Collaborative Editing)
System design · Nearby search

Find places near me: pick the geohash length from the radius

Scanning 200 million businesses per search is impossible. A geohash turns 'near me' into a few indexed prefix lookups.

  • A geohash is a short string for a map cell. A longer string is a smaller cell, and a shared prefix means the same cell.
  • In SQL it is an indexed column: WHERE geohash LIKE '9q8yy%' is a range scan.
  • Use the longest hash whose cells are at least as big as the radius. Then the circle touches at most 3 x 3 = 9 cells.
  • In San Francisco: 0.5 km uses 6 characters, 1 and 2 km use 5, 5 km uses 4, 20 km uses 3. Scan those cells, filter by exact distance, sort.

Why the cover matters

  • A place across a cell edge can be nearer than most in your own cell, so reading only your cell misses results.
  • Cells shrink toward the poles, so the width check uses cos(latitude).

Watch out

  • Density is uneven: a midtown cell can hold thousands of places while the next holds none.
  • Cursor paging by distance and id keeps page two stable; offsets drift when data changes.
const { cells } = coveringCells(lat, lon, radiusKm);
const found = [];
for (const cell of cells)
  for (const row of rangeScan(cell))      // LIKE 'cell%'
    if (haversineKm(origin, row) <= radiusKm) found.push(row);
found.sort((a, b) => a.distKm - b.distKm);
Go deeperDesign Yelp (Nearby Places)
System design · Nearby search

A quadtree splits the map only where places are dense

Manhattan and Montana need different cell sizes. A quadtree gives each area the size it needs.

  • Start with the whole map as one box. When a box holds more than about 100 businesses, split it into four.
  • Dense areas become many tiny leaves; empty areas stay as one big leaf. Every leaf stays small.
  • Search visits only boxes that touch the circle's bounding box, then checks exact distances.
  • 200M businesses make about 2M leaves and 1.9 GB: small enough for a full in-memory copy on every search server.

Operating it

  • Rebuild nightly from the database and save a snapshot. A restarted server loads it and replays the change feed.
  • Scale reads by adding copies. A search server answers any query alone.

Watch out

  • Limit depth, or many identical points will split a leaf forever.
  • If the index outgrows one machine's memory, move to S2 or H3 cells sharded by cell id.
insert(p) {
  let node = this.root;
  while (node.kids) node = this.childFor(node, p);
  node.points.push(p);
  if (node.points.length > 100 && node.depth < maxDepth)
    this.split(node);          // four children, redistribute points
}
Go deeperDesign Yelp (Nearby Places)
System design · Distributed cache

Size a cache from the database's limit, not from a guess

A cache exists to protect something slower. The slow thing's limit tells you the hit ratio you need, and the node count follows.

  • Database serves 20K reads a second, traffic is 1M. Misses must be 20K or fewer, so the hit ratio must be 98%.
  • At 95%, 50K reads a second reach the database: 2.5 times its limit.
  • Nodes by memory: 130 GB working set ÷ 24 GB usable = 6. By throughput: 1M ÷ 100K per node = 10, so run 12 with headroom.
  • Lose one of 12 nodes and 8.3% of keys miss: about 80K extra reads a second headed for a database that handles 20K.

Routing

  • Hash mod N moves almost every key when N changes. Rendezvous or consistent hashing moves only about 1/N.
  • Do the routing in the client library: one network hop on a hit, and no central router to scale.

Watch out

  • Count memory in bytes, not entries, and add overhead for keys and pointers.
  • After a restart, warm the cache gradually so the database is not flooded.
Go deeperDesign a Distributed Cache
System design · Distributed cache

Stampedes and hot keys: single flight and local copies

Two cache failures look like the cache working and still take the database down: everyone missing at once, and everyone asking one node.

  • A popular key expires and 1,000 requests miss together. Single flight: the first loads, the other 999 wait for that same load.
  • Add up to 10% jitter to TTLs so keys written together do not expire together. Cache 'not found' for a few seconds too.
  • A hot key lives on one node, so more nodes cannot help. Count requests per key and keep a copy in each app server for about a second.
  • A stale fill: a slow reader fills the cache after a writer deleted the key. Use short TTLs, a delayed second delete, or versioned values.

In tests

  • 1,000 simultaneous misses cause exactly one database read.
  • 5,000 reads of a hot key cut to under 150 node reads with a one second local copy.

Watch out

  • Do not cache failures: a failed load should be retried by the next request.
  • Rate-limit the load path so a cold node or restart cannot flood the database.
async do(key, load) {
  const running = inflight.get(key);
  if (running) return running;       // join the load in progress
  const p = load().finally(() => inflight.delete(key));
  inflight.set(key, p);
  return p;
}
Go deeperDesign a Distributed Cache
System design · Monitoring

Metrics storage: compress neighbours, roll up the old

100K hosts reporting 100 metrics every 10 seconds make 1 million samples a second. Raw, that is 1.38 TB a day.

  • 10M series every 10 s is 1M samples/s. At 16 bytes each (timestamp and value) that is 1.38 TB a day.
  • Timestamps follow a schedule, so the delta of deltas is nearly always 0: about 1 bit. Values change slowly, so XOR leaves few set bits.
  • Facebook's Gorilla paper reports about 1.37 bytes a sample on real data: roughly 118 GB a day here.
  • Keep raw for 15 days, then 1-minute and 1-hour rollups of min, max, sum, count. About 10.6 TB covers two years.

Percentiles

  • Averaging p99 values is wrong: a quiet minute counts like a busy one.
  • Store a histogram per bucket (counts per latency range). Histograms add exactly, so any window's p99 is right.

Watch out

  • Every distinct label set is a new series. A label like user_id with a million values multiplies cost by a million.
  • Enforce series quotas per team, and alert when the series count jumps.
Go deeperDesign a Metrics and Monitoring System
System design · Monitoring

Alerts are state machines: pending first, firing later

An alert that fires on every blip trains people to ignore pages. An alert that never fires is worse.

  • A rule moves inactive, pending, firing, resolved. The condition must hold for the whole for duration (say 5 minutes) before it fires.
  • A 20-second spike goes pending and then back to inactive: nobody is paged.
  • Missing data counts as bad by default, so a host that went silent alerts instead of looking healthy.
  • The notifier groups related alerts: 30 hosts in one cluster make one page, with repeats suppressed for hours.

Reliability

  • Run two evaluators, and a canary alert that must always fire. If it goes quiet, the pipeline is broken.
  • Monitor the monitor with separate infrastructure, so an outage does not blind you.

Watch out

  • A long for-duration means slower detection: use short ones for outages, longer ones for capacity warnings.
  • Support silences for maintenance windows, or people will mute rules for good.
evaluate(value, now) {
  const bad = value == null ? true : value > threshold;
  if (!bad) return this.clear();             // pending blips vanish
  if (this.state === 'inactive') this.begin(now);
  if (now - this.since >= forMs) return this.fire();
}
Go deeperDesign a Metrics and Monitoring System
System design · LRU cache

An LRU cache is a hash map plus a doubly linked list

A map alone cannot say which key is oldest, and a list alone cannot find a key fast. Together both operations cost O(1).

  • The map goes from key to list node, so a lookup is one step.
  • The list holds keys from newest (front) to oldest (back).
  • A hit unlinks the node and pushes it to the front. A full put removes the back.
  • A dummy head and tail mean every real node has two neighbours, so no edge cases.
  • Update both structures in one method, or a key is evicted twice or never.

Put, in order

  • A key that already exists is replaced and refreshed. Nothing is evicted.
  • A new key on a full cache evicts first, then inserts, so the size never passes capacity.

Watch out

  • Evicting before checking whether the key exists throws away a good entry.
  • A model test against a simple ordered map over thousands of random calls finds list bugs.
put(key, value) {
  if (this.entries.has(key)) { /* replace, refresh */ return; }
  if (this.entries.size >= this.capacity)
    this.#remove(this.policy.victim(), 'evicted');
  this.entries.set(key, { value });
  this.policy.onInsert(key);
}
Go deeperDesign an LRU Cache (LLD)
System design · LRU cache

In an LRU cache, a read is also a write

A hit moves the key to the front of the list. Two readers relinking at once can corrupt it, so reads need the lock too.

  • Start with one lock around get and put. It is correct, and cheap while the work is a few pointer moves.
  • A plain read-write lock does not help: every hit would still need the write side.
  • Lock striping hashes each key to one of N small caches, each with its own lock.
  • Run listeners after unlocking, so a slow or re-entrant one cannot stall or deadlock the cache.

Scaling further

  • Striping makes the order approximate across stripes, which is usually fine.
  • High-end caches buffer hits and replay them in batches. It is faster and harder to reason about.

A hot key

  • All threads still queue on one lock. A tiny front cache or a per-key copy absorbs it.
Go deeperDesign an LRU Cache (LLD)
System design · Logger design

A logger tree: levels inherited, appenders fan out

Name loggers with dots, and a child can inherit its level from the nearest ancestor that has one.

  • app.db has app as its parent, and app has the root. The name is the tree.
  • An unset level means ask the parent. The root always has one, so the walk ends.
  • Check the level first. A disabled call builds no record and no string.
  • A record goes to the logger's appenders, then up to its parent's, each with its own threshold.
  • Return how many appenders wrote, so tests can see the filters work.

Lazy messages

  • Take the message as a function, called only when the level is on.
  • At 20 debug calls on 1,000 requests a second, 20,000 strings a second are saved.

Watch out

  • An appender with a low threshold cannot bring back a record the logger already dropped.
  • Catch per appender, so one broken disk does not silence the console.
log(level, message) {
  if (!this.isEnabled(level)) return 0;
  const text = typeof message === 'function' ? message() : message;
  const record = { time: this.now(), level, logger: this.name, text };
  let written = 0;
  for (let l = this; l; l = l.parent)
    for (const a of l.appenders) if (a.append(record)) written++;
  return written;
}
Go deeperDesign a Logging Framework (LLD)
System design · Logger design

Async logging needs a bounded queue and a policy

A slow disk must not slow the request, but a queue with no limit just moves the outage into memory.

  • An async appender is a decorator: it queues records and one worker writes them in order.
  • Bound the queue. When it is full you must pick: drop the newest, or block the caller.
  • Drop and count for debug and info noise. Block for audit lines that must never be lost.
  • On shutdown, put a stop marker behind the last record so everything queued is written.

Thread safety

  • Keep the appender list copy-on-write, so a call in flight iterates a snapshot with no lock.
  • Lock each write so two threads never interleave half-lines.

Singleton

  • A global logger cannot be reset between tests. Inject the manager and add a default on top.
Go deeperDesign a Logging Framework (LLD)
System design · ATM

An ATM: a state table plus a backtracking note planner

The session needs a table that rejects out-of-order events, and the cash needs a planner that never strands a remainder.

  • Three states: Idle, CardIn, Authenticated. Each lists the events it accepts; the rest are rejected and change nothing.
  • Failed PINs are counted on the account, so three wrong tries lock it on every machine.
  • Greedy fails: owe 60 from one 50 and three 20s, and it takes the 50 and strands 10.
  • A chain of note handlers tries the most notes, then fewer, so it finds three 20s.
  • Four fixed notes are also fine as a loop with the same backtracking.

Order of a withdrawal

  • Validate the amount, plan the notes, debit in one locked step, then dispense.
  • Planning changes nothing, so most failures happen before any money moves.
  • If the cash unit jams after the debit, credit the amount back.

Two machines, one account

  • Check and subtract the balance inside the account's lock, or both machines can spend the same 50.
plan(amount, stock) {
  const d = this.denom;
  const most = Math.min(Math.floor(amount / d), stock[d] ?? 0);
  for (let n = most; n >= 0; n--) {
    const tail = this.next.plan(amount - n * d, stock);
    if (tail) return n ? { [d]: n, ...tail } : tail;
  }
  return null;
}
Go deeperDesign an ATM (LLD)
System design · ATM

Where an ATM keeps the PIN counter and how it handles a jam

The counter and the debit both cross machine boundaries, so each needs to live next to the account.

  • A counter in the session resets on the next insert. Put it on the account and update it under the account's lock.
  • The third wrong PIN locks the account and the machine keeps the card.
  • Debit and dispense cannot be one transaction. Debit, dispense, and credit back if the cash unit fails.
  • A bank would hold the amount, then commit on success or release on failure.

Power cut mid-withdraw

  • Write the intent before the debit and the result after the dispense.
  • On boot, reconcile any open entry with the bank.
Go deeperDesign an ATM (LLD)
System design · Tic-tac-toe

Check a tic-tac-toe win in O(1) with running totals

Scanning the row, column and diagonals after every move costs O(n). Keep one signed total per line instead.

  • Give X the value +1 and O the value -1.
  • A move touches one row, one column and at most two diagonals. Add its value to those four totals.
  • A line is won exactly when its total is n or -n. A mixed line cancels toward zero.
  • Keep a filled count, so a draw is filled === n * n and needs no scan.
  • Check the win before the draw, so a last move that completes a line wins.

Undo

  • Undo is the same update with the sign flipped, applied to the same four totals.
  • Only place and clear write the grid and the totals, so they cannot drift apart.

Fewer than n in a row

  • The totals stop working, because a run can sit inside a longer line. Count same marks outward from the new cell in four directions: O(k).
wouldComplete(mark, r, c) {
  const d = mark === 'X' ? 1 : -1;
  const totals = [this.rows[r] + d, this.cols[c] + d];
  if (r === c) totals.push(this.diag + d);
  if (r + c === this.n - 1) totals.push(this.anti + d);
  return totals.some(t => Math.abs(t) === this.n);
}
Go deeperDesign Tic-Tac-Toe (LLD)
System design · Tic-tac-toe

Two clients, one tic-tac-toe game: lock plus version

If two requests act on the same board, one can silently overwrite the other. Make the move a single atomic step.

  • Take a lock per game so the turn check and the cell check are one step.
  • Send a version with every move. A move based on an old version is refused as stale.
  • The server owns whose turn it is. Never trust a client to say it.
  • A rejected move changes nothing and does not use up the turn.

Why a version

  • A lock alone orders the requests but cannot tell that the second one was decided on an old board.
  • The stale client refreshes the board and decides again.
Go deeperDesign Tic-Tac-Toe (LLD)
System design · Snake and Ladder

Snake and Ladder: one jump map, no Snake class

A snake and a ladder behave the same way. Only the direction differs, so they do not need two classes.

  • The board is a size and one map: square to square. A higher end is a ladder, a lower end is a snake.
  • Refuse a jump from the last square, or the winner could slide away.
  • Refuse any jump that lands on another jump's start. Chains can loop forever.
  • A ladder onto the last square is a win, so check the win after the jump.
  • Each turn returns a result: player, roll, from, to and an event.

Watch out

  • A class per square, or a builder for two numbers, is over-engineering.
  • If you allow chains, follow them in a loop and remember the squares you visited.
for (const [from, to] of jumps) {
  if (from < 1 || from >= size) throw new RangeError('start');
  if (to < 1 || to > size || to === from) throw new RangeError('end');
  if (jumps.has(to)) throw new RangeError('chain');  // could loop
}
Go deeperDesign Snake and Ladder (LLD)
System design · Snake and Ladder

Dice and the ending rule are the two strategies

Ask what happens past the last square before coding. That rule changes, so it belongs behind an interface.

  • Exact: must land on the last square, else stay. Finish: any roll that reaches it wins. Bounce: extra steps walk back.
  • An ending rule maps position, roll and size to a landing square, or refuses the move.
  • Dice are passed in. Random dice play the game and fixed dice make every test repeatable.
  • A six may give another turn. Say whether a refused move still earns it.

Concurrency

  • One game has one writer at a time: lock the whole turn so a player never moves twice.
  • Different games share nothing and never wait for each other.
Go deeperDesign Snake and Ladder (LLD)
System design · Splitwise

Splitwise: integer cents and a strategy per split type

Shares must add up to the bill, to the cent, and the same input must always give the same split.

  • Keep money in integer cents. A dinner is 1001, not 10.01.
  • Each split type is a strategy: it turns an amount into one share per person, or says the data is invalid.
  • Floor every proportional share, then give the leftover cents one each to the first people, in order.
  • 1001 cents among three is 334, 334, 333, and the shares always sum to the bill.
  • Validate the whole expense first, so a bad one never half-applies.

Four split types

  • Equal, exact (must sum to the amount), percent (whole, summing to 100) and shares (positive weights).
  • Equal, percent and shares share one rounding routine, so the stray cent is handled in one place.

Over-engineering

  • If only equal splits are asked for, a plain function is enough. Add the interface with the second type.
const shares = weights.map(w => Math.floor(amount * w / total));
let left = amount - sum(shares);
for (let i = 0; left > 0; i++, left--) shares[i]++;
Go deeperDesign Splitwise (LLD)
System design · Splitwise

Simplify debts by matching biggest debtor to biggest creditor

Pair debts can form chains. Net balances show who really needs to pay whom.

  • Net each person: what they are owed minus what they owe. The nets always sum to 0.
  • Take the biggest debtor and the biggest creditor, and move the smaller of the two amounts.
  • Drop whoever reaches 0, and repeat. Each payment clears at least one person.
  • So n people need at most n - 1 payments. Break ties by name so the result repeats.
  • The true minimum is NP-hard in general, so say greedy is not always the fewest.

A chain

  • Ann owes Ben 10 and Ben owes Cam 10. Ben's net is 0, so one payment, Ann to Cam, settles both.

Races

  • Settle-up must check and subtract in one locked step, or two taps can both pay the same debt.
Go deeperDesign Splitwise (LLD)
System design · Library system

A library copy is a state machine, and the queue comes first

A title and a physical copy are different things. A copy moves through a few states, and a returned copy must reach the queue before anyone else.

  • Book is the title people search and reserve. BookItem is one physical copy that is loaned.
  • A copy is AVAILABLE, LOANED, HELD for a waiting member, or LOST. Only listed moves are legal.
  • A returned copy goes to the first member in the queue, who is told. It is available only if nobody waits.
  • A hold has a deadline. If it lapses, the copy passes to the next member or becomes available.

Why the queue comes first

  • If a returned copy became available first, a walk-in could take it ahead of someone who waited a week.
  • So a free copy exists only when the queue is empty, and borrow can just take any available one.

Watch out

  • A member at their loan limit cannot collect a held copy. Check the limit first and keep the hold.
  • Send overdue notices once per loan, not once per daily sweep.
Go deeperDesign a Library Management System (LLD)
System design · Library system

Two members, one last copy: make check-and-loan atomic

Checking that a copy is available and then marking it loaned are two steps. Two requests can both pass the check.

  • The bug: both requests see AVAILABLE, both write LOANED, and one copy has two loans.
  • Fix it with one lock around the change, or in SQL a conditional update: set LOANED where the state is still AVAILABLE.
  • Then read how many rows changed. Zero means someone else won, so answer NO_COPY.
  • Check the member limit in the same step, or two quick borrows slip past it.
  • Tell listeners after the lock is released, so a slow email never blocks a borrow.

Scaling it

  • One lock for the whole library is correct. Use a lock per title when contention shows up.
  • A database row is the lock when copies live in a table.
UPDATE book_item
   SET state = 'LOANED', loan_id = $2
 WHERE id = $1 AND state = 'AVAILABLE';
-- 1 row changed: you got it. 0 rows: someone else did.
Go deeperDesign a Library Management System (LLD)

You're all caught up

0 of 189 shorts ticked. Go deeper in a lesson, or go round again.

Lesson