System Design NotesRohit’s interview study guide
Chapter 12

Case Studies: Media, Location and Money

Six full case studies, each in the 45-minute shape: video streaming, file sync, ride-hailing, nearby search, payments and ticket booking, with the numbers that drive every decision calculated in code.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Design a video streaming service

Added

A video service looks like one product but is really two systems with opposite shapes. The upload side runs once per video: a creator sends a large file, and a pipeline turns it into many smaller files. It is write-heavy, CPU-bound and allowed to take minutes. The watch side runs billions of times: players fetch those small files over and over, and they must start in a second or two and never freeze. It is read-heavy, bandwidth-bound and lives almost entirely in a CDN.

The mental model is a factory and a warehouse network. The factory (transcoding) cuts each video into 6-second pieces and packs every piece in several sizes, from a thin 145 kbps version for a weak phone signal up to 1080p at several megabits. The warehouses (CDN edges) keep the popular packages close to viewers. The player is the clever shopper: before each piece it looks at how fast the last pieces arrived and how much video it has saved up, and picks which size to fetch next. That choice is called adaptive bitrate (ABR), and it is the part interviewers most like to probe, because it is where the network, the CDN and the user experience meet.

The numbers that drive the design: at YouTube scale (more than 500 hours uploaded every minute, as YouTube said in 2019, and over a billion hours watched a day, as it said in 2017) the service has to serve about 135 bytes for every byte it stores, and peak egress is in the hundreds of terabits per second. That one ratio explains why the CDN, not the database, is the centre of the design.

The heart of the watch path is the player's bitrate decision. This model uses Apple's example H.264 ladder, 6-second segments and a seeded bandwidth trace that goes from good Wi-Fi into a train tunnel and out onto 4G:

abr.mjs
// Seeded PRNG so the bandwidth trace is the same on every run.
const mulberry32 = (a) => () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const LADDER = [145, 365, 730, 1100, 2000, 3000, 4500, 6000, 7800]; // kbps
const SEG = 6, MAX_BUFFER = 30, SAFETY = 0.8;

// Network (kbps): good Wi-Fi, then a train tunnel, then 4G, with ±20% seeded noise.
const rand = mulberry32(42);
const trace = Array.from({ length: 16 }, (_, i) => Math.round((i < 6 ? 9000 : i < 11 ? 900 : 4000) * (0.8 + 0.4 * rand())));

function pick(estimate, buffer) {
  const budget = estimate * SAFETY * (buffer < 8 ? 0.5 : 1); // low buffer: be twice as careful
  let rung = 0;
  for (let r = 0; r < LADDER.length; r++) if (LADDER[r] <= budget) rung = r;
  return rung;
}

function play(abandon, print) {
  let fast = 0, slow = 0, buffer = 0, stalled = 0;
  const estimate = () => Math.min(fast, slow);      // two moving averages; trust the lower
  trace.forEach((bw, i) => {
    const est = estimate();                                  // what the player believes before this fetch
    let rung = i === 0 ? 0 : pick(est, buffer);              // start on the lowest rung
    let seconds = (LADDER[rung] * SEG) / bw;
    let note = "";
    if (abandon && seconds > buffer && rung > 0) {           // would run dry: give up after 1 s
      const was = LADDER[rung];
      rung = pick(bw, buffer - 1);                           // re-pick with what we just measured
      seconds = 1 + (LADDER[rung] * SEG) / bw;
      note = ` abandoned ${was}`;
    }
    const stall = i === 0 ? 0 : Math.max(0, seconds - buffer); // segment 0 is start-up, not a stall
    stalled += stall;
    buffer = Math.min(MAX_BUFFER, Math.max(0, buffer - seconds) + SEG);
    fast = i === 0 ? bw : 0.5 * fast + 0.5 * bw;            // reacts within a segment or two
    slow = i === 0 ? bw : 0.8 * slow + 0.2 * bw;            // remembers the longer trend
    if (print) console.log(`${String(i).padStart(3)} ${String(bw).padStart(7)} ${String(Math.round(est)).padStart(8)}` +
      ` ${String(LADDER[rung]).padStart(5)} ${seconds.toFixed(1).padStart(6)}s ${buffer.toFixed(1).padStart(5)}s` +
      ` ${stall ? stall.toFixed(1) + "s" : "-"}`.padStart(7) + note);
  });
  return stalled;
}

console.log("seg network estimate  kbps  fetch  buffer  stall");
const withAbandon = play(true, true);
console.log(`stall with abandonment: ${withAbandon.toFixed(1)} s, without: ${play(false, false).toFixed(1)} s`);
Result
seg network estimate  kbps  fetch  buffer  stall
  0    9364        0   145    0.1s   6.0s      -
  1    8814     9364  3000    2.0s  10.0s      -
  2   10269     9089  6000    3.5s  12.5s      -
  3    9611     9457  6000    3.7s  14.7s      -
  4    7829     9488  6000    4.6s  16.1s      -
  5    9096     8737  6000    4.0s  18.2s      -
  6     818     8917   365    3.7s  20.5s      - abandoned 6000
  7     945     4867  3000   19.0s   7.4s      -
  8    1032     2906  1100    6.4s   7.0s      -
  9     890     1969   730    4.9s   8.1s      -
 10     810     1430   145    2.1s  12.0s      - abandoned 1100
 11    4611     1120   730    0.9s  17.1s      -
 12    4393     2865  2000    2.7s  20.4s      -
 13    3691     3629  2000    3.3s  23.1s      -
 14    3516     3660  2000    3.4s  25.7s      -
 15    4001     3588  2000    3.0s  28.7s      -
stall with abandonment: 0.0 s, without: 27.2 s
What’s happening
  1. The ladder is the set of renditions the transcoder produced: 145 to 7800 kbps. A rendition is only useful if the network can deliver it faster than real time: a 6-second segment at 6000 kbps is 36 megabits, so on a 9,000 kbps link it takes about 4 s to fetch, and the player gains 2 s of buffer.
  2. Segment 0 starts on the lowest rung (145) because the player has no measurement yet; it arrives in 0.1s, so start-up is fast. Its measured throughput (9364) becomes both moving averages, and segment 1 jumps to 3000, then 6000 from segment 2. It never takes 7800: the budget is 80% of the estimate (SAFETY), about 7,000 to 7,600 kbps here, so the top rung would leave no margin.
  3. The player keeps two moving averages of measured throughput and trusts the lower one. The fast one reacts within a segment or two; the slow one remembers the trend. Taking the minimum means "go down quickly, come back up slowly", which is what Shaka Player and hls.js do (both document a min-of-two-EWMAs estimator).
  4. At segment 6 the network collapses to 818 kbps. The player had planned 6000, which would take 44 s, far more than the 18.2 s in its buffer. It abandons the download after 1 s and re-picks using what it just measured: 365. The fetch costs 3.7 s in total and the buffer still grows to 20.5s. Without abandonment the same trace stalls for 27.2 s.
  5. Segment 7 shows the cost of a lagging estimate: the fast average still says 4867, so it fetches 3000 at 945 kbps, which takes 19.0s and drains the buffer from 20.5 s to 7.4s. No stall, because the buffer absorbed it, but the buffer is now below 8 s, so the next two picks are twice as cautious (1100, then 730). At segment 10 the buffer is back at 8.1 s, so the player tries 1100 on the full budget, and abandonment turns it into 145.
  6. From segment 11 the 4G link gives about 4,000 kbps, the averages climb back, and the player settles on 2000 with the buffer refilling towards the 30 s cap.
  7. What this demonstrates: quality is a per-segment decision made by the client, from two signals (throughput and buffer). The server just stores every rung. That is why the backend design is about producing the ladder and getting segments close to viewers, not about pushing a stream.
AdvancedVideo service: requirements

Requirements

Functional, agreed with the interviewer:

  • Creators upload videos (up to a few GB, from flaky mobile connections, so uploads must be resumable).
  • The system processes each upload into streamable renditions and thumbnails; the video becomes watchable within minutes.
  • Viewers watch on phones, browsers and TVs, with quality adapting to their network; they can seek.
  • Viewers see title, creator and view count. Search, recommendations, comments and live streaming are out of scope (say so; live changes the segment and latency story, see the gotcha below).

Non-functional:

  • Playback start under about 2 seconds and rare rebuffering: the metrics viewers feel.
  • Watch path highly available (99.99% or better); the upload and processing path can be less available and slower.
  • Originals are never lost: a creator's upload is the only copy. Store it durably (object storage is designed for 11 nines of durability; AWS states 99.999999999% for S3) before acknowledging.
  • Cost: egress and storage dominate, so every decision is checked against bytes served and bytes stored.
  • Scale: 500 hours uploaded per minute and 1 billion hours watched per day, the YouTube figures above.
AdvancedVideo service: estimates

Estimates

video-estimates.mjs
// Assumptions (YouTube-scale, from the figures quoted above; change them and re-run).
const uploadHoursPerMin = 500;          // hours of new video per minute
const watchHoursPerDay = 1e9;           // hours watched per day
const ladderKbps = [145, 365, 730, 1100, 2000, 3000, 4500, 6000, 7800]; // Apple's H.264 example ladder
const avgWatchKbps = 2500;              // average rung actually streamed
const segmentSec = 6;                   // HLS target duration
const peakFactor = 2;                   // evening peak vs daily average
const sourceMbps = 10;                  // what creators upload (1080p, high quality)

const GB = 1e9, PB = 1e15;
const uploadHoursPerDay = uploadHoursPerMin * 60 * 24;
const ladderBytesPerHour = ladderKbps.reduce((a, b) => a + b, 0) * 1000 / 8 * 3600;
const storedPerDay = uploadHoursPerDay * ladderBytesPerHour;
const segmentsPerDay = uploadHoursPerDay * (3600 / segmentSec) * ladderKbps.length;
const egressPerDay = watchHoursPerDay * 3600 * avgWatchKbps * 1000 / 8;
const avgTbps = (egressPerDay * 8) / 86400 / 1e12;

console.log(`uploaded:        ${uploadHoursPerDay.toLocaleString("en")} hours/day`);
console.log(`ingest:          ${((uploadHoursPerDay * 3600 * sourceMbps) / 86400 / 1000).toFixed(0)} Gbps of source video, ${((uploadHoursPerDay * 3600 * sourceMbps * 1e6) / 8 / PB).toFixed(1)} PB/day of originals`);
console.log(`ladder:          ${(ladderBytesPerHour / GB).toFixed(1)} GB per hour of video (all ${ladderKbps.length} rungs)`);
console.log(`new renditions:  ${(storedPerDay / PB).toFixed(1)} PB/day, ${(storedPerDay * 365 / 1e18).toFixed(1)} EB/year`);
console.log(`new segments:    ${(segmentsPerDay / 1e9).toFixed(1)} billion files/day`);
console.log(`egress:          ${(egressPerDay / 1e18).toFixed(2)} EB/day`);
console.log(`bandwidth:       ${avgTbps.toFixed(0)} Tbps average, ${(avgTbps * peakFactor).toFixed(0)} Tbps peak`);
console.log(`read:write:      ${(egressPerDay / storedPerDay).toFixed(0)} bytes served per byte stored`);
Result
uploaded:        720,000 hours/day
ingest:          300 Gbps of source video, 3.2 PB/day of originals
ladder:          11.5 GB per hour of video (all 9 rungs)
new renditions:  8.3 PB/day, 3.0 EB/year
new segments:    3.9 billion files/day
egress:          1.13 EB/day
bandwidth:       104 Tbps average, 208 Tbps peak
read:write:      135 bytes served per byte stored
What’s happening
  1. 500 hours a minute is 720,000 hours of new video a day. Creators upload at around 10 Mbps (assumed), so ingest is about 300 Gbps and 3.2 PB/day of originals. That is large but steady, and it goes straight to object storage, never through app servers.
  2. Summing the ladder gives 25,640 kbps, which is 11.5 GB for one hour of video across all 9 rungs. Every uploaded hour therefore costs about 2.6 times its original's size again in renditions (11.5 GB against 4.5 GB for a 10 Mbps original): 8.3 PB/day, 3.0 EB/year. Storage is the second biggest cost, and it is why rarely watched videos lose their top rungs or move to colder storage classes.
  3. At 6-second segments, one hour is 600 segments per rung, so the day's uploads create 3.9 billion segment files. That number decides how segments are stored (see the data model): either an object per segment, which object stores can hold but makes listing and lifecycle rules heavy, or one file per rendition read with byte-range requests.
  4. Viewing: a billion hours at an average of 2.5 Mbps is 1.13 EB/day, 104 Tbps on average and about 208 Tbps at the evening peak (peak factor 2, assumed). No origin can serve that; even a single big CDN region cannot. The design must push bytes to edges and ideally into ISPs.
  5. Bytes served per byte stored: 135. A read-heavy system usually means "add caches"; at this ratio the cache (CDN) is the system.
AdvancedVideo service: API sketch

API

API sketch
POST /v1/uploads                      { title, sizeBytes, contentType }
  -> 201 { uploadId, partSize: 8388608, partUrls: [ ...presigned PUT URLs... ] }
PUT  <presigned part URL>             raw bytes of part n, straight to object storage; retry a part, not the file
POST /v1/uploads/{uploadId}/complete  { parts: [ { n, etag } ] }
  -> 202 { videoId, status: "processing" }
GET  /v1/videos/{videoId}
  -> 200 { title, status, durationSec, manifestUrl, thumbnails }
GET  https://cdn.example.com/v/{videoId}/master.m3u8     served by the CDN, not by the API
POST /v1/playback-events              batched: { videoId, startupMs, stalls, rendition, watchedSec }

The upload is a multipart, presigned upload: the API only hands out URLs and records progress; the bytes go from the creator's device to object storage, part by part, so a dropped connection costs one part, not the whole file. This is the pattern from object storage for files and media. The 202 on complete says "accepted, not done": processing is asynchronous and the client polls GET /v1/videos/{id} or receives a notification. Playback telemetry (start-up time, stalls) is how you will know whether the ABR and CDN choices work, so it belongs in the API from the start.

AdvancedVideo service: data model

Data model

StoreWhatKey and notes
Metadata DB (relational or wide-column)videos(id, owner_id, title, status, duration_sec, created_at)id; status is uploading → processing → ready | failed
Metadata DBrenditions(video_id, codec, height, kbps, playlist_key, bytes)(video_id, codec, height); one row per rung
Object storageoriginals/{videoId}/sourcewritten once, kept forever, cold storage class after processing
Object storagev/{videoId}/{codec}_{height}p/ with segments and a media playlist, plus v/{videoId}/master.m3u8immutable files, so they cache forever at the CDN
Counter storeviews per videosharded counters, aggregated asynchronously (as in comments and likes)

Two decisions worth saying aloud. Segment files are immutable: a re-encode writes new paths and a new manifest, so the CDN never needs purging. And the 3.9 billion new segments a day can be one object each, or each rendition can be a single fragmented MP4 file that the playlist addresses with byte ranges (HLS supports this with EXT-X-BYTERANGE), which cuts the object count by 600 times per hour of video at the cost of range-request support at every cache.

AdvancedVideo service: architecture diagram

High-level design

upload path: once per video Creator app or browser Upload service presigned URLs Object storage originals Job queue one job per video Transcoders split · encode · pack Viewer ABR player Video API metadata · auth Metadata DB videos · renditions Object storage renditions watch path: billions a day CDN edge near the viewer Origin shield one fetch per region parts go straight to storage status: ready 6 s segments cache miss
Two paths that share only object storage and the metadata DB: a slow, asynchronous factory on top and a cache-first read path below.

Walk the interviewer through it in two passes. Upload: the client creates an upload, sends parts straight to object storage with presigned URLs, and calls complete; the object-created event puts a job on a queue (why queues: the transcoders run at their own pace and a burst of uploads just makes the queue longer). Transcoders split, encode and package, write the renditions, and flip the video to ready. Watch: the player asks the video API for metadata and the manifest URL, then every byte of video comes from the CDN. On a miss the edge asks an origin shield (one mid-tier cache per region, so a new video causes one origin read per region rather than one per edge, see CDNs), and only the shield reads object storage.

AdvancedDeep dive: the transcoding pipeline

A 10-minute upload must become 9 renditions of 100 segments each. Doing it as one job on one machine is slow; the trick is to split first, then encode the pieces in parallel:

  1. Split the original into chunks of one segment each. The upload's own keyframes can be anywhere, so the splitter cuts at the source's keyframes nearest each boundary (or decodes across the boundary), and every encoded chunk is made to start with a fresh keyframe (IDR). Apple's HLS authoring specification asks for an IDR every 2 seconds and 6-second segments, so each 6-second output chunk starts on a keyframe and was encoded independently of its neighbours.
  2. Encode every chunk at every rung as a separate task: (chunk, rung) pairs are independent, so they fan out across a worker pool.
  3. Package the encoded chunks into segments and write the media playlists and the master playlist.
  4. Publish: write the renditions rows and set status = ready in one transaction; the CDN needs no warming, because the first viewer's miss fills it.

How much parallelism buys you, with encoding assumed to run at 2× real time per rung per vCPU (a rule of thumb that varies a lot with codec and preset):

transcode.mjs
// Assumptions: encoding one rung runs at 2× real time on one vCPU (a rule of thumb; it depends heavily on codec
// and preset), and a chunk is one 6-second segment.
const rungs = 9, encodeSpeed = 2, chunkSec = 6;
const uploadHoursPerDay = 720_000;

function wallClock(videoMin, workers) {
  const tasks = Math.ceil((videoMin * 60) / chunkSec) * rungs;  // one task = one chunk at one rung
  const taskSec = chunkSec / encodeSpeed;
  const waves = Math.ceil(tasks / workers);                     // tasks run in parallel waves
  return { tasks, seconds: waves * taskSec };
}

for (const [min, workers] of [[10, 1], [10, 100], [10, 900], [120, 900]]) {
  const r = wallClock(min, workers);
  const t = r.seconds >= 60 ? `${(r.seconds / 60).toFixed(1)} min` : `${r.seconds} s`;
  console.log(`${String(min).padStart(3)}-min video, ${String(workers).padStart(3)} ${workers === 1 ? "worker: " : "workers:"} ${String(r.tasks).padStart(5)} tasks, ${t.padStart(8)} of encoding`);
}
const cpuHoursPerDay = (uploadHoursPerDay * rungs) / encodeSpeed;
console.log(`fleet: ${(cpuHoursPerDay / 1e6).toFixed(2)} M vCPU-hours/day = ${Math.round(cpuHoursPerDay / 24).toLocaleString("en")} vCPUs busy around the clock`);
Result
 10-min video,   1 worker:    900 tasks, 45.0 min of encoding
 10-min video, 100 workers:   900 tasks,     27 s of encoding
 10-min video, 900 workers:   900 tasks,      3 s of encoding
120-min video, 900 workers: 10800 tasks,     36 s of encoding
fleet: 3.24 M vCPU-hours/day = 135,000 vCPUs busy around the clock
What’s happening
  1. A 10-minute video is 100 six-second chunks, times 9 rungs: 900 tasks of 3 seconds of encoding each.
  2. On 1 worker that's 45.0 min; on 100 workers it is 9 waves of tasks, 27 s; on 900 workers every task runs at once and the encoding takes 3 s plus scheduling and I/O overhead. Splitting turns latency from "proportional to the video's length" into "proportional to one chunk".
  3. A two-hour film with the same 900 workers needs 12 waves, 36 s. Long videos stop being a special case.
  4. The fleet-wide number is the cost: 720,000 hours a day × 9 rungs ÷ 2 = 3.24 M vCPU-hours/day, about 135,000 vCPUs busy around the clock. That is why real services encode the top rungs lazily (only once a video gets views), use hardware encoders, and choose ladders per title instead of encoding every rung for everything.

Make every task idempotent: its output path is derived from (videoId, rung, chunk), so a retried task overwrites the same object with the same bytes. The job queue delivers at least once, a worker can die halfway, and the pipeline still converges (the idea from idempotency and exactly-once). Track the DAG's progress in a table keyed by video, so a coordinator can see which tasks are missing and re-enqueue only those. Put uploads from big creators on a priority queue if the business wants their videos live first.

AdvancedDeep dive: manifests, segments and the ABR ladder

The player never sees "a video"; it sees playlists. The master playlist lists the rungs, and each rung's media playlist lists its segments:

master.m3u8 (HLS)
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=7200000,AVERAGE-BANDWIDTH=6000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
avc_1080p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3600000,AVERAGE-BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
avc_720p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=180000,AVERAGE-BANDWIDTH=145000,RESOLUTION=416x234,CODECS="avc1.42c00d,mp4a.40.2"
avc_234p/playlist.m3u8

BANDWIDTH is the peak bitrate of the rung and AVERAGE-BANDWIDTH its average; the player compares its throughput estimate with these numbers. The playlist shows three of the nine rungs, and its peak values are illustrative: Apple's spec asks for the measured peak and average to be within 10% of these attributes, and for on-demand video a peak of no more than 200% of the average. The ladder used on this page is Apple's example H.264 ladder from the HLS authoring specification (checked October 2026): 416×234 at 145 kbps, 640×360 at 365, 768×432 at 730 and 1100, 960×540 at 2000, 1280×720 at 3000 and 4500, 1920×1080 at 6000 and 7800. The same document gives an HEVC ladder that reaches 1080p at 4,500–5,800 kbps, roughly a quarter less than H.264 for the same picture, and 4K at 11,600–16,800 kbps. Apple presents it as "one possible set" of variants, not a requirement.

Segment length is a trade-off you should be able to defend. HLS's authoring spec asks for 6-second segments; DASH deployments commonly use 2 to 6 seconds (Bitmovin's 2020 guide gives 2–6 s as the typical range for on-demand video). Shorter segments let the player react sooner and cut live latency, but they mean more requests per minute, more playlist entries and more keyframes (keyframes are expensive, so compression gets worse). Longer segments compress better and cost fewer requests, but the player reacts later, and a bad pick costs more buffer. For video on demand, 6 s is a good default; for live, segments get shorter and are split into smaller chunks (low-latency HLS and CMAF chunked transfer).

The ABR algorithm is a client decision, and there are two families. Throughput-based players, like the model above, estimate bandwidth from recent downloads and pick the highest rung below a safety fraction of it. Buffer-based players (BOLA is the best-known; dash.js ships it) pick from the buffer level alone: low buffer means low rung, full buffer means high rung, which avoids trusting noisy throughput samples. Production players mix both, start on a low or middle rung for fast start-up, and abandon a download that would empty the buffer, as segment 6 showed.

AdvancedDeep dive: the CDN and the long tail

At 208 Tbps the question isn't "should we use a CDN" but "how much of the catalogue must sit at the edge to keep origin egress affordable". Video popularity is heavily skewed: a few videos get most views, and millions get a handful. This model assumes a Zipf distribution with skew 0.9 over 10 million videos and asks what share of views the edge can serve if it holds the top K:

cdn-hit.mjs
// Popularity of N videos follows a Zipf law: the k-th most popular gets views ∝ 1 / k^s.
const N = 10_000_000;   // videos in the catalogue
const s = 0.9;          // skew: an assumption; measure your own catalogue
const egressTbps = 208; // peak from the estimate above
const gbPerVideo = (11.5 * 8) / 60; // 8-minute average video, all rungs (11.5 GB per hour)

// Prefix sums of the weights, so "share of views in the top K" is one division.
const prefix = new Float64Array(N + 1);
for (let k = 1; k <= N; k++) prefix[k] = prefix[k - 1] + 1 / k ** s;
const share = (K) => prefix[K] / prefix[N];

console.log("edge holds top K   storage   hit ratio   origin egress at peak");
for (const K of [1_000, 10_000, 100_000, 1_000_000, 5_000_000]) {
  const hit = share(K);
  const pb = (K * gbPerVideo) / 1e6;
  console.log(`${K.toLocaleString("en").padStart(16)}   ${(pb < 1 ? (pb * 1000).toFixed(0) + " TB" : pb.toFixed(1) + " PB").padStart(7)}` +
    `   ${(hit * 100).toFixed(1).padStart(8)}%   ${(egressTbps * (1 - hit)).toFixed(0).padStart(5)} Tbps`);
}
Result
edge holds top K   storage   hit ratio   origin egress at peak
           1,000      2 TB       25.9%     154 Tbps
          10,000     15 TB       38.6%     128 Tbps
         100,000    153 TB       54.5%      95 Tbps
       1,000,000    1.5 PB       74.7%      53 Tbps
       5,000,000    7.7 PB       91.8%      17 Tbps
What’s happening
  1. Under a Zipf law the k-th most popular video gets views in proportion to 1 / k^0.9. The prefix sums turn "share of views going to the top K videos" into one division, prefix[K] / prefix[N].
  2. Holding the top 1,000 videos (2 TB, fits on a single server) already serves 25.9% of views. That is the head of the distribution, and why a small edge in every city helps immediately.
  3. The tail is long: to reach 74.7% you need the top million videos, 1.5 PB, and the origin still has to send 53 Tbps at peak. Going from a quarter of views to half took a hundred times the storage (1,000 to 100,000 videos).
  4. At five million videos (7.7 PB) the hit ratio is 91.8% and origin egress falls to 17 Tbps. Each point of hit ratio at the top end is worth about 2 Tbps of origin traffic, which is why video companies place their own caches inside ISPs.
  5. The skew s = 0.9 is an assumption; with a more skewed catalogue the curve rises faster. The method is the point: measure your popularity curve, then size edge storage against the origin bandwidth you can afford.

Netflix's Open Connect is the extreme version. Netflix's page on how it works with ISPs says Open Connect delivers 100% of its video traffic, close to 90% of it over direct connections with the residential ISPs its members use, and Netflix gives qualifying ISPs its cache appliances for free to put inside their networks. Those figures are old: the page is undated, but it gives viewing as "over 125 million hours" a day, the figure Digital TV Europe reported for Open Connect in March 2016, and a June 2018 APNIC blog post on Open Connect already put the direct share at close to 95%. Treat them as roughly ten-year-old figures that show the approach, not as current numbers. For an interview, the layered answer is enough: edge caches in many cities, an origin shield per region, object storage as the origin, a cache key that ignores per-user query parameters, and long TTLs because segments are immutable. For paid or private content, the player gets short-lived signed URLs or cookies that the edge validates, so access control doesn't break caching.

Pre-positioning helps the head: when a new episode of a popular show is due, push it to edges overnight ("fill") instead of letting the first viewers' misses pull it at peak. Rarely watched long-tail videos can stay at the origin and in regional shields only.

AdvancedVideo service: trade-offs and what to say

Trade-offs and what to say

DecisionChoiceWhy, and what it costs
Who streams the bytesCDN, never app servers208 Tbps peak; app servers would cost more and add a hop
UploadMultipart presigned upload to object storageResumable per part; API servers don't carry 300 Gbps
ProcessingQueue + split into chunks + parallel encodeMinutes, not hours; idempotent tasks survive retries; costs a large CPU fleet
Segment length6 s for on-demandGood compression and few requests; adapts more slowly than 2 s
LadderPer title, top rungs on demandSaves storage and CPU on the long tail; first viewers of a newly popular video may get lower quality for a while
Segment storageImmutable files (one object each, or byte ranges of one file per rung)Infinite TTLs, no purges; billions of objects a day, or caches must support range requests
Hit ratio and origin egress

Peak egress is 208 Tbps and the CDN hit ratio drops from 95% to 90% after a cache layer is resized. What happens to the traffic the origin has to serve?

Show answer

It doubles, from about 10.4 Tbps to 20.8 Tbps.

What’s happening
  1. The origin serves only the misses: egress × (1 − hit ratio).
  2. At 95%: 208 × 0.05 = 10.4 Tbps. At 90%: 208 × 0.10 = 20.8 Tbps.
  3. The hit ratio fell by 5 points (about 5%), but the miss ratio went from 5% to 10%, a 100% increase. Origin and shield capacity are sized on the miss ratio, which is why small hit-ratio changes at the top end matter so much.

#Design file storage and sync

Added

A Dropbox-style service keeps a folder identical on every device a person owns, and lets them share parts of it. The naive design, "upload the file when it changes, download it everywhere else", breaks on the first realistic case: a 2 GB video project where the user changed one frame, on a phone with a weak connection, while the same file was edited offline on a laptop.

The mental model is a file is a list of block hashes. The client cuts each file into blocks and names every block by the SHA-256 of its bytes. The server is two things: a content-addressed warehouse of blocks (if two blocks have the same hash, they are the same block, stored once) and a journal that records, for every path, which list of hashes it points to at each revision. Syncing is then cheap list arithmetic: send only the blocks the server doesn't have, then move the path's pointer to the new list with a compare-and-set on the revision. Other devices read the journal from where they left off and fetch only the blocks they're missing.

What makes this work or not is where the block boundaries fall. Dropbox's API documents a content hash computed over 4 MiB blocks (4,194,304 bytes) with SHA-256, which tells you their block size. Fixed blocks are simple, but an insertion near the start of a file shifts every later byte and changes every later block. Content-defined chunking chooses boundaries from the bytes themselves, so an insertion only disturbs the chunk it lands in. This model measures both on a 16 MiB file (seeded random bytes), after overwriting 100 bytes in place and after inserting 100 bytes:

chunking.mjs
import { createHash } from "node:crypto";
const mulberry32 = (a) => () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const rand = mulberry32(7);
const GEAR = Uint32Array.from({ length: 256 }, () => Math.floor(rand() * 2 ** 32)); // random value per byte

function fixed(buf, size) {
  const out = [];
  for (let i = 0; i < buf.length; i += size) out.push(buf.subarray(i, i + size));
  return out;
}

// Content-defined: cut where a rolling hash of the last 32 bytes has its top 13 bits zero (≈ 1 in 8192).
function cdc(buf, min = 2048, max = 65536) {
  const out = [];
  let start = 0, h = 0;
  for (let i = 0; i < buf.length; i++) {
    h = ((h << 1) + GEAR[buf[i]]) >>> 0;
    const len = i - start + 1;
    if ((len >= min && h >>> 19 === 0) || len >= max) { out.push(buf.subarray(start, i + 1)); start = i + 1; h = 0; }
  }
  if (start < buf.length) out.push(buf.subarray(start));
  return out;
}

const sha = (b) => createHash("sha256").update(b).digest("hex");
function uploadAfterEdit(chunker, before, after) {
  const known = new Set(chunker(before).map(sha));        // the server already has these
  const chunks = chunker(after);
  const fresh = chunks.filter((c) => !known.has(sha(c)));  // only these are sent
  return { chunks: chunks.length, sent: fresh.length, bytes: fresh.reduce((n, c) => n + c.length, 0) };
}

const file = Buffer.from(Uint8Array.from({ length: 16 * 2 ** 20 }, () => Math.floor(rand() * 256))); // 16 MiB
const patch = Buffer.alloc(100, 0x41);
const overwrite = Buffer.concat([file.subarray(0, 5_000_000), patch, file.subarray(5_000_100)]);
const insert = Buffer.concat([file.subarray(0, 5_000_000), patch, file.subarray(5_000_000)]);

const schemes = [["fixed 4 MiB", (b) => fixed(b, 4 * 2 ** 20)], ["fixed 8 KiB", (b) => fixed(b, 8192)], ["content-defined", cdc]];
console.log("scheme            edit         chunks  sent  bytes uploaded");
for (const [name, fn] of schemes) {
  for (const [edit, after] of [["overwrite", overwrite], ["insert", insert]]) {
    const r = uploadAfterEdit(fn, file, after);
    console.log(`${name.padEnd(17)} ${edit.padEnd(11)} ${String(r.chunks).padStart(7)} ${String(r.sent).padStart(5)}` +
      `  ${r.bytes.toLocaleString("en").padStart(14)}`);
  }
}
Result
scheme            edit         chunks  sent  bytes uploaded
fixed 4 MiB       overwrite         4     1       4,194,304
fixed 4 MiB       insert            5     4      12,583,012
fixed 8 KiB       overwrite      2048     1           8,192
fixed 8 KiB       insert         2049  1439      11,780,196
content-defined   overwrite      1606     1           5,376
content-defined   insert         1606     1           5,476
What’s happening
  1. fixed(buf, size) cuts at every size bytes. cdc(buf) runs a rolling hash over the bytes (each step shifts the hash left and adds a random value for the current byte, so after 32 bytes the oldest byte has shifted out) and cuts where the top 13 bits of the hash are zero, which happens about once every 8,192 bytes. min and max keep chunks between 2 KiB and 64 KiB. Here that gives 1606 chunks, about 10 KiB each on average.
  2. uploadAfterEdit plays the sync protocol: the server already knows the hashes of the old version's chunks, the client chunks the new version, and only chunks with unknown hashes are sent. That's deduplication against the previous version, for free.
  3. Overwriting 100 bytes in place changes one block in every scheme. With 4 MiB blocks that one block is 4,194,304 bytes uploaded for a 100-byte change; with 8 KiB blocks it is 8,192; with content-defined chunks it is the one 5,376-byte chunk the edit landed in. Small blocks waste less per change.
  4. Inserting 100 bytes is where fixed blocks fail. Every byte after offset 5,000,000 moves 100 places, so every later fixed block has new contents: 4 of 5 big blocks (12,583,012 bytes) and 1439 of 2049 small blocks (11,780,196 bytes) go up again. The content-defined boundaries move with the content, so the chunk that contains the insertion changes and everything after it re-synchronises at the next boundary: 1 chunk, 5,476 bytes.
  5. The trade-off is in the chunk count: 1,606 chunks for 16 MiB versus 4 big blocks, so 400 times more hashes to store, look up and send in the file's block list. Small chunks save bandwidth on edits and cost metadata. That's the decision this topic keeps returning to.
AdvancedFile sync: requirements

Requirements

Functional:

  • Upload, download, rename, move and delete files and folders, of any type, up to tens of GB.
  • Sync automatically across a user's devices; a change shows up on the other devices within seconds when they're online.
  • Work offline and sync when back online.
  • Share folders with other users (they become part of a shared namespace).
  • Keep version history (for example 30 days) and restore deleted files.

Non-functional:

  • Durability above everything: never lose a file or silently overwrite someone's edit. Conflicts must keep both versions.
  • Bandwidth efficiency: small edits to large files must not re-upload the file (mobile data, slow uplinks).
  • Sync latency of seconds for small changes; large uploads can take as long as the network needs, and are resumable.
  • Consistency: one path has one current revision per namespace, and every device ends up at the same state (eventually, in seconds). Strong consistency for the commit of a revision; eventual for propagation.
  • Scale: 500 M accounts, 100 M daily active users.
AdvancedFile sync: estimates

Estimates

sync-estimates.mjs
// Assumptions for a Dropbox-like service (state them; the interviewer can change them).
const users = 500e6, dauShare = 0.2;     // 500 M accounts, 20% active on a given day
const storedPerUserGB = 15;              // average used quota
const editsPerDau = 20;                  // files created or changed per active user per day
const avgFileKB = 300;                   // average changed file
const blockMiB = 4;                      // Dropbox's content-hash block size
const devicesPerUser = 2.5;              // each change must reach the user's other devices
const dedupSaving = 0.3;                 // assumed share of bytes already stored (identical blocks)
const peak = 3;

const dau = users * dauShare;
const writesPerSec = (dau * editsPerDau) / 86400;
const uploadGbps = (writesPerSec * avgFileKB * 1e3 * 8) / 1e9;
const logicalEB = (users * storedPerUserGB * 1e9) / 1e18;
const blocks = (users * storedPerUserGB * 1e9) / (blockMiB * 2 ** 20);
const notifyPerSec = writesPerSec * (devicesPerUser - 1);

console.log(`DAU:                  ${(dau / 1e6).toFixed(0)} M`);
console.log(`file commits:         ${Math.round(writesPerSec).toLocaleString("en")}/s average, ${Math.round(writesPerSec * peak).toLocaleString("en")}/s peak`);
console.log(`upload bandwidth:     ${uploadGbps.toFixed(0)} Gbps average (before dedup)`);
console.log(`stored (logical):     ${logicalEB.toFixed(1)} EB, ${(logicalEB * (1 - dedupSaving)).toFixed(2)} EB after ${dedupSaving * 100}% dedup`);
console.log(`block index:          at least ${(blocks / 1e12).toFixed(1)} trillion entries, ${((blocks * 64) / 1e12).toFixed(0)} TB at 64 B each`);
console.log(`change notifications: ${Math.round(notifyPerSec).toLocaleString("en")}/s to other devices`);
Result
DAU:                  100 M
file commits:         23,148/s average, 69,444/s peak
upload bandwidth:     56 Gbps average (before dedup)
stored (logical):     7.5 EB, 5.25 EB after 30% dedup
block index:          at least 1.8 trillion entries, 114 TB at 64 B each
change notifications: 34,722/s to other devices
What’s happening
  1. 20% of 500 M accounts active on a given day is 100 M DAU. Twenty changed files each is 23,148 commits a second on average and about 69,444/s at peak (factor 3). Every commit is a metadata write that must be strongly consistent per path, so the metadata store is the hot, hard part, not the bytes.
  2. At 300 KB per changed file, raw upload is about 56 Gbps. That is before chunk-level dedup, which (as the model showed) turns most edits into a few KB.
  3. Storage: 15 GB per account is 7.5 EB logical. Dedup across identical blocks (assumed 30% here; the real figure depends on the data and on how widely you dedup, see the privacy note below) brings it to 5.25 EB. Either way it lives in object storage, with metadata elsewhere.
  4. The block index maps hash → where the block is stored. Even if every byte were in full 4 MiB blocks there would be at least 1.8 trillion entries, 114 TB at 64 bytes each. Small files produce one short block each, so the real count is higher. With 10 KiB chunks it would be roughly 400 times more, which is why services with fixed 4 MiB blocks accept the bandwidth cost: the index has to stay manageable.
  5. Every commit must reach the user's other devices: about 34,722 notifications a second (2.5 devices per user, assumed). Notifications are tiny ("namespace 42 changed"), and the device then pulls the details.
AdvancedFile sync: API sketch

API

API sketch
POST /v1/blocks/check            { hashes: ["9f86…", "2c26…"] }      -> { missing: ["2c26…"] }
PUT  /v1/blocks/{sha256}         raw bytes (≤ 4 MiB); server recomputes the hash and rejects a mismatch
GET  /v1/blocks/{sha256}         raw bytes
POST /v1/commit                  { namespace, path, baseRev, blocks: [...], size, mtime }
  -> 200 { rev }                 when baseRev is still the current revision
  -> 409 { currentRev }          someone committed first: the client keeps both versions
GET  /v1/changes?namespace=&cursor=     -> { entries: [...], cursor, hasMore }
GET  /v1/changes/wait?cursor=&timeout=60 -> { changed: true | false }      long poll

The order of calls is the protocol. A client uploads blocks first and commits metadata second, so a committed revision can never point at a block the server doesn't have; a crash between the two leaves only unreferenced blocks, which garbage collection removes later. PUT /v1/blocks/{sha256} is idempotent by construction: the URL is the content's hash, so a retried upload writes the same bytes to the same key. The commit carries baseRev, the revision the client's edit started from, which turns it into a compare-and-set.

AdvancedFile sync: data model

Data model

TableColumnsNotes
namespacesid, owner_id, kind (personal | shared)the unit of sync and of sharding
journalnamespace_id, seq, path, rev, blocks, size, deleted, device_id, tsappend-only, seq increases per namespace; this is the history and the change feed
files_latestnamespace_id, path, rev, blocks, sizecurrent state, updated in the same transaction as the journal row
blockshash, size, storage_key, ref_counthash is the key; object storage holds the bytes under blocks/{hash}
devicesdevice_id, user_id, namespace_id, cursorhow far each device has read the journal

Shard the metadata by namespace: every commit, conflict check and journal read touches one namespace, so it is one shard and one transaction. A huge shared namespace (a company-wide folder) can be hot; that is the resharding problem from partitioning and sharding, solved by giving big namespaces their own shards. Blocks are shared across namespaces only if you dedup globally.

AdvancedFile sync: architecture diagram

High-level design

Laptop client watcher · chunker Block service check · put · get Object storage blocks/{sha256} Metadata service commit with baseRev Metadata DB journal · by namespace Change log namespace changed Phone client cursor = seq 41 Notifications long poll per device 1 blocks 2 commit 3 "namespace 42 changed" 4 changes since 41 5 phone fetches missing blocks
Bytes and metadata take separate paths: blocks go to object storage by hash; revisions go through one strongly consistent journal per namespace.

The client is a big part of this design and worth naming: a file watcher notices changes, a chunker cuts and hashes, a local database remembers the last synced revision and block list of every path, and an upload queue retries blocks independently. On the server the block service is stateless (it streams bytes to and from object storage), and the metadata service is the one stateful, carefully designed component. Notifications only say "something changed"; the device pulls the details with its cursor, so a missed notification costs latency, never correctness.

AdvancedDeep dive: blocks, hashes and deduplication

Naming blocks by their hash gives three things at once. Integrity: the server recomputes the SHA-256 of every uploaded block and rejects one that doesn't match its URL. Idempotency: uploading the same block twice is harmless. Deduplication: identical blocks are stored once, whether they come from the previous version of the same file, a copy in another folder, or (if you allow it) another user.

Choosing the chunking scheme is choosing where to pay:

SchemeRe-upload after an insertIndex sizeClient CPUUsed by
Fixed 4 MiB blockseverything after the insertsmalllowestDropbox's documented content hash uses 4 MiB SHA-256 blocks
Fixed small blocks (8 KiB)everything after the insert~500× largerlowrarely: worst of both
Content-defined (~8 KiB average)about one chunklargerolling hash over every byteLBFS (2001) with Rabin fingerprints; backup tools; FastCDC (USENIX ATC 2016) made it fast

A practical design mixes them: content-defined chunking with a large average (around 1 MiB) for big files, so inserts stay cheap and the block count stays manageable, and whole-file blocks for small files, which are most files by count and are usually rewritten entirely anyway. Files that are already compressed or encrypted (videos, zip archives) dedup poorly whatever you do.

Deleting is the hard part of dedup. A block may be referenced by many file revisions in many namespaces, so you can't delete it when one file goes away. Keep a reference count per block, or periodically mark every block still referenced by a live revision and sweep the rest after a grace period (safer: a reference-count bug becomes data loss). Version history (30 days) means old revisions keep their blocks alive until they expire.

AdvancedDeep dive: the sync protocol

Every namespace has one journal with a sequence number that only goes up. A device's state is one number per namespace: the cursor, the last seq it applied. Syncing a device is "give me everything after my cursor", which makes the protocol simple and restartable: a device that was offline for a week sends cursor 41 and gets the entries from 42 onwards, in order, page by page.

Pushing the change itself to every device would be fragile (devices are offline, connections drop), so the push is only a hint. Each device holds a long poll (GET /v1/changes/wait) or a WebSocket to the notification service, which subscribes to the change log for that device's namespaces. When a commit lands, the hint goes out, the device calls GET /v1/changes with its cursor, applies the entries to its local database, fetches missing blocks from the block service, writes the file, and advances its cursor. At 34,722 hints a second, the notification tier is a fan-out problem like the ones in pub/sub and fan-out, but each message is a few bytes.

Applying entries in seq order matters: a rename followed by an edit applied in the other order would edit a path that doesn't exist yet. The journal gives the order for free.

AdvancedDeep dive: conflicts

Two devices edit the same file from the same revision while one of them is offline. When both commit, one has to lose the race. The question is what "lose" means. This model plays the scenario with the metadata service's compare-and-set:

sync.mjs
// Metadata service: the latest revision of each path, plus an append-only journal of every change.
const files = new Map();   // path -> { rev, blocks, by }
const journal = [];        // { seq, path, rev, blocks }

// A conflicted copy gets a name no other file has: the device and the date, then a counter if even that is taken.
function conflictName(path, device, day) {
  const [, base, ext] = path.match(/^(.*?)(\.\w+)?$/);
  const tag = `${device}'s conflicted copy ${day}`;
  let name = `${base} (${tag})${ext ?? ""}`;
  for (let n = 2; files.has(name); n++) name = `${base} (${tag} ${n})${ext ?? ""}`;
  return name;
}

function commit(device, path, baseRev, blocks, day) {
  const cur = files.get(path);
  if ((cur?.rev ?? 0) !== baseRev) {                       // someone committed since this device last synced
    return commit(device, conflictName(path, device, day), 0, blocks, day); // a new file: never drop a user's edit
  }
  const rev = baseRev + 1;
  files.set(path, { rev, blocks, by: device });
  journal.push({ seq: journal.length + 1, path, rev, blocks });
  return { path, rev };
}

// A device asks "what changed since my cursor?" and applies it.
function pull(device) {
  const changes = journal.filter((e) => e.seq > device.cursor);
  for (const e of changes) device.local.set(e.path, { rev: e.rev, blocks: e.blocks });
  device.cursor = journal.length;
  return changes.map((e) => `${e.path}@${e.rev}`);
}

const laptop = { cursor: 0, local: new Map() }, phone = { cursor: 0, local: new Map() };
const base = (device) => device.local.get("plan.txt").rev;  // the revision this device's edit starts from
const day = "2026-10-24";                                   // a fake clock: the commit date

console.log("laptop creates:", commit("laptop", "plan.txt", 0, ["b1", "b2"], day));
pull(phone); pull(laptop);

// Both go offline and edit the same file from revision 1.
console.log("laptop commits:", commit("laptop", "plan.txt", base(laptop), ["b1", "b3"], day));
console.log("phone commits: ", commit("phone", "plan.txt", base(phone), ["b4", "b2"], day));
pull(phone); pull(laptop);
// Later the same day both edit offline again, now from revision 2, and the phone loses the race again.
console.log("laptop commits:", commit("laptop", "plan.txt", base(laptop), ["b1", "b6"], day));
console.log("phone commits: ", commit("phone", "plan.txt", base(phone), ["b5", "b2"], day));

console.log("phone pulls:   ", pull(phone));
console.log("laptop pulls:  ", pull(laptop));
for (const [p, f] of files) console.log(`server has:     ${p} rev ${f.rev} blocks ${f.blocks} (by ${f.by})`);
Result
laptop creates: { path: 'plan.txt', rev: 1 }
laptop commits: { path: 'plan.txt', rev: 2 }
phone commits:  { path: "plan (phone's conflicted copy 2026-10-24).txt", rev: 1 }
laptop commits: { path: 'plan.txt', rev: 3 }
phone commits:  { path: "plan (phone's conflicted copy 2026-10-24 2).txt", rev: 1 }
phone pulls:    [ 'plan.txt@3', "plan (phone's conflicted copy 2026-10-24 2).txt@1" ]
laptop pulls:   [ 'plan.txt@3', "plan (phone's conflicted copy 2026-10-24 2).txt@1" ]
server has:     plan.txt rev 3 blocks b1,b6 (by laptop)
server has:     plan (phone's conflicted copy 2026-10-24).txt rev 1 blocks b4,b2 (by phone)
server has:     plan (phone's conflicted copy 2026-10-24 2).txt rev 1 blocks b5,b2 (by phone)
What’s happening
  1. The laptop creates plan.txt at rev: 1; both devices pull and record revision 1 as their base for that path.
  2. Both go offline and edit. The laptop reconnects first and commits with baseRev = 1. The current revision is 1, so the compare succeeds and the file becomes rev: 2 with blocks b1,b3.
  3. The phone commits with baseRev = 1 too, but the current revision is now 2. The compare fails. Instead of overwriting the laptop's edit (last-write-wins would silently lose it) or rejecting the phone's edit, the service saves the phone's version as a new file: conflictName builds plan (phone's conflicted copy 2026-10-24).txt from the device and the commit date, and it is committed with baseRev = 0, as a file that didn't exist before (rev: 1).
  4. Both devices pull and edit again offline, now from revision 2. The laptop commits rev: 3; the phone conflicts again on the same day, so the name with its device and date is already taken, and the counter makes it plan (phone's conflicted copy 2026-10-24 2).txt. Unique names are what keep this case safe. With one name per device, plan (phone's conflicted copy).txt, committed against that copy's current revision, the second conflict would silently become revision 2 of the first copy and the edit with blocks b4,b2 would be lost (checked by running that variant), the very loss the conflicted copy exists to prevent.
  5. Each device pulls the entries after its cursor (plan.txt@3 and the second copy) and converges: all three files exist everywhere, and the user decides how to merge them.
  6. This is what Dropbox does: its help centre (checked October 2026) explains that when people edit the same file at once, or someone edits offline, it keeps both, and the last version saved becomes the conflicted copy, named with the editor's user name, "conflicted copy" and the save date. The numeric counter for a second conflict on the same day is this model's own fallback; Dropbox's page doesn't say what it does then. For opaque binary files there's nothing better a sync service can do without understanding the format.

The alternatives, so you can argue for the choice: last-write-wins (simple, loses data, unacceptable for user files; see last-write-wins and CRDTs); locking (check-out/check-in, as some enterprise tools do; prevents conflicts, but a lock held by an offline laptop blocks everyone); merging (only possible when the system understands the content, which is what a collaborative editor does with operational transforms or CRDTs, see collaborative document editor). A file sync service keeps both copies and leaves merging to the user or the application.

AdvancedFile sync: trade-offs and what to say

Trade-offs and what to say

DecisionChoiceCost
File representationList of block hashesMetadata per block; big block lists for big files
Block sizeLarge (MiB) blocks, content-defined for big filesSmall edits to small files still re-upload the file
Upload orderBlocks first, then commitOrphan blocks on crashes, cleaned up by GC
CommitCompare-and-set on baseRev per path, one shard per namespaceHuge shared namespaces become hot shards
PropagationHint push + pull with cursorOne extra round trip per change; much more robust than pushing content
ConflictsKeep both ("conflicted copy")User has to merge by hand
Dedup scopePer user or namespace by defaultLess storage saved than global dedup, no information leak
Fixed blocks and a log file

A 100 MiB log file is synced with fixed 4 MiB blocks. A program inserts one 80-byte line at the very top. Roughly how much is re-uploaded, and with content-defined chunks?

Show answer

With fixed blocks, all 100 MiB (all 25 blocks). With content-defined chunks, about one chunk.

What’s happening
  1. 100 MiB in 4 MiB blocks is 25 blocks. The insert is at byte 0, so every byte of the file moves 80 places later.
  2. Each fixed block now covers different bytes than before, so all 25 hashes are new, and the server has none of them: 100 MiB goes up for an 80-byte change. (An append at the end, which is how logs usually grow, would only change the last block.)
  3. With content-defined chunking, the boundaries depend on the bytes around them, not on their offset. The first chunk changes because it contains the new line; the first boundary after it is found at the same content as before, and from there every chunk is identical to a chunk the server already has. Only the first chunk is uploaded, as the model's insert row showed.

#Design a ride-hailing service

Added

A ride-hailing service (Uber, Lyft) has one unusual piece of data at its centre: the position of every available driver, which changes every few seconds. Everything else, riders, trips, payments and ratings, is an ordinary transactional system. The interview is about the moving part: how to store millions of dots that keep moving, and how to find the closest free one when a rider taps "Request" without two riders getting the same driver.

The mental model is a live map in memory plus a matchmaker. The map is a grid laid over each city; each cell holds the set of drivers currently inside it. A driver's location update usually just overwrites their position, and only sometimes moves them from one cell to the next. A nearby search looks at the rider's cell and the cells around it, never at the whole city. The map is ephemeral: if a server dies, the next round of updates (seconds later) rebuilds it. The trip, by contrast, is durable and goes in a database. Keeping those two apart is the main design decision.

The grid here is geohash cells (the building block from geohashing and quadtrees). At geohash length 6 a cell is about 0.9 km × 0.6 km at Toronto's latitude. This model puts 10,000 seeded drivers over greater Toronto, sends three rounds of location updates, and runs a nearest-driver search across the rider's cell and its 8 neighbours:

driver-index.mjs
const mulberry32 = (a) => () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const rand = mulberry32(2026);
const B32 = "0123456789bcdefghjkmnpqrstuvwxyz";

function geohash(lat, lng, len) {                 // interleave lng/lat bits, 5 bits per character
  let lo = [-90, -180], hi = [90, 180], out = "", bits = 0, ch = 0, even = true;
  while (out.length < len) {
    const d = even ? 1 : 0, v = even ? lng : lat, mid = (lo[d] + hi[d]) / 2;
    ch = (ch << 1) | (v >= mid ? 1 : 0);
    if (v >= mid) lo[d] = mid; else hi[d] = mid;
    even = !even;
    if (++bits === 5) { out += B32[ch]; bits = 0; ch = 0; }
  }
  return { hash: out, h: hi[0] - lo[0], w: hi[1] - lo[1], lat: (lo[0] + hi[0]) / 2, lng: (lo[1] + hi[1]) / 2 };
}
const neighbours = (lat, lng, len) => {           // the cell and the 8 around it, from its centre ± one cell
  const c = geohash(lat, lng, len), out = [];
  for (const dy of [-1, 0, 1]) for (const dx of [-1, 0, 1]) out.push(geohash(c.lat + dy * c.h, c.lng + dx * c.w, len).hash);
  return out;
};
const km = (a, b) => {                            // haversine distance
  const r = Math.PI / 180, x = Math.sin(((b.lat - a.lat) * r) / 2) ** 2 +
    Math.cos(a.lat * r) * Math.cos(b.lat * r) * Math.sin(((b.lng - a.lng) * r) / 2) ** 2;
  return 12742 * Math.asin(Math.sqrt(x));
};

const P = 6;                                      // ≈ 0.9 km × 0.6 km cells in Toronto
const cells = new Map(), drivers = new Map();     // cell -> Set(driver id), id -> position
let moves = 0;
function update(id, lat, lng) {
  const cell = geohash(lat, lng, P).hash, old = drivers.get(id);
  if (old && old.cell !== cell) { cells.get(old.cell).delete(id); moves++; }
  if (!old || old.cell !== cell) (cells.get(cell) ?? cells.set(cell, new Set()).get(cell)).add(id);
  drivers.set(id, { lat, lng, cell });
}
function nearest(rider, k) {
  const ids = neighbours(rider.lat, rider.lng, P).flatMap((c) => [...(cells.get(c) ?? [])]);
  return { examined: ids.length, best: ids.map((id) => ({ id, km: km(rider, drivers.get(id)) }))
    .sort((a, b) => a.km - b.km).slice(0, k) };
}

for (let id = 0; id < 10_000; id++) update(id, 43.55 + rand() * 0.2, -79.5 + rand() * 0.25); // greater Toronto
for (let tick = 0; tick < 3; tick++)              // every driver reports again, ~50 m from before
  for (const [id, d] of drivers) update(id, d.lat + (rand() - 0.5) * 0.0009, d.lng + (rand() - 0.5) * 0.0012);

const rider = { lat: 43.6532, lng: -79.3832 };
const { examined, best } = nearest(rider, 3);
const brute = [...drivers].map(([id, d]) => ({ id, km: km(rider, d) })).sort((a, b) => a.km - b.km).slice(0, 3);
console.log(`rider cell ${geohash(rider.lat, rider.lng, P).hash}, ${cells.size} non-empty cells, ${drivers.size} drivers`);
console.log(`30,000 updates, ${moves} changed cell (${((moves / 30000) * 100).toFixed(1)}%)`);
console.log(`examined ${examined} drivers in 9 cells instead of ${drivers.size}`);
for (const b of best) console.log(`  driver ${b.id}  ${b.km.toFixed(3)} km  cell ${drivers.get(b.id).cell}`);
console.log(`same answer as scanning everyone: ${best.every((b, i) => b.id === brute[i].id)}`);
Result
rider cell dpz83d, 893 non-empty cells, 10000 drivers
30,000 updates, 2011 changed cell (6.7%)
examined 102 drivers in 9 cells instead of 10000
  driver 9096  0.069 km  cell dpz83d
  driver 5362  0.167 km  cell dpz83e
  driver 962  0.180 km  cell dpz83d
same answer as scanning everyone: true
What’s happening
  1. geohash(lat, lng, len) halves the longitude and latitude ranges alternately, writes one bit per halving, and turns every 5 bits into a base-32 character. It also returns the cell's centre and size, which neighbours uses to find the 8 surrounding cells: it encodes the points one cell-height and one cell-width away from the centre in each direction.
  2. The index is two maps: cells (cell → set of driver ids) and drivers (id → last position and cell). update always overwrites the position; it touches cells only when the driver crossed into a different cell.
  3. 10,000 drivers spread over the region occupy 893 non-empty cells. Three rounds of updates (30,000 in total) moved each driver by up to about 50 m north–south and east–west each time, and only 2011 updates (6.7%) changed cell. The other 93% are a single map write. That ratio is why an in-memory cell index can absorb hundreds of thousands of updates a second.
  4. The rider is in cell dpz83d. nearest collects the drivers in the 9 cells around it, 102 of them, computes the real (haversine) distance to each and sorts. A query that examined all 10,000 drivers would do about 100 times more work, and on a real fleet the ratio is far bigger.
  5. The second-nearest driver, 5362 at 0.167 km, is in cell dpz83e, a neighbour. Searching only the rider's own cell would have missed him: a rider near a cell edge can be 10 m from a driver across the line. That's why the 8 neighbours are always included.
  6. same answer as scanning everyone: true checks the indexed result against a brute-force scan. It's correct here because the 3 nearest drivers are well inside the 9-cell block; the quiz at the end shows when that guarantee runs out.
AdvancedRide-hailing: requirements

Requirements

Functional:

  • Riders see nearby available cars on the map and request a ride from a pickup to a destination.
  • The system matches the request to a nearby available driver within seconds; the driver accepts or declines.
  • Both sides see each other's live position until pickup, and the rider sees the trip's progress.
  • Trip completion produces a fare and a payment (payment itself is the payment system). Pricing, ETAs and surge are mentioned but not designed in depth.

Non-functional:

  • Matching latency: a match within a few seconds; the nearby-cars map updates every few seconds.
  • No double assignment: a driver is never offered or assigned to two trips at once. This needs strong consistency per driver, even though positions themselves can be eventually consistent and a few seconds stale.
  • High write throughput: location updates outnumber everything else by orders of magnitude.
  • Availability: degrade gracefully per city; a problem in one region must not stop the others.
  • Scale: 1.5 M drivers online at the busiest hour, 30 M rides a day.
AdvancedRide-hailing: estimates

Estimates

Uber's developer documentation (the Guest Rides API's driver-location webhook, checked October 2026) says the webhook is sent every 4 seconds by default during a trip, and notes that frequent driver location webhooks are "not available in all regions". That is a public figure for how often positions are shared with partners; the driver app's own reporting interval isn't published, so this estimate assumes 4 seconds as well.

ride-estimates.mjs
// Assumptions for a global ride-hailing service at peak.
const driversOnline = 1.5e6;     // drivers with the app on at the busiest hour
const updateEverySec = 4;        // driver app reports its location every 4 s
const updateBytes = 100;         // id, lat, lng, heading, speed, timestamp, plus overhead
const ridesPerDay = 30e6;
const peakFactor = 3;
const indexEntryBytes = 64;      // in-memory: id, position, cell, status

const updatesPerSec = driversOnline / updateEverySec;
const ridesPeak = (ridesPerDay / 86400) * peakFactor;
console.log(`location updates:   ${updatesPerSec.toLocaleString("en")}/s, ${((updatesPerSec * updateBytes * 8) / 1e6).toFixed(0)} Mbps in`);
console.log(`location history:   ${((updatesPerSec * updateBytes * 86400) / 1e12).toFixed(1)} TB/day if every point is kept`);
console.log(`ride requests:      ${Math.round(ridesPeak).toLocaleString("en")}/s at peak`);
console.log(`live index:         ${((driversOnline * indexEntryBytes) / 1e6).toFixed(0)} MB, fits in one machine's RAM`);
console.log(`write:read ratio:   ${Math.round(updatesPerSec / ridesPeak)} location writes per ride request`);

// Geohash cell size: each character adds 5 bits, alternating longitude and latitude.
const lat = 43.65, kmPerDegLat = 111.32, kmPerDegLng = 111.32 * Math.cos((lat * Math.PI) / 180);
console.log("geohash length  cell size at Toronto's latitude");
for (const len of [4, 5, 6, 7]) {
  const bits = 5 * len, lngBits = Math.ceil(bits / 2), latBits = Math.floor(bits / 2);
  const w = (360 / 2 ** lngBits) * kmPerDegLng, h = (180 / 2 ** latBits) * kmPerDegLat;
  const fmt = (x) => (x >= 1 ? `${x.toFixed(1)} km` : `${Math.round(x * 1000)} m`);
  console.log(`${String(len).padStart(14)}  ${fmt(w)} wide × ${fmt(h)} tall`);
}
Result
location updates:   375,000/s, 300 Mbps in
location history:   3.2 TB/day if every point is kept
ride requests:      1,042/s at peak
live index:         96 MB, fits in one machine's RAM
write:read ratio:   360 location writes per ride request
geohash length  cell size at Toronto's latitude
             4  28.3 km wide × 19.6 km tall
             5  3.5 km wide × 4.9 km tall
             6  885 m wide × 611 m tall
             7  111 m wide × 153 m tall
What’s happening
  1. 1.5 M drivers reporting every 4 s is 375,000 updates a second. At 100 bytes each that's only 300 Mbps of traffic; the cost is the number of writes, not the bytes. No ordinary database index on (lat, lng) wants 375,000 updates a second.
  2. Keeping every point would be 3.2 TB/day. That's useful (trip replay, ETA models, fraud checks) but belongs in an append-only log and a cheap analytical store, not in the live index.
  3. Ride requests peak at about 1,042/s. The ratio of location writes to ride requests is 360 to one, so the system is write-heavy on positions and read-light on matching. Optimise the index for cheap updates.
  4. The live index is small: 1.5 M drivers × 64 bytes is 96 MB. One machine could hold it; the reasons to shard are the write rate and the blast radius, not memory.
  5. Cell size per geohash length, at Toronto's latitude: length 5 is 3.5 km × 4.9 km, length 6 is 885 m × 611 m, length 7 is 111 m × 153 m. Each character adds 5 bits, alternating longitude and latitude, so the cells alternate between wider-than-tall and taller-than-wide. Length 6 suits a "drivers within a kilometre or so" search: 9 cells cover about 2.7 km × 1.8 km, although only 611 m around the rider is guaranteed to be inside them (see the quiz below).
AdvancedRide-hailing: API sketch

API

API sketch
Driver app (one WebSocket per online driver)
  -> { type: "location", lat, lng, heading, speedKmh, ts }        every 4 s
  <- { type: "offer", offerId, pickup, etaSec, expiresInSec: 15 }
POST /v1/offers/{offerId}/accept       -> 200 { rideId }  | 409 { reason: "offer expired" }
POST /v1/offers/{offerId}/decline

Rider app
POST /v1/rides   Idempotency-Key: …   { pickup, dropoff, product: "standard" }
  -> 202 { rideId, status: "matching" }
GET  /v1/rides/{rideId}                -> { status, driver, driverPosition, etaSec }
WebSocket /v1/rides/{rideId}/live      <- driver position and status changes
GET  /v1/drivers/nearby?lat=&lng=      -> up to 20 approximate positions for the map

Locations come over a long-lived connection because 375,000 short HTTP requests a second would spend most of their cost on connection setup, and the same connection carries offers back to the driver (see scaling WebSockets). POST /v1/rides carries an idempotency key because a rider on a bad connection taps again, and two rides for one tap is a real bug (idempotency keys for APIs). Matching is asynchronous, so the request returns 202 and the rider watches the status.

AdvancedRide-hailing: data model

Data model

StoreWhatNotes
Geo index (in memory, per city shard)cell → set of driver ids; driver id → lat, lng, heading, tsrebuilt from updates within seconds after a restart
Driver state (in-memory store with replication, e.g. Redis)driver_id → status (offline | available | offered | on_trip), version, ride_iddecides "is this driver free" for matching; updated by compare-and-set. Redis replicates asynchronously, so a failover can lose a recent claim: the rides DB's one-active-ride rule is the backstop
Rides DB (relational)rides(id, rider_id, driver_id, status, pickup, dropoff, requested_at, accepted_at, fare)durable, transactional, sharded by city; a partial unique index allows one active ride per driver_id
Location logevery update, partitioned by driver idKafka-style log for history, ETAs and analytics

Positions and status are kept separate on purpose. Positions are overwritten constantly and can be stale; status changes rarely and must be exact. A stale position means a slightly worse match; a stale status means two riders get one car.

AdvancedRide-hailing: architecture diagram

High-level design

Driver app GPS every 4 s Location gateway WebSockets Location service geo index per city Location stream history · ETA · pricing Rider app request · track Ride API idempotent requests Matching service rank · claim · offer Rides DB durable trips Driver state status + version nearby drivers claim (CAS) offer driver position
Positions flow along the top and are allowed to be a few seconds stale; the claim on a driver is a compare-and-set, backed by one active ride per driver in the rides DB.

Talk through one ride. The driver app streams its position to a gateway that holds its WebSocket; the gateway forwards updates to the location service shard for that city, which updates its cell index, and to a log for everything that wants history. The rider requests a ride; the matching service asks the location service for available drivers near the pickup, ranks them (distance first, or better, estimated time to pickup), claims the best one in the driver state store with a compare-and-set, writes the ride, and sends the offer back through the driver's gateway. If the driver declines or the 15-second offer expires, the claim is released and the next candidate gets it.

AdvancedDeep dive: the geo index for moving drivers

Why not a database with an index on latitude and longitude? A B-tree on (lat, lng) answers "latitude between a and b" well, but not "and longitude between c and d" at the same time, and every one of 375,000 updates a second rewrites index pages. Spatial indexes (R-trees, PostGIS) answer the query well but are built for data that changes far less often. A grid of cells in memory makes both operations cheap: an update is a hash-map write (and a set move 6.7% of the time), and a search is 9 set lookups plus a sort of a few hundred candidates.

Options for the grid:

  • Geohash cells in your own service, as in the model. Simple, and a cell id is a string, so it shards and caches easily.
  • Redis GEO commands. GEOADD stores members in a sorted set whose score is a 52-bit geohash (latitude and longitude bits interleaved), and GEOSEARCH finds members within a radius or box. Redis's source (geo.c) searches the cell and its 8 neighbours at a precision chosen for the radius and then filters by distance, and the docs say that with ANY it returns as soon as enough matches are found, so they may not be the closest. A sorted set update is O(log N), fine at a few hundred thousand writes a second spread over shards.
  • H3 hexagons. Uber built H3 for dispatch and pricing and open-sourced it (Uber engineering blog, 2018). Every hexagon's six neighbours are at the same distance from its centre, unlike squares, whose diagonal neighbours are farther than the side ones, which makes "rings" of cells and per-cell statistics like demand and surge cleaner.

Shard by geography. A city is a natural shard: rides almost never cross cities, and a city's traffic fits on a few machines. Big cities split further by cell prefix. A rider near a shard boundary needs a query to both shards, which the location service handles by mapping the 9 cells to their shards. Replicate each shard to a standby, but remember that losing it costs only seconds: the next updates rebuild it.

Updates arrive out of order sometimes (mobile networks retry). Keep the update's device timestamp and drop any update older than the stored one, otherwise a driver can jump backwards on the map.

AdvancedDeep dive: matching without double-booking a driver

Many matching servers run at once, and two riders standing on the same corner will both see the same nearest driver. Reading "driver is available" and then writing "driver is offered" is a race: both matchers read available, both write offered, and the driver gets two offers. The fix is the same as for any shared resource: make the claim a compare-and-set on a version.

dispatch.mjs
// Driver state with a version number; an offer only goes out if the version hasn't moved since it was read.
const drivers = new Map([
  ["d1", { status: "available", version: 7, km: 0.4 }],
  ["d2", { status: "available", version: 3, km: 0.9 }],
  ["d3", { status: "on_trip", version: 12, km: 0.2 }],
]);

function compareAndSet(id, expectedVersion, next) {        // in Redis: a Lua script or WATCH/MULTI; in SQL: UPDATE … WHERE version = ?
  const d = drivers.get(id);
  if (d.version !== expectedVersion) return false;
  Object.assign(d, next, { version: d.version + 1 });
  return true;
}

function match(rideId, snapshot) {                         // snapshot = what this matcher read from the geo index
  const candidates = [...snapshot].filter(([, d]) => d.status === "available").sort((a, b) => a[1].km - b[1].km);
  for (const [id, seen] of candidates) {
    if (compareAndSet(id, seen.version, { status: "offered", ride: rideId })) return `${rideId} -> offer sent to ${id}`;
    console.log(`  ${rideId}: ${id} was taken between read and write, trying the next driver`);
  }
  return `${rideId} -> no driver, widen the search`;
}

// Two matchers read the same state at the same moment, then act one after the other.
const snapA = new Map([...drivers].map(([id, d]) => [id, { ...d }]));
const snapB = new Map([...drivers].map(([id, d]) => [id, { ...d }]));
console.log(match("ride-A", snapA));
console.log(match("ride-B", snapB));
console.log(Object.fromEntries([...drivers].map(([id, d]) => [id, `${d.status} v${d.version}${d.ride ? " " + d.ride : ""}`])));
Result
ride-A -> offer sent to d1
  ride-B: d1 was taken between read and write, trying the next driver
ride-B -> offer sent to d2
{ d1: 'offered v8 ride-A', d2: 'offered v4 ride-B', d3: 'on_trip v12' }
What’s happening
  1. Driver d3 is the closest (0.2 km) but on_trip, so both matchers filter him out. d1 at 0.4 km is the best available driver for both riders, at version 7.
  2. Both matchers read the state at the same moment (snapA, snapB are copies of what they saw). Matcher A calls compareAndSet("d1", 7, …): the version is still 7, so the write succeeds and d1 becomes offered at version 8.
  3. Matcher B tries the same claim with expected version 7. The current version is 8, so it fails and prints d1 was taken between read and write. Without the version check, B would have overwritten A's offer.
  4. B moves to its next candidate, d2 at 0.9 km, whose version is unchanged (3), and claims it. Final state: d1 offered to ride-A, d2 offered to ride-B, d3 untouched.
  5. In Redis this is a small Lua script (read status and version, compare, write) or WATCH/MULTI; in SQL it is UPDATE drivers SET status = 'offered', version = version + 1 WHERE id = ? AND version = ? and checking that one row changed. The offer then carries an expiry; if the driver doesn't accept in 15 s, another compare-and-set puts them back to available.

The claim store is not the final word. The compare-and-set is atomic on the Redis primary, but Redis replicates asynchronously (its replication documentation says so, and adds that even WAIT doesn't turn it into a strongly consistent system), the same weakness the ticket-booking design below notes for Redis hold keys. If the primary fails right after d1 was claimed for ride-A, the promoted replica may still show d1 as available, and another matcher can offer him to ride-B. Two backstops make that harmless. First, an offer is only an offer: the driver's explicit accept (POST /v1/offers/{offerId}/accept) is what assigns the ride. Second, the accept writes the ride in the rides database under a rule of one active ride per driver, a partial unique index on rides(driver_id) for rides in accepted or in_progress, so a second accept fails with 409 and that rider is matched again. The fast store keeps double offers rare; the durable, transactional database makes a double assignment impossible.

Greedy "nearest free driver per request" is easy to explain and good enough for an interview. Mention the improvement: batch matching. Collect the requests that arrive in a short window (a second or two) and solve the assignment for all of them together, minimising total pickup time, so one rider doesn't take the only driver who was perfect for another. Rank by estimated time to pickup rather than straight-line distance: a driver 300 m away on the other side of a highway can be ten minutes away.

AdvancedDeep dive: the location pipeline

The gateway tier holds 1.5 M WebSockets; at tens of thousands of connections per server (a common rule of thumb; measure your own) that's dozens to hundreds of servers, behind a load balancer that keeps a driver on one gateway for the life of the connection. Each update is fanned out to two places with different needs: the geo index (latest position only, may drop updates under load) and the location log (every point, durable, consumed later). If the index falls behind, shed load by dropping stale updates, not by queueing them: a 20-second-old position is worthless when a fresh one arrives every 4 seconds (the idea from backpressure and load shedding).

During a trip the rider subscribes to their driver's position: the gateway (or a pub/sub channel per ride) pushes each update to the rider's connection. Before a request, the "cars near you" map doesn't need exact data: return a handful of approximate positions from the cells around the rider, cached for a few seconds per cell, which turns millions of map views into a small number of index reads.

The app can also adapt its reporting rate: every 4 seconds while available or on a trip, much less often when stationary or offline. That saves phone battery and cuts the write rate where it buys nothing.

AdvancedRide-hailing: trade-offs and what to say

Trade-offs and what to say

DecisionChoiceCost
Live positionsIn-memory cell index per city, rebuilt from updatesLost on a crash (seconds to rebuild); not queryable historically
Cell systemGeohash length 6 (or H3)Fixed cell size: dense downtown cells hold many drivers, rural ones need wider rings
Driver statusSeparate store, compare-and-set on a version; one active ride per driver in the rides DBOne more store; a Redis failover can lose a claim, so the driver's accept and the database rule are the real guarantee
MatchingGreedy nearest by ETA, offers with expiryNot globally optimal; batch matching is better but adds a second or two
TransportWebSockets for updates and offersStateful gateways; sticky connections; reconnect storms after an outage
HistoryAppend-only log, not the live indexTwo writes per update
AdvancedThe rider's own cell is not enough
When does the 9-cell search miss the true nearest driver?

At geohash length 6 in Toronto, cells are 885 m wide and 611 m tall. The 9-cell search returns its closest driver at 1.0 km. Is that guaranteed to be the closest driver overall?

Show answer

No. The 9 cells only guarantee that every point within 611 m of the rider is covered.

What’s happening
  1. The rider can be anywhere in the centre cell. In the worst case they sit on its northern edge, so the 3×3 block extends only one cell height (611 m) further north of them; the closest uncovered point is 611 m away.
  2. East and west the block extends at least one cell width, 885 m. So the safe radius is the smaller dimension, 611 m.
  3. A driver found at 1.0 km is beyond that radius, so a driver at, say, 700 m just outside the block could be closer and wasn't looked at.
  4. The fix: if the k-th result is farther than the guaranteed radius, search the next ring of cells (or a shorter geohash) and merge. The model's answer was exact because its third result was 180 m away, well inside 611 m.

#Design a payment system

Added

A payment system for a marketplace or an online shop moves money between customers, merchants and the platform, through a payment service provider (PSP) such as Stripe or Adyen that talks to the card networks. Its traffic is modest compared with a feed or a video service. What makes it hard is that mistakes are money: a double charge is a refund, an angry customer and possibly a regulator; a lost record is money nobody can account for. The design is about correctness under retries, crashes and partial failures, not about raw scale.

Two mental models carry the whole design. First, money is never created or destroyed inside your system, only moved. So you record every movement as entries that sum to zero (debit one account, credit another), you never edit a balance in place, and a balance is just the sum of an account's entries. That's double-entry bookkeeping, and it has caught errors for more than five centuries because an unbalanced book is visibly wrong. Second, every call across a network may have happened even when you got an error back. A timeout from the PSP doesn't mean the card wasn't charged. So every step is made safe to repeat with an idempotency key, and a separate process (reconciliation) checks your records against the PSP's every day, because something will still slip through.

"Exactly-once money movement" therefore isn't a delivery guarantee you buy; it's at-least-once attempts plus idempotent effects plus reconciliation, the pattern from idempotency and exactly-once. This model is the core of it: a ledger that only accepts balanced transactions, behind an idempotency layer with one key per payment attempt. It handles a declined card followed by a new attempt with another card, a double click, a retry after a timeout, a reused key, and a new attempt on an order that is already paid:

ledger.mjs
// Amounts are integers in cents. Debits are +, credits are −; every transaction must sum to 0.
const entries = [];                 // append-only: { txn, account, amount }
const keys = new Map();             // idempotency key, one per payment attempt -> { fingerprint, state, response }
const liveByOrder = new Map();      // order -> key of its in-flight or successful attempt; failed ones don't count
let nextTxn = 1, pspCalls = 0, declines = 0;

const psp = { async charge(key, card, cents) { pspCalls++; await null;
  if (card === "pm_expired") { declines++; return { declined: true }; } return { pspRef: `ch_${key}`, cents }; } };

function post(txn, lines) {         // the only way money moves: a balanced set of entries, written together
  if (lines.reduce((s, [, amount]) => s + amount, 0) !== 0) throw new Error(`txn ${txn} does not balance`);
  for (const [account, amount] of lines) entries.push({ txn, account, amount });
}

async function pay({ key, order, card, merchant, cents, feeCents }) {
  const fingerprint = JSON.stringify({ order, card, merchant, cents, feeCents });
  const seen = keys.get(key);
  if (seen && seen.fingerprint !== fingerprint) return { status: 422, error: "key reused with different parameters" };
  if (seen?.state === "in_progress") return { status: 409, error: "same request still running, retry later" };
  if (seen) return { ...seen.response, replayed: true };
  if (liveByOrder.has(order)) return { status: 409, error: `${order} is already paid or being paid` };
  keys.set(key, { fingerprint, state: "in_progress" });          // claim the key before doing anything
  liveByOrder.set(order, key);                                   // and the order: one live attempt at a time
  const charge = await psp.charge(key, card, cents);             // our key goes to the PSP too
  let response;
  if (charge.declined) {
    liveByOrder.delete(order);                                   // a decline frees the order for a new attempt
    response = { status: 402, error: "card declined" };
  } else {
    const txn = nextTxn++;
    post(txn, [["psp_receivable", +cents], [`merchant:${merchant}`, -(cents - feeCents)], ["fee_revenue", -feeCents]]);
    response = { status: 201, txn, pspRef: charge.pspRef };
  }
  keys.set(key, { fingerprint, state: "done", response });
  return response;
}

const first = { key: "k_3f9a", order: "ord_77", card: "pm_expired", merchant: "bookshop", cents: 4_999, feeCents: 175 };
console.log("attempt 1:", await pay(first));                        // the saved card has expired
console.log("retry 1:  ", await pay(first));                        // same attempt retried: same answer
const second = { ...first, key: "k_8c21", card: "pm_visa" };        // the customer picks another card: a new attempt, a new key
const [a, b] = await Promise.all([pay(second), pay(second)]);       // double click: two requests at once
console.log("attempt 2:", a);
console.log("racing:   ", b);
console.log("retry 2:  ", await pay(second));                       // client timed out and retries
console.log("misuse:   ", await pay({ ...second, cents: 5_999 }));  // same key, different amount
console.log("attempt 3:", await pay({ ...second, key: "k_d410" })); // a fresh key for an order already paid
await pay({ key: "k_51e7", order: "ord_78", card: "pm_visa", merchant: "bookshop", cents: 1_250, feeCents: 66 });

const balance = (acct) => entries.filter((e) => e.account === acct).reduce((s, e) => s + e.amount, 0);
for (const acct of ["psp_receivable", "merchant:bookshop", "fee_revenue"]) console.log(acct.padEnd(18), balance(acct));
console.log(`entries ${entries.length}, PSP calls ${pspCalls} (${declines} declined), sum of all entries ${entries.reduce((s, e) => s + e.amount, 0)}`);
Result
attempt 1: { status: 402, error: 'card declined' }
retry 1:   { status: 402, error: 'card declined', replayed: true }
attempt 2: { status: 201, txn: 1, pspRef: 'ch_k_8c21' }
racing:    { status: 409, error: 'same request still running, retry later' }
retry 2:   { status: 201, txn: 1, pspRef: 'ch_k_8c21', replayed: true }
misuse:    { status: 422, error: 'key reused with different parameters' }
attempt 3: { status: 409, error: 'ord_77 is already paid or being paid' }
psp_receivable     6249
merchant:bookshop  -6008
fee_revenue        -241
entries 6, PSP calls 3 (1 declined), sum of all entries 0
What’s happening
  1. Amounts are integers in cents (4_999 is $49.99). Floating-point dollars can't represent 0.1 exactly, and rounding errors in money are bugs. Each currency has its own minor unit, so the amount always travels with its currency in a real system.
  2. Attempt 1 pays order ord_77 with a saved card that has expired. The key k_3f9a is generated once for this attempt; pay claims it (in_progress) and marks the order as having a live attempt, then the PSP declines. The decline frees the order and is saved as the attempt's response, 402 card declined. The client's retry with the same key gets that saved decline back (replayed: true): a key names one attempt, and that attempt's answer doesn't change.
  3. Attempt 2: the customer picks another card, which is a new attempt with a new key, k_8c21. They double-click, so two requests with that key start together. The first claims the key before calling the PSP; the second runs while the first is waiting, sees in_progress and gets 409 same request still running, retry later. Without the claim, both would have charged the card.
  4. The first request gets the PSP's result and calls post, which writes three entries: psp_receivable +4999 (the PSP owes us the money), merchant:bookshop −4824 (we owe the merchant the price minus our fee) and fee_revenue −175. They sum to zero, or post throws. It saves the response under the key and returns 201 with txn: 1. Its retry after a client timeout gets the saved response (replayed: true) and nothing is charged or posted again, the behaviour Stripe documents for its API (the first response for a key is saved and replayed, even a 500). A buggy client that reuses k_8c21 with a different amount gets 422; Stripe also compares a retry's parameters with the original's and errors if they differ.
  5. Attempt 3 is a fresh key for ord_77, which is already paid, and gets 409 ord_77 is already paid or being paid. The guard is liveByOrder: uniqueness on the order's in-flight or successful payment only. A declined attempt drops out of it, which is why attempt 2 was allowed; a succeeded one never does, so a second charge for the order is refused. In the database this is a partial unique index (see the data model). A plain unique order_id on every payment row would have blocked the retry with another card.
  6. After a second order (ord_78, $12.50, its own key), the balances are psp_receivable 6249, merchant:bookshop −6008, fee_revenue −241: 6 entries and 3 PSP calls (1 declined) for two paid orders despite eight calls to pay, and the sum of all entries is 0. If any code path ever wrote one side without the other, that sum would show it.
AdvancedPayments: requirements

Requirements

Functional:

  • Charge a customer for an order (card or wallet, through a PSP); handle payments that need extra customer action (3-D Secure).
  • Refund fully or partially.
  • Pay merchants out their balance on a schedule, minus the platform's fees.
  • Keep a complete, auditable ledger of every movement; show merchants their balance and history.
  • Notify the order system when a payment succeeds or fails.

Non-functional:

  • Correctness first: never charge twice for one order, never lose a payment, and every cent is accounted for. When in doubt, a payment should fail or wait rather than be guessed.
  • Auditability: entries are immutable; corrections are new entries; history is kept for years (7 assumed here; the real retention depends on the jurisdiction).
  • Availability is important but secondary to correctness: during an outage, queue or reject payments, never record ones that may not have happened.
  • Security and compliance: card numbers never touch our servers (the PSP's hosted fields or SDK tokenise them), which keeps most of the system out of PCI DSS scope.
  • Latency: a second or two for a payment is acceptable; the PSP round trip dominates.
AdvancedPayments: estimates

Estimates

pay-estimates.mjs
// Assumptions for the payment service of a large marketplace.
const paymentsPerDay = 30e6;
const peakFactor = 10;           // flash sales and holidays are far spikier than daily traffic
const entriesPerPayment = 3;     // receivable, merchant, fee (refunds and payouts add more)
const entryBytes = 200;          // row + indexes
const keyBytes = 600;            // idempotency record: key, fingerprint, saved response
const keyTtlHours = 24;
const pspLatencyMs = 800;        // card authorisation round trip (assumed; measure your PSP)
const years = 7;                 // keep the ledger for audits

const tps = paymentsPerDay / 86400;
console.log(`payments:          ${Math.round(tps)}/s average, ${Math.round(tps * peakFactor).toLocaleString("en")}/s peak`);
console.log(`ledger writes:     ${Math.round(tps * peakFactor * entriesPerPayment).toLocaleString("en")} entries/s peak`);
console.log(`ledger growth:     ${((paymentsPerDay * entriesPerPayment * entryBytes) / 1e9).toFixed(0)} GB/day, ` +
  `${((paymentsPerDay * entriesPerPayment * entryBytes * 365 * years) / 1e12).toFixed(0)} TB over ${years} years`);
console.log(`idempotency store: ${((tps * peakFactor * keyTtlHours * 3600 * keyBytes) / 1e9).toFixed(0)} GB if the peak lasted all ${keyTtlHours} h`);
console.log(`in flight at PSP:  ${Math.round(tps * peakFactor * (pspLatencyMs / 1000)).toLocaleString("en")} concurrent calls at peak (Little's law)`);
Result
payments:          347/s average, 3,472/s peak
ledger writes:     10,417 entries/s peak
ledger growth:     18 GB/day, 46 TB over 7 years
idempotency store: 180 GB if the peak lasted all 24 h
in flight at PSP:  2,778 concurrent calls at peak (Little's law)
What’s happening
  1. 30 M payments a day is 347 a second on average. Shopping is spiky (a flash sale, Black Friday), so the design uses a peak factor of 10: 3,472/s. For scale, Visa's annual reports describe VisaNet as capable of processing more than 65,000 transaction messages a second; one marketplace is a small fraction of a card network.
  2. Three ledger entries per payment is 10,417 entry writes a second at peak, in about 3,500 transactions a second (each payment's entries, status change and idempotency record commit together). Treat that as a planning number, not a promise: one relational primary can reach it only if commits are batched, by the database's group commit (many transactions' log flushes share one disk flush) or by the service batching inserts, and only on fast storage; if every commit waited for its own flush, throughput would be capped by how many flushes a second the disk can do. Measure it on your hardware. If it doesn't hold, partition the ledger by account from the start. The price is that one payment's entries then land on several partitions, so each partition's write is made idempotent by txn_id and the zero-sum check runs across them, and platform accounts that every payment touches (psp_receivable, fee_revenue) are split into sub-accounts so they don't become one hot partition. Keeping it in one database for as long as you can is what makes a payment's entries, its status change and its idempotency record one ACID transaction.
  3. Growth: 18 GB/day of ledger, 46 TB over 7 years. That's the number that eventually forces partitioning, usually by time (old months become read-only partitions) or by account.
  4. Idempotency records live 24 hours (as Stripe's API v1 does: it may prune keys once they are at least 24 hours old). Even if the peak lasted a whole day that's 180 GB; in practice far less. A table with a TTL, or a key-value store, is fine.
  5. Little's law: 3,472 payments a second × 0.8 s at the PSP means about 2,778 calls in flight at peak. That sets the size of connection pools and worker concurrency, and it's why the PSP call must not hold a database transaction or lock open: 2,778 open transactions waiting on the network would exhaust the database.
AdvancedPayments: API sketch

API

API sketch
POST /v1/payments
  Idempotency-Key: 8c21e0f4-…     new for each payment attempt, the same on its retries
  { orderId: "ord_77", amount: { value: 4999, currency: "CAD" }, paymentMethod: "pm_tok_…", merchantId: "bookshop" }
  -> 201 { paymentId, status: "succeeded" }
  -> 202 { paymentId, status: "requires_action", nextAction: { type: "3ds_redirect", url } }
  -> 202 { paymentId, status: "processing" }        PSP outcome not known yet
  -> 409 same key still running     422 same key, different parameters     402 card declined
  -> 409 order already paid or being paid (another attempt is live or succeeded)
GET  /v1/payments/{paymentId}
POST /v1/payments/{paymentId}/refunds    Idempotency-Key: …   { amount: { value: 1000, currency: "CAD" } }
POST /webhooks/psp                       signed by the PSP; deduplicated by the PSP's event id
GET  /v1/merchants/{id}/balance          derived from ledger entries

The client generates the key once per payment attempt (each time the customer presses Pay with a chosen card) and sends the same key on every retry and duplicate submission of that attempt, the rule from idempotency keys for APIs: one key per Pay press, not per HTTP request. A retry with another card after a decline is a new attempt with a new key. A fixed key per order (ord_77-pay, say) breaks exactly that case: the second card's request reuses the key with different parameters and gets 422, or, with the same parameters, the saved decline. The second line of defence is in the payments table: at most one in-flight or successful payment per order, not a plain unique order_id, so declined attempts don't block the next one. Stripe's documentation (checked October 2026) suggests V4 UUIDs or similar random keys, allows up to 255 characters, and warns against putting personal data like email addresses in them. Verifying the PSP's webhook signatures is covered in webhooks.

processing is an honest status: if the PSP call timed out we don't know whether the card was charged, and the API says so rather than guessing.

AdvancedPayments: data model

Data model

All in one relational database (Postgres or MySQL), so the rows below that change together are written in one transaction:

TableColumnsNotes
paymentsid, order_id, idempotency_key, merchant_id, amount, currency, status, psp_ref, created_at, versionone row per attempt; status: created → processing → succeeded | failed | requires_action, then refunded; unique per order only while in flight or succeeded (below)
payment_eventspayment_id, seq, from_status, to_status, reason, atevery transition, for audit and debugging
accountsid, owner, type (asset | liability | revenue), currencypsp_receivable, merchant:bookshop, fee_revenue, …
ledger_entriesid, txn_id, account_id, amount (signed, minor units), currency, created_atappend-only; SUM(amount) per txn_id is 0
idempotency_keyskey, scope, fingerprint, state, response, expires_atscope (merchant or API client) so two clients' keys can't collide
outboxid, topic, payload, created_at, published_atevents to publish, written in the same transaction

The "one live or successful payment per order" rule is a partial unique index. In PostgreSQL: CREATE UNIQUE INDEX one_live_payment_per_order ON payments (order_id) WHERE status IN ('processing', 'requires_action', 'succeeded'). MySQL has no partial indexes, so add a stored generated column that holds order_id for those statuses and NULL otherwise, and put a unique index on it (a MySQL unique index allows any number of NULLs). Both were tried on this site's sandbox (PostgreSQL 18 and MySQL 9.7.2, October 2026): two failed rows and one succeeded row for ord_77 were accepted, and a fourth row in processing was rejected with a duplicate-key error (ERROR 1062 (23000): Duplicate entry 'ord_77' for key 'payments.one_live_payment_per_order' in MySQL).

Balances are derived. For speed you can keep an account_balances row per account updated in the same transaction as the entries (with a version for compare-and-set), but the entries are the truth, and a nightly job checks that each stored balance equals the sum of its entries.

AdvancedPayments: architecture diagram

High-level design

Checkout web · app · backend Payment API idempotency check Payment service state machine PSP (external) cards · banks · wallets Payments DB ledger · keys · outbox Webhooks in signed · deduped Outbox relay publish, mark sent Event log orders · payouts · email Reconciliation daily · exceptions same key one transaction settlement file ledger
Every state change, ledger entry and outgoing event is written in one database transaction; the PSP is reached with the same idempotency key on every attempt, and reconciliation catches what slips through.

A payment, end to end: the API checks the key (new, running, done or mismatched). The service inserts the payment as processing and claims the key in one transaction (the insert fails if the order already has a live or successful payment), commits, and only then calls the PSP, passing our idempotency key so the PSP deduplicates our retries too. When the PSP answers, a second transaction records the result: status change, ledger entries, saved response, and an outbox row. The relay publishes outbox rows to the event log, so the order service hears about the payment if and only if it was recorded, the pattern from distributed transactions: two-phase commit, sagas and the outbox. Webhooks from the PSP (asynchronous results, 3-D Secure completions, disputes) go through the same state machine, deduplicated by the PSP's event id. Every night, reconciliation compares the ledger with the PSP's settlement report.

AdvancedDeep dive: unknown outcomes and the payment state machine

The dangerous moment is between "we asked the PSP to charge the card" and "we recorded the answer". Each failure there has a specific, safe response:

What happenedWhat we knowSafe action
PSP returned success, we crashed before recording itpayment stuck in processing, key in_progressa recovery job finds old processing payments and asks the PSP for the result by our key, then records it
PSP call timed outnothing: the charge may or may not existretry with the same key (the PSP replays the result if it did happen), or query by key; never retry with a new key
PSP returned a declinedefinite failurerecord failed, release the key's result as the saved response
Webhook arrives before our own call returnsthe PSP's view is ahead of oursapply it through the state machine; the later direct response becomes a no-op
Same webhook delivered twiceduplicatededupe by event id; transitions are idempotent (succeeded → succeeded is ignored)

The state machine is the guard: transitions are only allowed forwards (processing → succeeded, never succeeded → processing), each is a compare-and-set on the payment's version, and each writes a payment_events row. A message that tries an illegal transition is logged and dropped, not applied. This is what makes at-least-once delivery from every direction (client retries, our retries, PSP webhooks) safe.

AdvancedDeep dive: the double-entry ledger

The ledger is the system's memory of money, so its rules are strict:

  • Append-only. No UPDATE or DELETE on entries. A mistake is fixed by a reversing transaction that mirrors the wrong one, then a correct one, so the history shows what happened and when.
  • Balanced. Every transaction's entries sum to zero per currency. Enforce it in the one function that writes entries (as post does) and check it again in the nightly job.
  • One currency per entry. A cross-currency payment is two transactions joined through an FX account, never a mixed sum.
  • Derived balances. A merchant's balance is the sum of their account's entries; any cached balance is checked against it.

The payment in the model, as a bookkeeper would write it:

txnaccountdebit (+)credit (−)
1psp_receivable (asset: the PSP owes us)4,999
1merchant:bookshop (liability: we owe the merchant)4,824
1fee_revenue (our income)175

When the PSP settles (pays us the money, minus its own fees), a new transaction moves the amount from psp_receivable to bank_cash and books the PSP's fee as an expense. When we pay the merchant out, another moves it from merchant:bookshop to bank_cash. A refund is a transaction in the opposite direction. At any moment, psp_receivable should equal what the PSP says it owes us, which is exactly what reconciliation checks.

AdvancedDeep dive: reconciliation

Idempotency prevents most errors; reconciliation finds the rest. Every day the PSP publishes a settlement report (a file or an API) listing every charge, refund and fee. The reconciliation job matches it line by line against the ledger:

reconcile.mjs
const mulberry32 = (a) => () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const rand = mulberry32(5);

// The last hour of yesterday: 1,000 card payments as our ledger recorded them (cents), and the PSP's settlement file.
const ours = Array.from({ length: 1000 }, (_, i) => ({ ref: `ch_${1000 + i}`, cents: 500 + Math.floor(rand() * 20_000),
  at: `23:${String(Math.floor(i / 17)).padStart(2, "0")}` }));
const theirs = ours.map((p) => ({ ref: p.ref, cents: p.cents }));

// What goes wrong in real life:
theirs.push({ ref: "ch_9001", cents: 3_150 });            // we crashed after the PSP charged the card
theirs.find((p) => p.ref === "ch_1400").cents -= 1;        // PSP captured a cent less (rounding in a currency conversion)
theirs.splice(theirs.findIndex((p) => p.ref === "ch_1999"), 1); // our last payment of the day lands in tomorrow's file
theirs.splice(theirs.findIndex((p) => p.ref === "ch_1250"), 1); // we booked a charge the PSP never captured

function reconcile(ours, theirs, cutoff = "23:58") {
  const byRef = new Map(theirs.map((p) => [p.ref, p]));
  const report = { matched: 0, amountMismatch: [], missingAtPsp: [], lateCheckTomorrow: [], missingInLedger: [] };
  for (const p of ours) {
    const t = byRef.get(p.ref);
    byRef.delete(p.ref);
    if (!t) (p.at >= cutoff ? report.lateCheckTomorrow : report.missingAtPsp).push(p.ref);
    else if (t.cents !== p.cents) report.amountMismatch.push(`${p.ref} ours ${p.cents} theirs ${t.cents}`);
    else report.matched++;
  }
  report.missingInLedger = [...byRef.keys()];             // charged at the PSP, unknown to us
  return report;
}

const sum = (xs) => xs.reduce((s, p) => s + p.cents, 0);
console.log(`ledger total ${sum(ours)}, settlement total ${sum(theirs)}, difference ${sum(ours) - sum(theirs)}`);
console.log(reconcile(ours, theirs));
Result
ledger total 10116231, settlement total 10108476, difference 7755
{
  matched: 997,
  amountMismatch: [ 'ch_1400 ours 17056 theirs 17055' ],
  missingAtPsp: [ 'ch_1250' ],
  lateCheckTomorrow: [ 'ch_1999' ],
  missingInLedger: [ 'ch_9001' ]
}
What’s happening
  1. The ledger has 1,000 payments from the last hour of the day; the settlement file starts as an exact copy. Four realistic faults are injected: a charge the PSP made that we never recorded (ch_9001, a crash after the charge), a capture one cent lower (ch_1400, a rounding difference in a currency conversion), our last payment of the day missing from the file (ch_1999, it falls into tomorrow's report) and a payment we booked that the PSP never captured (ch_1250).
  2. The totals already show something is wrong: a difference of 7755 cents. But a total can't say what, and errors can cancel out, so matching is done per transaction, by the PSP reference.
  3. reconcile indexes the PSP's lines by reference, walks our ledger and classifies each payment: 997 match exactly; ch_1400 is an amountMismatch (ours 17056 theirs 17055); ch_1250 is missingAtPsp.
  4. ch_1999 is also missing from the file, but it was booked at 23:58, after the cut-off, so it goes to lateCheckTomorrow rather than raising an alarm. Timing differences are the most common "break", and treating them as errors would bury the real ones.
  5. Whatever is left in the PSP's index was never seen in our ledger: ch_9001 is missingInLedger, a customer who was charged without our system knowing. That one is urgent: either record the payment (and fulfil the order) or refund it.
  6. In production each class becomes a work item: automatic fixes for known patterns (a late file, a known rounding rule), and a queue for finance to investigate the rest. Reconciliation is also run between our own systems: ledger against payments table, balances against entries.
AdvancedPayments: trade-offs and what to say

Trade-offs and what to say

DecisionChoiceCost
Duplicate protectionIdempotency key per payment attempt, claimed before the PSP call, passed to the PSP; one live or successful payment per orderA key store and a partial unique index; clients must reuse keys correctly
LedgerDouble-entry, append-only, integer minor unitsMore rows than a balance column; corrections are new entries
StorageOne relational DB for payments, ledger, keys and outboxVertical limits eventually; partition by time or account later
PSP callOutside any DB transaction; intent and outcome in separate transactionsA processing state and a recovery job
EventsTransactional outboxA relay process; consumers must be idempotent (at-least-once)
Safety netDaily reconciliation with the PSPFinance tooling and an exceptions workflow
The timeout retry

Your service calls the PSP to charge $49.99. After 10 seconds the call times out. What should the service do, and what must it not do?

Show answer

Retry (or query) with the same idempotency key, and record the outcome the PSP reports. It must not treat the timeout as a failure, and must not retry with a new key.

What’s happening
  1. A timeout means "no answer", not "no charge". The PSP may have charged the card and the response was lost on the way back.
  2. If the service marks the payment failed and the customer pays again, a successful first charge becomes a double charge. If it retries with a new key, the PSP sees a new request and charges again.
  3. Retrying with the same key is safe: if the first attempt happened, the PSP replays its saved result; if it didn't, the retry performs it once.
  4. Meanwhile the payment stays processing and the API tells the client so. If retries keep failing, the recovery job keeps asking, and reconciliation is the final check.

#Design a ticket booking system

Added

Ticket booking (Ticketmaster, a cinema chain, an airline seat map) looks like a simple inventory system, and for most events it is. The interview is about the other case: a stadium concert where two million people arrive in the same minute for fifty thousand seats. The data is tiny (one row per seat), the write rate for successful sales is modest, and yet systems fall over, because every one of those people hammers the same few rows at once. Ticketmaster said its November 2022 Eras Tour presale drew 3.5 billion total system requests, four times its previous peak, driven partly by bots.

The mental model is a small shop with a bouncer and a cloakroom. The bouncer (a virtual waiting room) keeps the crowd outside and lets people in at the rate the shop can serve them. Inside, picking a seat gives you a cloakroom ticket with a timer (a hold): the seat is yours for 10 minutes while you pay, and if you walk away the timer returns it to the shelf. The hard rule is that two people can never hold or buy the same seat, which comes down to one operation done atomically per seat: "if this seat is still free, it's mine".

The classic bug is to check and then write as two steps: read the seat, see it's free, write the hold. Between the read and the write another buyer can do the same, and both believe they hold it. This model runs the same on-sale twice with a fake clock: 400 buyers rush 100 seats in the first second, 200 more arrive in minutes 10–15, writes land 5–50 ms after the read, holds last 10 minutes, and 30% of holders walk away. The first run checks then writes; the second uses a compare-and-set on a per-seat version:

seat-holds.mjs
const mulberry32 = (a) => () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const HOLD = 10 * 60_000;                               // a hold lasts 10 minutes (fake clock, in ms)

function simulate(useCas) {
  const rand = mulberry32(11);                          // seeded: both runs start from the same buyers
  const seats = Array.from({ length: 100 }, () => ({ state: "free", holder: null, expires: 0, version: 0 }));
  const events = []; let seq = 0;
  const at = (t, fn) => events.push({ t, seq: seq++, fn });
  const st = { holds: 0, conflicts: 0, reclaimed: 0, lateToPay: 0, sold: new Map() };

  function tryHold(buyer, tries, now) {
    if (tries === 0) return;
    const s = Math.floor(rand() * rand() * 100);        // everyone prefers the front rows
    const seen = { ...seats[s] };                       // 1. read the seat
    const free = seen.state === "free" || (seen.state === "held" && seen.expires <= now);
    if (!free) return at(now + 20, (t) => tryHold(buyer, tries - 1, t));
    at(now + 5 + rand() * 45, (t) => {                  // 2. the write lands 5–50 ms later
      const seat = seats[s];
      if (useCas && seat.version !== seen.version) {    // compare-and-set: changed since I read it?
        st.conflicts++;
        return at(t + 20, (t2) => tryHold(buyer, tries - 1, t2));
      }
      if (seen.state === "held") st.reclaimed++;        // took over an expired hold
      Object.assign(seat, { state: "held", holder: buyer, expires: t + HOLD, version: seat.version + 1 });
      st.holds++;
      if (rand() < 0.7) at(t + 60_000 + rand() * 600_000, (t2) => pay(s, buyer, t2)); // 30% walk away
    });
  }
  function pay(s, buyer, now) {
    const seat = seats[s];
    if (useCas && !(seat.state === "held" && seat.holder === buyer && seat.expires > now)) return st.lateToPay++;
    Object.assign(seat, { state: "sold", holder: buyer, version: seat.version + 1 });
    st.sold.set(s, (st.sold.get(s) ?? 0) + 1);
  }

  for (let b = 0; b < 400; b++) at(rand() * 1_000, (t) => tryHold(b, 3, t));                // on-sale rush
  for (let b = 400; b < 600; b++) at(600_000 + rand() * 300_000, (t) => tryHold(b, 3, t));  // minutes 10–15
  while (events.length) {
    events.sort((a, b) => a.t - b.t || a.seq - b.seq);
    const e = events.shift();
    e.fn(e.t);
  }
  const twice = [...st.sold.values()].filter((n) => n > 1).length;
  return [st.holds, st.conflicts, st.reclaimed, st.lateToPay, st.sold.size, twice];
}
const naive = simulate(false), cas = simulate(true);
const rows = ["holds granted", "conflicts caught", "expired holds reused", "paid after hold expired", "seats sold", "seats sold twice"];
console.log("                         read then write   compare-and-set");
rows.forEach((r, i) => console.log(`${r.padEnd(25)} ${String(naive[i]).padStart(15)} ${String(cas[i]).padStart(17)}`));
Result
                         read then write   compare-and-set
holds granted                         141               135
conflicts caught                        0                19
expired holds reused                   28                41
paid after hold expired                 0                20
seats sold                             78                76
seats sold twice                        9                 0
What’s happening
  1. Each attempt is two events on the fake clock: a read of the seat now, and a write 5–50 ms later (at(now + 5 + rand() * 45, …)), which is how long a real request takes to reach the database after the app read the row. Buyers prefer the front rows (rand() * rand() skews towards seat 0), so many target the same seats. Everything runs from one seed, so the output is the same every time; the two runs diverge once their decisions differ.
  2. Read then write grants 141 holds for 100 seats with 0 conflicts caught: whenever two buyers read the same free seat within those milliseconds, both writes go through and the second silently overwrites the first. Both buyers think they hold the seat, both pay, and 9 seats are sold twice. That's nine customers with a ticket for a seat someone else is sitting in.
  3. Compare-and-set writes the hold only if the seat's version is still the one it read. When another buyer's write landed first, the version has moved, so the write is refused (19 conflicts caught) and the buyer retries another seat. Every hold is exclusive, and 0 seats are sold twice.
  4. Expiry is checked lazily: a hold whose expires time has passed counts as free on the next read, and no background job is needed for correctness. With compare-and-set, 41 holds were taken over after their 10 minutes ran out, by the second wave of buyers arriving in minutes 10–15.
  5. 20 payments came in after their hold had expired and were rejected (payments in the model take 1 to 11 minutes). With compare-and-set the payment itself is a check, "is this seat still held by me and not expired?", so a slow payer can't buy a seat that was released and taken by someone else. The read-then-write run shows 0 here only because it never checks; it sells regardless. The deep dive fixes the slow-payer case properly by extending the hold when payment starts.
  6. 76 seats sold with compare-and-set; the other 24 are back on sale or in holds that expired after the last wave. That's the honest outcome: correct, and the unsold seats go to the next buyers rather than to two at once.
AdvancedTicket booking: requirements

Requirements

Functional:

  • Browse events and see a seat map with live availability (sections, rows, seats, prices).
  • Select seats and hold them for a fixed time (10 minutes) while paying.
  • Pay and receive tickets; release seats when holds expire or are cancelled.
  • For big on-sales: a waiting room that admits fans fairly, purchase limits per person, and bot resistance.

Non-functional:

  • Never double-book a seat: strong consistency for holds and sales, per seat.
  • Survive the on-sale spike: millions of concurrent fans for one event, without the booking path collapsing for everyone else.
  • Fairness and honesty: people should be admitted in a defensible order and told quickly when it's sold out, not left refreshing.
  • The seat map may be a few seconds stale; the hold is the source of truth (a seat shown as free can turn out to be taken, and the UI says so).
  • Availability: other events' sales must not be affected by one hot event.
AdvancedTicket booking: estimates

Estimates

ticket-estimates.mjs
// Assumptions for one stadium on-sale.
const seats = 50_000;
const ticketsPerOrder = 3;
const fans = 2_000_000;          // people waiting when the sale opens
const pollEverySec = 30;         // waiting-room page asks "is it my turn?" every 30 s
const shopperReqPerSec = 0.5;    // seat map, holds, checkout: one request every 2 s while shopping
const minutesToCheckout = 4;
const buyRate = 0.7;             // share of admitted fans who complete an order
const admitPerMin = 2_000;       // admission rate the booking service is sized for

const orders = seats / ticketsPerOrder;
const sellOutMin = orders / (admitPerMin * buyRate);
const shoppers = admitPerMin * minutesToCheckout;            // Little's law: arrival rate × time inside
console.log(`orders to sell out:     ${Math.round(orders).toLocaleString("en")}`);
console.log(`sell-out time:          ${sellOutMin.toFixed(0)} min at ${admitPerMin.toLocaleString("en")} admissions/min`);
console.log(`fans who get in:        ${Math.round(sellOutMin * admitPerMin).toLocaleString("en")} of ${fans.toLocaleString("en")} ` +
  `(${((sellOutMin * admitPerMin * 100) / fans).toFixed(1)}%)`);
console.log(`waiting-room polls:     ${Math.round(fans / pollEverySec).toLocaleString("en")}/s (static, cacheable)`);
console.log(`booking service:        ${Math.round(shoppers * shopperReqPerSec).toLocaleString("en")} req/s from ${shoppers.toLocaleString("en")} shoppers inside`);
console.log(`with no waiting room:   ${Math.round(fans * shopperReqPerSec).toLocaleString("en")} req/s on the booking service`);
console.log(`contention:             ${Math.round(fans / seats)} fans per seat`);
Result
orders to sell out:     16,667
sell-out time:          12 min at 2,000 admissions/min
fans who get in:        23,810 of 2,000,000 (1.2%)
waiting-room polls:     66,667/s (static, cacheable)
booking service:        4,000 req/s from 8,000 shoppers inside
with no waiting room:   1,000,000 req/s on the booking service
contention:             40 fans per seat
What’s happening
  1. 50,000 seats at 3 tickets per order is 16,667 orders to sell out. That's all the successful work there is: a few tens of thousands of transactions, which one database handles easily.
  2. If the booking service admits 2,000 fans a minute and 70% of them buy, the show sells out in about 12 minutes. Only 23,810 of 2 million fans (1.2%) ever get inside. The system's job for the other 98.8% is to queue them cheaply and tell them the truth early.
  3. Waiting fans poll "is it my turn?" every 30 s: 66,667 requests a second, but they are tiny, identical in shape and served by the edge (a CDN worker or the waiting-room service) without touching the booking database.
  4. Inside, Little's law gives 8,000 concurrent shoppers (2,000 a minute × 4 minutes each), making 4,000 requests a second (seat map, holds, checkout). That's the load to size the booking service and the inventory database for.
  5. With no waiting room, all 2 million would be shopping at once: 1,000,000 requests a second on the booking service, 250 times the planned load, for the same 50,000 seats. The waiting room doesn't sell more tickets; it turns an outage into a queue.
  6. 40 fans per seat is the contention that makes "check then write" fail: many buyers target the same seat within milliseconds.
AdvancedTicket booking: API sketch

API

API sketch
GET  /v1/events/{eventId}/seats?section=112          -> { seats: [ { id: "112-F-7", status: "free", price } ], asOf }
POST /v1/events/{eventId}/holds    Authorization: admission token
  { seatIds: ["112-F-7", "112-F-8"] }
  -> 201 { holdId, expiresAt }      all seats held, or none
  -> 409 { unavailable: ["112-F-8"] }
POST /v1/holds/{holdId}/checkout   Idempotency-Key: …   { paymentMethod }
  -> 200 { orderId, tickets }   |   410 hold expired
DELETE /v1/holds/{holdId}

GET  /queue/{eventId}/status        (waiting room, served at the edge)
  -> { position: 183402, estimatedWaitSec: 1500 }   or   { admitted: true, admissionToken }

A hold covers all requested seats or none: two friends want seats together, so holding one of them is worse than failing. Checkout is idempotent because the buyer will click "Pay" twice and the payment provider may time out; it uses the patterns of the payment system. The admission token is signed and short-lived, binds the fan's session to the event, and is checked by the booking API, so nobody can skip the queue by calling the API directly.

AdvancedTicket booking: data model

Data model

TableColumnsNotes
eventsid, venue_id, starts_at, on_sale_at, max_tickets_per_buyer
seatsevent_id, seat_id, section, row, number, price_tier, status, hold_id, hold_expires_at, order_id, versionprimary key (event_id, seat_id); one event's seats live on one shard
holdsid, event_id, buyer_id, seat_ids, expires_at, statusstatus: active → checked_out | expired | cancelled
ordersid, buyer_id, event_id, seat_ids, payment_id, created_atan order_seats table, (order_id, event_id, seat_id, status), with one valid ticket per seat as a last guard against double sale (below)

The last guard needs care. A plain UNIQUE (event_id, seat_id) on order_seats means a seat can be sold only once, ever, so a refunded or cancelled ticket could never be resold. Put the uniqueness on valid tickets only: each order_seats row has a status (valid, refunded, cancelled), and the unique index is partial, CREATE UNIQUE INDEX one_valid_ticket_per_seat ON order_seats (event_id, seat_id) WHERE status = 'valid' in PostgreSQL (MySQL has no partial indexes; use the generated-column version from the payment system's data model). A refund sets the row to refunded, the seat goes back on sale, and the next buyer's row is the only valid one. Adding status to the key instead, UNIQUE (event_id, seat_id, status), works only until the same seat is refunded a second time.

Putting all of one event's seats in one partition keeps a multi-seat hold inside one database transaction. At 4,000 requests a second for the hottest event (most of them cached seat-map reads), one partition is enough, because the waiting room limits the rate; without it, no partitioning scheme would help, since the contention is on the same rows.

AdvancedTicket booking: architecture diagram

High-level design

Fans 2 M at on-sale Waiting room at the edge · tokens Booking API token required Inventory service holds · sales Seat map cache seconds stale Seats DB one partition per event Payment system idempotent charge Expiry sweeper delay queue 2,000/min seat map compare-and-set changes checkout release expired
The waiting room turns millions of fans into a steady trickle; inside, every hold and sale is a compare-and-set on the seat's row.

Walk through a fan's path. They open the event page and land in the waiting room, which is served from the edge and costs almost nothing per fan. When their turn comes, they get a signed admission token valid for, say, 15 minutes. With it they load the seat map (from a cache refreshed from the seats database every second or two) and request a hold. The inventory service runs the compare-and-set on the seats' rows; on success the fan has 10 minutes to pay. Checkout extends the hold, calls the payment system with an idempotency key, and on success marks the seats sold in the same compare-and-set style. Expired holds are free as soon as their time passes (the check is part of every hold attempt), and a sweeper driven by a delay queue releases them in the seat map so other fans can see them.

AdvancedDeep dive: holds without double booking

Three ways to implement "if this seat is free, it's mine", all correct if done atomically:

1. A conditional update in the database (the compare-and-set, with the condition in the WHERE clause):

SQL
UPDATE seats
SET status = 'held', hold_id = ?, hold_expires_at = NOW() + INTERVAL 10 MINUTE, version = version + 1
WHERE event_id = ? AND seat_id IN (?, ?)
  AND (status = 'free' OR (status = 'held' AND hold_expires_at < NOW()));
-- In the same transaction: if the affected row count is not 2, ROLLBACK (one seat was taken: hold none).

The database evaluates the condition and writes the row under the row's lock, so two buyers can't both pass it. Checking the affected row count gives "all seats or none". This is the simplest correct answer, and it's what I'd lead with. (This statement is written for MySQL and wasn't run for this page; it shows the shape of the query.)

2. Pessimistic locking: SELECT … FROM seats WHERE … FOR UPDATE, check in the application, then UPDATE, then commit. Correct, but the locks are held across a round trip to the application, so under heavy contention buyers queue on the locks; keep transactions very short and lock seats in a fixed order (by seat_id) to avoid deadlocks between two multi-seat holds.

3. A hold key in Redis: SET hold:evt42:112-F-7 buyer123 NX PX 600000 sets the key only if it doesn't exist, with a 10-minute expiry built in. It's fast and expiry is automatic, which makes it attractive for the hold itself. But Redis is now a second source of truth: the sale must still be recorded in the database with its own guard (the one-valid-ticket-per-seat index on order seats), and a Redis failover can lose recent keys, so a hold could vanish. Use it to absorb hold traffic, never as the only record of a sale.

Expiry needs no timer for correctness: a hold is free once hold_expires_at < NOW(), and the next attempt can take it. A sweeper (a delay queue or a scheduled scan of holds by expires_at, see scheduled and delayed jobs) only cleans up the rows and refreshes the seat map.

The slow payer, the paid after hold expired row in the model, needs a decision. Options: extend the hold (for example by 5 minutes, once) when the buyer enters payment, so that a payment in progress never races the timer; or charge first and confirm the seats second, refunding automatically if the confirm fails. The first is kinder; the second must exist anyway as the fallback, because a payment can still finish after the extension.

AdvancedDeep dive: the waiting room and fairness

The waiting room is a queue in front of the whole booking path. Fans arrive and get a position; the room admits them at a configured rate (2,000 a minute in the estimate), chosen from what the booking service and the inventory database can sustain, the same reasoning as backpressure and load shedding. It runs at the edge (a CDN worker or a dedicated, horizontally scaled service) because it has to absorb the full crowd, and because a position check is a tiny, cacheable request.

What order to admit people in is a product decision you should name. First in, first out rewards arriving early, which leads people to open many tabs and devices long before the start. Random admission among everyone who arrived before the on-sale time, then first in, first out for later arrivals, removes the incentive to arrive early. Commercial waiting rooms offer both; Cloudflare's Waiting Room, for example, documents FIFO and random queueing methods (checked October 2026). Either way, tell people their position and an estimated wait, and when the inventory runs out, tell everyone still waiting immediately: with 2 million fans for 50,000 seats, 98.8% of them won't get in, and an honest "sold out" is better than a spinning page.

Bots are the other half of fairness: they join the queue many times and buy at machine speed. Mitigations, at design level: identity-based pre-registration for big events, one queue position per verified account, purchase limits per account and per card (max_tickets_per_buyer), rate limits per client (rate limiting algorithms), and bot detection at the edge.

AdvancedDeep dive: general admission and other hot counters

Some events have no seat numbers, only a count: 5,000 general-admission tickets. Then the hold is a decrement of one counter, which every buyer writes, the purest hot spot:

SQL
UPDATE ticket_pools SET remaining = remaining - ? WHERE pool_id = ? AND remaining >= ?;
-- 1 row affected: you got them. 0 rows: not enough left.

That's still a correct compare-and-set, but every purchase serialises on one row. If that row becomes the bottleneck, split the pool: ten rows of 500 tickets each, and a buyer is routed to a random pool with stock left (falling back to the others when it's empty). Ten rows take ten times the write rate, at the price of a little complexity near the end of the sale when some pools are empty and others aren't. With a waiting room limiting the arrival rate, a single row is usually enough; the split is the answer when the interviewer pushes the numbers up.

AdvancedTicket booking: trade-offs and what to say

Trade-offs and what to say

DecisionChoiceCost
AdmissionWaiting room at the edge, fixed admission rateFans wait; the rate must be tuned to measured capacity
HoldConditional update (compare-and-set) per seat, all-or-nothingContention on popular seats; buyers see "just taken" and retry
Hold expiryLazy check on read, sweeper for cleanupSeat map lags by seconds
Hold storeDatabase rows (Redis optional for holds only)Database does the work; Redis adds speed but a second source of truth
PartitioningAll seats of an event in one partitionA hot event's partition is hot; acceptable because admission is limited
Slow payersExtend the hold at payment, refund if confirm failsSeats stay unavailable longer; refunds must be automatic
Two friends, one seat each

Two buyers each request a hold on seats F-7 and F-8 at the same moment, using the conditional update with the affected-row check. Can each end up with one of the two seats?

Show answer

No. One gets both seats; the other gets none and a 409.

What’s happening
  1. Buyer A's statement locks and updates the rows that pass the condition. If it reaches both rows first, both are updated (2 rows affected), and it commits: A holds F-7 and F-8.
  2. Buyer B's statement waits for A's row locks, then re-checks the condition. Both seats are now held with a future expiry, so 0 rows are affected, B rolls back and returns 409 { unavailable: ["F-7", "F-8"] }.
  3. Could A take F-7 while B takes F-8? Not with one statement each in MySQL's InnoDB: it reads an IN list on the primary key in key order, so both statements visit F-7 before F-8, and B blocks on F-7 before it reaches F-8. That was checked on this site's sandbox (MySQL 9.7.2, October 2026): with F-8 already locked by another transaction, UPDATE … WHERE seat_id IN ('F-8', 'F-7') held F-7 and waited on F-8 in performance_schema.data_locks, so it locked F-7 first although it was listed second. This is InnoDB's behaviour, not a guarantee of SQL: another database, or a different query plan, can visit the rows in another order. To be safe everywhere, lock the seats explicitly in seat order first (SELECT … ORDER BY seat_id FOR UPDATE), as in the pessimistic option above. It could happen if the holds were written as separate statements in different orders; then each buyer gets one row, one of them deadlocks or finds its second seat taken, its affected-row count is less than 2, and it rolls back, releasing its seat to the other.
  4. The all-or-nothing check is what prevents the half-and-half result. Without it, A would keep F-7 and B would keep F-8, and neither would get two seats together.

Written fresh for interviews (there was no Google Doc for this one). Runnable models were run on Node 26; figures and limits were checked against sources in October 2026.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.