SysDesignPrep.com
Interviewer kit

Design Strava

Run this for someone else. You hold the answers; they do not. Read the prompt, keep the clock, and use the probes below when an answer is thin. Do not show them this page.

The candidate should have a blank page and a whiteboard, not this.

Open with this

Record runs and rides offline, process each upload into a map, stats and segment efforts, and rank them for 100 M athletes. Take a couple of minutes on requirements, then we will do some numbers, then the design. I will interrupt to keep us moving.

The clock

  • 4 min: functional requirements and scope
  • 4 min: non-functional requirements, with numbers
  • 5 min: back-of-envelope estimates
  • 16 min: high-level design and one or two flows
  • 16 min: deep dives and the close

Move them on out loud when a section overruns. The commonest failure is spending twenty minutes on requirements and never reaching a deep dive, and preventing that is your job as much as theirs.

Requirements · 8 min

Listen for: a scoped set of capabilities, an explicit out-of-scope list, and numeric targets rather than adjectives. Prompt with “what are you not building?” if they never scope, and “what number would make that requirement real?” if they say “fast” or “highly available”.

Functional (9)
  • Record a run or ride: GPS once a second plus heart rate, cadence and power where a sensor exists, on a phone or a watch, with no network at all. The device holds the only copy until the server has it.
  • Upload and process the activity: The finished activity goes up as one compressed file, is acknowledged once stored, and is then processed into a map, distance, pace, splits and elevation. Retrying an upload never creates a second activity.
  • View an activity: Map, stats, splits, segment efforts and achievements. The owner sees their own numbers within seconds, even on a Saturday morning.
  • Follow athletes and see their activities in a feed: Friends’ activities, newest first, with a map thumbnail. The feed is the standard fan-out pattern; what is specific here is that it must not wait for segment results.
  • Segments and efforts: A segment is a stretch of road or trail that athletes create. Every new activity is matched against every segment it completed, producing one effort (an elapsed time) per pass.
  • Segment leaderboards: Each athlete’s best effort per segment, ranked. Views by this year, gender, age group and people you follow; the top 10 plus your own rank with neighbours.
  • Privacy controls: Privacy zones hide any part of an activity near home or work. Visibility is everyone, followers or only me, and only public activities count on public leaderboards.
  • Live tracking for safety: Optional: share a live position with chosen contacts during the activity (Strava calls this Beacon). Best effort, and independent of the recording.
  • Out of scope: Route planning, heatmaps, training analytics, clubs and challenges, kudos and comments, subscriptions, and building the device-partner sync itself (partner uploads arrive through the same upload API).
Non-functional (7)
  • Scale (100 M athletes · 40 M uploads/week): Not uniform: weekend mornings run about 5× the average for three hours. Tens of millions of segments, a few with millions of efforts.
  • Never lose or duplicate an activity (0 lost · 0 duplicated): Losing a marathon is the failure athletes never forgive. Mobile networks at the end of a run are poor, so every upload is retried, and a retry must land on the same activity.
  • Upload acknowledgement (p99 < 1 s after the last byte): The acknowledgement means "your file is stored durably", nothing more. Processing never sits inside the request.
  • Time to see your own activity (stats p95 < 10 s · segments p95 < 2 min): At the weekend peak too. This drives the hardest tradeoff: splitting processing into lanes so the athlete’s own view never waits behind shared systems like the segment index and the leaderboard database.
  • Location privacy (no zone point ever shared): No surface other than the owner’s own view may show a point inside a privacy zone: maps, feed thumbnails, segment efforts, the public API, embeds, aggregates. One leak exposes where someone lives.
  • Leaderboard reads (p99 < 200 ms): Top 10, your rank and your neighbours, even on a board with 400 k athletes. The board must count only each athlete’s best and be rebuildable from the efforts.
  • Recording availability (works with no network): Recording never depends on the server. Live tracking is best effort and may drop points; the recorded file may not.

Estimates · 5 min

Ask for two or three numbers, not all of them. What matters is whether they state assumptions, round sensibly, and say what the number implies. Push once with “where did that come from?”

The numbers (8)
  • Uploads per second: ~66 avg · ~330 peak. 40 M uploads a week ÷ 604,800 s ≈ 66/s. Weekend mornings run about 5× the average as Europe, then the Americas, finish their long runs and rides: ~330/s. Small in absolute terms; the shape (a predictable 5× step for three hours) is what the design has to handle.
  • Size of one activity file: 3,600 points · ~300 KB. An average activity is about an hour at one point a second: 3,600 points. Each carries time, latitude, longitude, altitude and, from a watch, heart rate, cadence and power: ~80 B in a compact binary format, so ~290 KB. Budget 300 KB per upload; a long ride is a few MB.
  • Raw file storage: ~0.6 PB/year. 40 M × 52 ≈ 2.1 B activities a year × 300 KB ≈ 620 TB/yr of raw files, kept forever because every derived result is recomputed from them. Processed streams (the owner’s full copy and a trimmed display copy) roughly double it. Peak ingest is only 330 × 300 KB ≈ 100 MB/s: bandwidth is not the problem.
  • Processing CPU at peak: ~330 cores. Parsing, cleaning, elevation correction against a terrain model, stats, splits and rendering the map image take roughly 1 s of CPU per activity. 330/s × 1 s = 330 busy cores at peak against ~70 on average. A small fleet: the risk is that it must grow 5× within an hour, not what it costs.
  • Candidate segments per activity: ~1,000 → ~50 → ~25 ms. A 15 km track, buffered by 50 m, covers about 40 S2 level-13 cells (~1 km across). Active areas have ~25 segment start points per cell: ~1,000 candidates. Checking that the track passes near both the start and the end keeps ~50, and the precise match costs ~0.5 ms each: ~25 ms per activity. Brute force would test 30,000× as many segments.
  • Efforts written: ~3,300/s at peak. A typical activity completes ~10 segments (city routes far more, rural ones none). 330/s × 10 = 3,300 efforts/s at peak, ~660/s on average. A year is ~21 B efforts × ~100 B ≈ 2 TB. Only an effort that beats the athlete’s own best changes a leaderboard, a small fraction of these.
  • Leaderboard storage: ~5 B rows · ~500 GB. 30 M segments, but a board holds one row per athlete, not per effort, so a commuter who rides a segment 500 times a year is one row. Across the all-time and this-year windows that is ~5 B rows; at ~100 B with indexes, ~500 GB. Most boards are tiny (median under 100 athletes); the largest have ~400 k athletes and millions of efforts behind them.
  • Activities in progress at peak: ~1.2 M. Little’s law: 330 activities finishing per second × ~3,600 s average duration ≈ 1.2 M being recorded at once. If 10 % share live tracking at one point per 15 s, that is 120 k ÷ 15 ≈ 8 k points/s: tiny next to a ride-hailing location stream, and nothing queries it by place.

High-level design · 16 min

Let them draw. Interrupt only to ask what backs a component or what a box actually does. Then pick one flow below and ask them to walk it end to end.

Components (15)
  • Athlete app (phone · watch · offline-first): Records GPS once a second plus sensor data into a local file under an activity id it generates at Start, so recording never needs a network. A local outbox uploads the finished file with retries and keeps it until the server reports it processed. Sends a separate low-rate position stream only when live tracking is on.
  • API gateway (auth · rate limits · routing): Terminates TLS, authenticates athletes and partner apps, rate limits per token and routes to the services. Stateless. Caps request bodies so a 300 KB run and a 5 MB multi-day ride pass but a 1 GB file does not.
  • Activity service (idempotent PUT by activity id): Accepts a file at PUT /activities/{activity_id}, writes it to object storage, creates the activity row and enqueues processing, then acknowledges. The device-generated id makes every retry land on the same row. Also serves the activity page, checking visibility on every read and returning the trimmed track to anyone but the owner.
  • Activity files (object storage · raw + streams): Immutable raw uploads, kept forever as the input to every reprocessing and readable only by the processor and the owner’s export. Processed streams are stored as two objects: the owner’s full track and the trimmed display track, which is the only one any shared surface can read. ~0.6 PB of raw files a year.
  • Fast lane (Kafka · key = activity_id): Processing jobs the athlete is waiting for: parse, clean, trim, stats. Its workers are scaled up on a schedule before the weekend peak and autoscaled on the age of the oldest job, target under 5 s. Bulk imports and reprocessing never enter this lane, so a 2,000-activity history import cannot delay anyone’s Saturday run.
  • Activity store (sharded by athlete_id): Activity rows (status, stats, summary polyline, visibility, processing version, the effort list with ranks) plus each athlete’s settings and privacy zones, co-located by athlete. Writes from processing are conditional on the processing version, so a repeated job cannot overwrite newer output. ~2 B rows a year at ~2 KB.
  • Activity processor (parse · clean · trim · stats): Turns the raw file into an activity: parse, drop GPS spikes, smooth, detect pauses, correct elevation, apply privacy zones, compute stats and splits, render the map image. Deterministic given the file, the athlete’s settings and the processing version, which is what makes reruns safe. It does not snap the display track to roads: trails are not on the road graph and athletes want their own line.
  • Segment lane (allowed to lag at peak): Segment-matching jobs, published once the trimmed track exists. It may run a minute or two behind on a Saturday morning, because segments depend on shared systems that must never delay the athlete’s own stats. A separate background topic for backfills and reprocessing is read only when this lane is empty.
  • Segment matcher (in-memory S2 start-cell index): Finds every segment the trimmed track completed. Holds an index of all 30 M segment start points by S2 level-13 cell (~2 GB) in memory, so candidates cost hash lookups rather than database queries; prefilters on the end point; then walks each candidate’s polyline in order with a tolerance. ~25 ms per activity; one effort per completed pass.
  • Segment store (polylines · versions): Segment definitions: polyline, length, grade, version and state (active, flagged, hazardous), ~30 M at ~1 KB each. A nightly snapshot plus a change stream keep every matcher’s in-memory index current. Editing a polyline bumps the version and queues a background re-match of that segment’s efforts.
  • Leaderboard service (best-only writes · views): The only writer of efforts and best-effort rows, and the reader of every board. Records an effort and lowers the athlete’s best for each window only if the new time beats it, in one transaction on the segment’s shard. Serves the top 10, the viewer’s rank and neighbours, filtered views, and the following view computed on read.
  • Efforts DB (sharded SQL · by segment_id): Two tables on the same shard: every effort (~21 B a year) and best_efforts, one row per (segment, period, athlete) carrying gender and age bracket. Ordered indexes on (segment, period, elapsed) and (segment, period, gender, age bracket, elapsed) make each shared view an index prefix. Sharding by segment keeps an effort insert and its best-row update in one local transaction.
  • Leaderboard cache (Redis · top-N · histograms): The top 10 per (segment, view), rewritten only when a new best enters it. For boards above ~10 k athletes, a count of athletes per elapsed second (~12 KB a view) that turns "how many are faster than me" into one small read. Derived entirely from the Efforts DB and rebuilt nightly for the big boards.
  • Feed service (follow graph · fan-out): Owns the follow graph and fans each processed activity id out to followers’ feeds: fan-out on write, with fan-out on read for athletes with huge followings (the News Feed pattern). Stores ids, not copies, so segment results and edits appear without a second fan-out. Respects visibility: followers-only activities reach followers, only-me activities reach nobody.
  • Live tracking (Redis · TTL · 1 point / 15 s): Receives one position and battery level every 15 s per session and keeps the last hour in a capped Redis list with a TTL; safety contacts poll it through an unguessable link. Lossy by design: a missed point is superseded by the next one. Separate from recording and upload, so it can fail without touching the activity.
Flows to ask them to walk (5)
  1. Finish a run offline and upload it: The core write. The phone holds the only copy of the run, so the server’s single job inside the request is to make that copy durable and say so; everything else happens after the acknowledgement.
    1. The app records a point every second into a local file under an activity id it generated at Start.
    2. On Save, the app compresses the file and PUTs it to the Activity service at a URL containing that id.
    3. The service writes the file to object storage under a key derived from the athlete and the activity id.
    4. It inserts the activity row in state received; an id that already exists inserts nothing.
    5. It enqueues a processing job on the fast lane, keyed by activity id.
    6. The service returns 202; the app marks the upload done but keeps its copy until the activity is processed.
  2. Saturday morning: stats first, segments later: The asynchronous path at the weekend peak of ~330 uploads a second. The athlete’s own view depends only on their file; segments depend on shared systems and are allowed to lag by a minute or two.
    1. A fast-lane worker takes the job and reads the raw file.
    2. The worker cleans the track: drops spikes, smooths, detects pauses and corrects elevation.
    3. It loads the athlete’s privacy zones and cuts every point inside them from the display track.
    4. It computes stats on the full track and writes both streams and the processed activity.
    5. It publishes activity.processed for the feed and a job on the segment lane.
    6. The athlete opens the app and sees the map, distance, pace and splits.
    7. A matcher takes the segment job and finds candidate segments from its in-memory index of start points.
    8. The leaderboard service records each completed pass and lowers bests only when beaten; the matcher stores the results on the activity.
    9. A follower opens their feed: activity ids come back and are hydrated with the latest stats and segment results.
  3. Open the leaderboard of a hot segment: The main read, at its hardest: a park loop with 2 M efforts from 400 k athletes, opened ~50 times a second on a Saturday. The top 10, a rank of 237,000, a filtered view and the people you follow, each in milliseconds.
    1. The app opens the segment page and asks for the overall board around the viewer.
    2. The top 10 is read from the cache, which changes only when a new best enters it.
    3. The viewer’s best time comes from their best-effort row; their rank comes from the board’s histogram.
    4. Neighbours above and below come from two short index scans around the viewer’s time.
    5. The viewer taps "Women, 35–44, this year": the same three reads run against that view’s index prefix and histogram.
    6. The following tab fetches the viewer’s follow list and looks up just those athletes’ best rows.
  4. A lost acknowledgement and a crashed worker: The two classic duplicate makers: a phone that never saw the 202 uploads again, and a worker that dies halfway has its job redelivered. Neither may produce a second activity or a second effort.
    1. The first upload stores the file and the row, but the 202 is lost as the phone leaves coverage.
    2. The app’s outbox retries with backoff, sending the same id and the same bytes.
    3. The service finds the existing row with the same hash and returns its status without creating or enqueueing anything.
    4. Meanwhile a processor crashed after writing the stats but before publishing; the job is redelivered to another worker.
    5. The second worker recomputes from the immutable file; its conditional write finds version 7 already stored and changes nothing.
    6. It publishes the feed event and the segment job again; every consumer drops the duplicate by key.
  5. Live tracking for a safety contact: A low-rate, lossy stream beside the recording, never instead of it. The contact sees where the athlete is every 15 seconds; the activity itself still comes from the uploaded file.
    1. The athlete starts a run with live tracking on; the app opens a session and texts a link to their safety contacts.
    2. Every 15 s the app sends its latest position and battery level, while recording continues locally at one point a second.
    3. The Live service appends the point to a capped list that expires.
    4. The contact opens the link and their browser polls for new points every 15 s.
    5. At Save the session ends and the uploaded file becomes the activity; the live trail expires.

Deep dives · 16 min

Pick two. Ask the headline question, let them answer, then use the follow-ups. The follow-ups are where the level gets decided, so leave time for at least three of them.

Upload first, process later

Ask: Why not process the activity inside the upload request and return the finished activity?

Good answers name: Store the raw file, acknowledge, process asynchronously; everything keyed by a device-generated activity id, Process inline and return the finished activity, Presigned direct upload to object storage, Server-generated id from a create call before upload.

Our pick: The app generates a UUIDv7 at Start and writes the activity to a local file. At Save it sends PUT /v1/activities/{activity_id}/file with the compressed file and its SHA-256. The Activity service writes the object to raw/{athlete_id}/{activity_id} (a retry overwrites identical bytes), inserts the row with ON CONFLICT DO NOTHING on the activity id, enqueues a fast-lane job keyed by the id, and returns 202. Same id with the same hash returns the current status; same id with a different hash is a 409. A sweeper re-enqueues rows stuck in received for 5 minutes, and a lifecycle rule deletes objects that never got a row. Processing writes are conditional on the processing version and every downstream key (stream objects, feed entries, effort ids) derives from the activity id, so a duplicate job is wasted CPU, never a duplicate activity. The app keeps its copy until the status reaches processed.

  1. The same run arrives twice with different ids: once from the watch’s own sync and once from the phone that also recorded it. What happens?
    Id-based idempotency cannot catch this, because they really are two recordings. After stage one, a duplicate check looks for the athlete’s activities whose start times are within a minute and whose tracks overlap by more than, say, 80 %. The later one is marked a probable duplicate and hidden, keeping the recording with more sensor data, and the athlete is told and can undo it. Partner uploads also carry the partner’s own file id, which catches the same partner resending.
  2. The phone died mid-run and the file is truncated. Do you reject it?
    No. The recorder flushes every 10 s and the binary format is a sequence of self-delimiting records, so the parser keeps every complete record and drops the torn tail. The athlete loses at most the last 10 s. Only a file with nothing parseable is marked failed, with a reason, and the raw bytes are kept so a parser fix can reprocess it.
  3. You ship a better GPS cleaner. How do you apply it to ten billion old activities?
    Bump the processing version and feed old activities into the background lane, newest and public first. At ~1 s each that is ~10 B CPU-seconds, about 23 days on 5,000 spare cores, so it runs for weeks at the lowest priority. Re-run segment matching only where the cleaned track changed enough to matter, because shifting times by a second reorders boards and athletes notice. Announce changes that move leaderboards.
  4. The object write succeeded but the database insert failed. Or the other way round?
    The order makes the second case impossible: the row is inserted only after the object is durable, so there is never a row without a file. The first case leaves an orphan object, which is harmless: the app retries, the PUT overwrites the same key with the same bytes, and the insert succeeds. Objects with no row after a day are deleted by a lifecycle sweep.
  5. How would you know the upload path is healthy?
    Upload success rate by app version and network type, retries per successful upload, and the share of uploads that were retries of an already-stored id (a rising number means acknowledgements are being lost). On the server: p99 time from last byte to 202, and the count of rows stuck in received. On the app: activities still in the local outbox after 24 hours, which is the number that predicts support tickets about lost runs.
Matching an activity to segments

Ask: There are 30 million segments. How do you find the ones this ride completed without checking all of them?

Good answers name: In-memory S2 start-cell index, end-point prefilter, ordered tolerance walk, Spatial join in the database (PostGIS R-tree, ST_DWithin against the track), Index every cell each segment passes through, Map-match tracks and segments to road-graph edges, then match edge sequences.

Our pick: Each matcher holds an index from S2 level-13 cell (~1 km across) to the segments starting in it: segment id, start, end, length, bounding box and version, ~64 B each, ~2 GB for 30 M segments, built from a nightly snapshot of the segment store and kept current by its change stream. For a job, simplify the trimmed track (3,600 points to ~400), cover it with a 50 m buffer (~40 cells) and collect candidates (~1,000 in a city). Prefilter with a ~20 m grid hash of the track points: keep a candidate only if some track point is within 25 m of its start and a later one within 25 m of its end (~50 remain). For each pass of the start, walk the segment polyline resampled every 20 m, advancing a pointer through the track that can only move forward; each vertex needs a track point within 30 m, a few misses are allowed for dropouts, and the pass completes when the track reaches the end within 25 m. Elapsed time is interpolated at the start and end crossings. Matching runs only on the trimmed display track, so a privacy zone can never produce a public effort.

  1. A runner does five laps of a park loop. What do they get?
    Five efforts. Each time the track passes the start, a separate walk begins, and each walk that reaches the end is an effort with its own pass number in the effort id. The activity page shows all five; the leaderboard keeps only the fastest, and only if it beats the runner’s previous best. Overlapping passes cannot double count because each walk consumes its own stretch of the track.
  2. GPS drops out for 40 seconds under trees halfway along the segment. Effort or not?
    It depends on how much of the segment is missing. The walk tolerates a few missed vertices, up to about 10 % of the segment, and treats a straight jump between two good fixes along the polyline as followed. A longer gap fails the match rather than guessing, because a guessed effort on a leaderboard is worse than a missing one. Tolerances widen a little when the device reports poor accuracy.
  3. Someone creates a new segment on a famous climb. Do ten years of earlier rides count?
    Only through a backfill. Keep a reverse index of public activities by the level-13 cells their display tracks cover, stored as columnar files partitioned by cell. The backfill reads the activities in the segment’s start cell, keeps those that also touch the end cell, and runs the precise match on each in the background lane. On a popular climb that can be a million activities and hours of work, so the board is marked "building" until it finishes.
  4. At ten times the uploads, what breaks first?
    Not the matching: it grows linearly to ~100 cores, and the index grows with segments, not uploads, so it still fits in memory. The effort and best-effort writes grow to ~33 k a second, which means more Efforts DB shards. The background work grows faster than either, because every new segment needs a backfill over a bigger history, so backfills need their own capacity budget and a cap per segment.
  5. How do you know the matcher is right?
    A golden set of hand-labelled activities with their expected efforts, run on every matcher release, with precision and recall reported per sport. In production, efforts per activity by region and the match rate per segment over time: a popular segment whose matches suddenly drop has a bad polyline or the matcher regressed. Athlete reports of missing efforts, linked to the activity, feed the golden set.
Segment leaderboards and filtered views

Ask: Thirty million leaderboards, each with views by year, gender, age group and people you follow. Which do you precompute?

Good answers name: One best row per (segment, period, athlete) in SQL sharded by segment; shared views as index prefixes; per-viewer views on read, A Redis sorted set per segment per view, Compute boards on read from raw efforts (MIN per athlete, then sort), Precompute each viewer’s following board.

Our pick: best_efforts(segment_id, period, athlete_id, elapsed_s, effort_id, achieved_at, gender, age_bracket) with primary key (segment_id, period, athlete_id), sharded by segment_id beside the efforts table. Recording an effort is one transaction: insert the effort (idempotent on effort_id), then for each period (all, 2026) upsert the best row with WHERE best.elapsed_s > new.elapsed_s, so only an improvement writes. Indexes (segment_id, period, elapsed_s, achieved_at) and (segment_id, period, gender, age_bracket, elapsed_s) serve every shared view as a prefix scan. A new year is a new period value, the key-per-window idea from the game leaderboard. Following is computed on read from the follow graph by primary-key lookups, or by scanning the board when it is smaller than the follow list. Rarer filters (weight class, a club) are computed on read for small boards and capped at the top 100 plus the viewer on big ones. PRs and ranks are computed when the effort is recorded and stored on the activity, so activity pages never read leaderboards.

  1. An athlete deletes the activity that holds their best time on a segment. What happens?
    For each segment in that activity, one transaction on the segment’s shard deletes the activity’s efforts, recomputes the athlete’s best for each window as the minimum of their remaining efforts (an index on segment, athlete, elapsed time makes that one short read), and updates or deletes the best row. The histogram and top 10 cache are adjusted afterwards. If they held the top spot, the next athlete becomes first and is notified.
  2. An athlete has a birthday and moves to a new age bracket. Which rows change?
    Age bracket is a column on their best rows, so a nightly job rewrites it for athletes whose bracket changed that day. That is about 100 M ÷ 365 birthdays, a tenth of which cross a bracket boundary, so ~27 k athletes a day; at a few hundred segments each, several million row updates a day, under 100 a second. Histograms for big boards move the athlete between views as part of the same job.
  3. It is January 1st. How does the this-year board reset?
    It does not need to: the period column is the year, and writes now go to period 2027, which starts empty. Last year’s rows stay readable as history or are dropped by a partition drop later. No job touches 5 B rows at midnight. The game-leaderboard question covers the same trick for daily and weekly boards.
  4. Someone follows 5,000 athletes and opens a segment with 40 athletes on it. Lookups or scan?
    Scan. The service compares the two sizes it knows: the board’s row count (kept on the segment) and the viewer’s follow count. Forty rows intersected with a 5,000-id set in memory beats 5,000 primary-key lookups. For a hot segment with 400 k rows it does the lookups instead. Either way it is one shard and a few milliseconds.
  5. An athlete opts out of leaderboards, or makes an activity private. How do you keep them off every view?
    Best rows carry a hidden flag, and the leaderboard indexes are partial indexes that exclude hidden rows, so every view skips them with no extra filtering. Only activities visible to everyone produce public best rows; efforts from private activities are stored for the owner’s own history. Changing an activity’s visibility re-runs the best computation for its segments, the same path as a deletion.
The hot segment: top 10 and around me

Ask: A segment has 2 million efforts from 400,000 athletes and is opened 50 times a second. How do you show the top 10 and someone’s rank of 237,000?

Good answers name: Cached top 10, per-second histogram for rank, index range scans for neighbours, COUNT(*) over the ordered index per request, Redis sorted set with ZRANK, Approximate rank ("top 12 %").

Our pick: For every view of a board above ~10 k athletes, keep a histogram of best times per second in the leaderboard cache (~3,100 counters, ~12 KB), built on first read and kept while the view is read. Rank is the sum of the counts below the viewer’s time plus one; neighbours come from two LIMIT 2 or 3 index scans around the viewer’s (elapsed, achieved_at); the top 10 is a cached list that the writer rewrites only when a new time beats the current tenth. The leaderboard service holds the hottest top-10 lists in local memory for a few seconds so no single Redis key takes every read. After each committed improvement the writer decrements the old second and increments the new one. A nightly job recomputes histograms for big boards from the index and alerts if any count differs, which bounds drift to a day; ranks in the top few hundred always come straight from the index, so exactness never depends on the histogram where it matters most.

  1. A new fastest time lands on the hot segment. Walk me through it.
    The effort transaction lowers the athlete’s best row. After commit the writer sees the new time beats the cached tenth, so it reloads the top 10 from the index and rewrites the cache key, and the local caches pick it up within seconds. The histogram moves one athlete between seconds. The previous holder of first place gets a push notification, and the new holder’s activity gets the achievement, which was computed in the same call.
  2. Two athletes have the same time. Who is ranked higher?
    They share the rank, because the board ranks whole seconds and the histogram counts athletes strictly faster. In the list they appear in the order they achieved it, which is the second column of the index. If the product wanted to break ties by tenths of a second, store elapsed milliseconds and keep the histogram per second only for the rank estimate, with exact ranks inside a second resolved from the index.
  3. Someone uploads a car ride as a run and takes first place on a hundred segments. How is that stopped?
    First by the processor: speeds implausible for the sport, such as sustained 60 km/h on a run, flag the activity and its efforts are never posted to boards. Then by thresholds per segment: a new first place far faster than the existing top 10 is held for review rather than published. And by athletes: anyone can flag an effort, and a removed effort goes through the same recompute path as a deleted activity.
  4. Reads on the hottest boards grow ten times. What changes?
    Top 10 is already served from local memory, so it barely notices. Rank and neighbour reads move to read replicas of the shard holding the hot segments, or the hottest segments move to a dedicated shard. Per-viewer results (my rank on this board) can be cached for a minute, because a rank of 236,998 that is a minute stale is fine. Writes are unchanged, since they depend on efforts, not readers.
Privacy zones: trim when processing, not when drawing

Ask: Why not store the full track and just hide the privacy zone when you draw the map?

Good answers name: Trim at processing into a display track; the full track readable only by the owner, Store the full track; filter at render time, Trim on the device before upload, Blur or offset the whole track instead of cutting it.

Our pick: Privacy zones live with the athlete’s settings. When a zone is created, its stored centre is randomly offset from the address, by up to half the radius, so the circle’s centre is not the house. The processor cuts every point inside any zone from the display track, extending each cut by a per-activity distance of 0 to 200 m seeded by hash(activity_id), so cut points from many activities do not lie on one circle, and reprocessing reproduces the same cut. Stats come from the full track, so distance and time do not change. Two stream objects are written: full, readable only through the owner’s session, and display, the only input to the feed, map images, segment matching, the public API and aggregates. A zone change queues the athlete’s whole history in the background lane (2,000 activities at ~1 s is ~30 CPU-minutes), writes new display versions under new URLs, deletes the old objects and purges the old image URLs from the CDN.

  1. A segment starts right outside someone’s house, inside their zone. Do they get an effort?
    Not a public one. Matching runs on the display track, which has no points inside the zone, so the start is never reached and the match fails. That is deliberate: an effort on a segment that starts at a front door, listed on a public leaderboard with the athlete’s name, reveals the address just as well as a map. The athlete can still see their full track privately.
  2. An athlete adds a privacy zone today after three years of activities. What do they and others see, and when?
    New activities are trimmed from now on. The history goes into the background lane, newest first, and finishes within minutes to an hour depending on load. Until each activity is reprocessed its old display track is still visible, so the reprocess for a privacy change jumps ahead of other background work. Old map images are deleted at the origin and purged from the CDN by URL. We cannot recall copies third-party apps already fetched through the API, which is one more reason to trim before anything is shared.
  3. Are aggregate maps, like a heatmap of popular routes, safe because they are anonymous?
    Not automatically. In 2018 people noticed that Strava’s global heatmap showed the outlines of military bases in remote areas, because a handful of soldiers running laps in an empty desert is a bright, anonymous signal. Aggregates must be built from trimmed public tracks only, drop cells with fewer than some number of distinct athletes, and honour opt-outs. Sparse areas are where aggregates leak.
  4. Live tracking shows the athlete’s true position, even near home. Isn’t that a contradiction?
    No, because the audience is different. Privacy zones protect against followers and the public; live tracking exists so that a chosen safety contact knows exactly where the athlete is, including the last 200 metres to their door. It is visible only through an unguessable per-session link, expires within a day, and is never stored with the activity.
  5. How do you test that no surface leaks a zone?
    Make it structural and then check it. Structurally, the full-track object is readable only by the owner-facing service’s role, so another service cannot fetch it by mistake. As a check, synthetic athletes with zones upload activities through production every day, and a crawler fetches their activity through every public surface (web, API, embeds, feed images, segment pages) and fails if any coordinate falls inside a zone.
The weekend morning surge

Ask: Saturday 9 am: uploads jump to five times the average for three hours. What do athletes see, and what do you scale?

Good answers name: Priority lanes, scheduled pre-scaling and autoscaling on queue age, One FIFO queue, autoscaled on depth, Provision for the peak all week, Show device-computed stats instantly.

Our pick: Three lanes with their own worker pools. The fast lane (parse, clean, trim, stats, feed event) is scaled up on a schedule at 06:00 local in each region before the weekend wave, then autoscaled on the age of its oldest job with a target under 5 s; it carries only the newest activity of any import. The segment lane (matching, efforts, achievements) is allowed to lag, with a target under 2 minutes at the peak and alerts above 10. The background lane (backfills, reprocessing, the rest of bulk imports) has a per-athlete concurrency cap and pauses automatically whenever the fast lane’s oldest job is older than 10 s. Activity pages show "segments processing" while that lane catches up, the feed never waits for segments, and the app shows its own recorded summary the moment the athlete taps Save.

  1. The matcher fleet is down for an hour at the peak. What happens, and how do you recover?
    Nothing is lost: segment jobs sit in the segment lane, and everything can be recomputed from the raw files anyway. Athletes see their stats as usual and "segments processing" for longer. That hour builds a backlog of ~1.2 M jobs (330 a second for 3,600 s); with three times the normal segment capacity it drains in about half an hour. Drain newest first, so people opening the app now see theirs, and let the older ones catch up behind.
  2. A new user connects their watch account and imports five years: 1,500 activities. Where do they go?
    The most recent one goes through the fast lane, so the new user immediately sees something. The rest go to the background lane with a per-athlete concurrency cap of a few at a time, so one import cannot occupy the fleet. The import finishes in tens of minutes off-peak or hours on a Saturday, and the app shows progress rather than pretending it is instant.
  3. What would you put on the dashboard for Saturday morning?
    Age of the oldest job in each lane, which is what users feel. p50 and p95 from upload to processed and from upload to segments done. The share of activities opened by their owner before they were processed, which says whether the fast lane is fast enough in practice. Fast-lane worker count against the schedule, so a failed scale-up is visible before the queue is.
  4. Why not just make the processor faster and keep one queue?
    Faster processing lowers cost but does not fix ordering. The lanes exist for isolation: they stop urgent work queueing behind work that can wait, and stop a slow dependency, a hot leaderboard shard or a matcher reloading its index, from delaying the stats that do not need it. A faster processor still sits behind a 2,000-activity import in a single FIFO queue.

Close · 5 min

Ask what breaks first at ten times the load, and what they would build next. Then give them your read: one thing that was strong, one thing that was missing, one thing to practise. Be specific; “good job” helps nobody.