SysDesignPrep.com
System design interview question

Design Strava

Record runs and rides offline, process each upload into a map, stats and segment efforts, and rank them for 100 M athletes.

Last updated 2026-10-26. Difficulty: hard. Patterns: geospatial, async-pipeline, spatial-matching, leaderboards, privacy. Reported at Strava, Google, Apple, Meta, Amazon, Uber.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.

Or sit it in the style of Google · Apple · Meta · Amazon, run as that company’s round is reported to go.

The short answer

How to design Strava in a system design interview, in five steps:

  1. Scope it. Record a run or ride, upload and process the activity, view an activity, follow athletes and see their activities in a feed, segments and efforts, segment leaderboards, privacy controls, live tracking for safety.
  2. Set the targets. Scale: 100 M athletes · 40 M uploads/week; Never lose or duplicate an activity: 0 lost · 0 duplicated; Upload acknowledgement: p99 < 1 s after the last byte; Time to see your own activity: stats p95 < 10 s · segments p95 < 2 min.
  3. Do the numbers. Uploads per second: ~66 avg · ~330 peak; Size of one activity file: 3,600 points · ~300 KB; Raw file storage: ~0.6 PB/year.
  4. Draw the design. API gateway, Activity service, Activity files, Fast lane, Activity store, Activity processor, Segment lane, Segment matcher, Segment store.
  5. Go deep where it is hard.
    • Upload first, process later → Store the raw file, acknowledge, process asynchronously; everything keyed by a device-generated activity id.
    • Matching an activity to segments → In-memory S2 start-cell index, end-point prefilter, ordered tolerance walk.
    • Segment leaderboards and filtered views → One best row per (segment, period, athlete) in SQL sharded by segment; shared views as index prefixes; per-viewer views on read.
    • The hot segment: top 10 and around me → Cached top 10, per-second histogram for rank, index range scans for neighbours.
    • Privacy zones: trim when processing, not when drawing → Trim at processing into a display track; the full track readable only by the owner.
    • The weekend morning surge → Priority lanes, scheduled pre-scaling and autoscaling on queue age.

Functional requirements

  • 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 requirements

  • 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.

Back-of-envelope estimates

  • 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.

Components

  • 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.

User flows

  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. The id is a UUIDv7 created on the device, so recording needs no network and the id exists before any request is made. Points are appended and flushed every 10 s, so a crash or a dead battery loses at most 10 s. A watch that syncs to the phone later carries the same id inside its file.
    2. On Save, the app compresses the file and PUTs it to the Activity service at a URL containing that id. A PUT to a client-chosen URL is idempotent by definition: the same request twice means the same resource. One request carries the whole ~300 KB file; at this size a presigned two-step upload adds round trips on a weak signal and saves nothing.
    3. The service writes the file to object storage under a key derived from the athlete and the activity id. Writing the file first means a row never exists without its file. The key is deterministic, so a retry overwrites identical bytes instead of creating a second object; an object with no row after a day is deleted by a lifecycle sweep.
    4. It inserts the activity row in state received; an id that already exists inserts nothing. The primary key on the device id is the deduplication. If the row exists with the same file hash, this is a retry and the service returns the current status; with a different hash it is a client bug and gets a 409 rather than silently replacing a recorded activity.
    5. It enqueues a processing job on the fast lane, keyed by activity id. Keying by activity id sends every job for one activity to one partition, in order. A crash between the insert and the enqueue is covered by a sweeper that re-enqueues any activity still received after 5 minutes; processing is idempotent, so an extra job costs only CPU.
    6. The service returns 202; the app marks the upload done but keeps its copy until the activity is processed. The acknowledgement means "your file is safe", nothing more, and takes ~100–300 ms. The app deletes its copy only once the status reaches processed, so even an object lost in storage would be recoverable from the phone.
  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. Fast-lane workers were scaled up on a schedule before the morning and autoscale on the age of the oldest job, target under 5 s. A file that fails to parse three times is marked failed with a reason instead of retrying forever; the raw file stays for a parser fix.
    2. The worker cleans the track: drops spikes, smooths, detects pauses and corrects elevation. A point implying 80 km/h on a run is dropped; smoothing removes the zig-zag that inflates GPS distance by a few percent; speed under ~1 km/h for 10 s counts as a pause. GPS altitude is off by 10–20 m, so elevation comes from the barometer if the watch has one and otherwise from a terrain model, and climbs under a few metres are ignored so noise does not add phantom gain.
    3. It loads the athlete’s privacy zones and cuts every point inside them from the display track. This produces two tracks: the full one, readable only by the owner, and the trimmed one, the only input to the feed, map images, segments and the public API. The cut lands a jittered distance past the zone edge, seeded by the activity id, so many activities’ cut points do not trace a circle around the home.
    4. It computes stats on the full track and writes both streams and the processed activity. Distance and moving time come from the full track, so a privacy zone never changes the athlete’s numbers. The streams are written first under versioned keys, then the row, conditional on the processing version, so a slower duplicate worker cannot overwrite newer output.
    5. It publishes activity.processed for the feed and a job on the segment lane. The feed fans the activity id out to followers now, without waiting for segments. The segment job is keyed by activity id and processing version so consumers can drop duplicates.
    6. The athlete opens the app and sees the map, distance, pace and splits. Upload to processed is ~5–10 s at p95 even at the peak. The page shows "segments processing" until the segment lane catches up, typically within a minute and within 2 minutes at the worst of the morning.
    7. A matcher takes the segment job and finds candidate segments from its in-memory index of start points. The trimmed track is covered by ~40 S2 cells; the index returns the ~1,000 segments that start in them; an end-point check keeps ~50, whose polylines come from a local cache backed by the segment store. Each survivor is then walked in order with a tolerance (see the matching deep dive).
    8. The leaderboard service records each completed pass and lowers bests only when beaten; the matcher stores the results on the activity. One transaction on the segment’s shard inserts the effort and conditionally lowers the athlete’s best for each window; only an improvement touches the board, its top 10 cache and its histogram. Ranks and achievements (PR, top 10) are computed here and stored on the activity, so viewing an activity never queries a leaderboard.
    9. A follower opens their feed: activity ids come back and are hydrated with the latest stats and segment results. Because feed entries are ids rather than copies, the PRs that landed a minute after the fan-out show up with no second fan-out. Hydration is a batch read from the activity store that applies the same visibility rules as the activity page.
  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. One call returns what is on screen: the top 10 and the viewer with two neighbours either side. Filtered views and the following tab are separate calls made when tapped.
    2. The top 10 is read from the cache, which changes only when a new best enters it. On a popular board a time enters the top 10 a few times a month, so this key is read millions of times per write. The leaderboard service also holds it in local memory for a few seconds, so the hottest board does not concentrate on one Redis shard.
    3. The viewer’s best time comes from their best-effort row; their rank comes from the board’s histogram. Times are whole seconds in a narrow range (this loop: ~900 to ~4,000 s), so a count of athletes per second answers "how many are faster" exactly in one ~12 KB read: rank = the sum of the counts below 1,412 s, plus one. A COUNT(*) over the index would read 237 k entries per request.
    4. Neighbours above and below come from two short index scans around the viewer’s time. Two LIMIT queries on the ordered index cost a few page reads each, however deep the rank. Equal times share a rank and are listed by who did it first.
    5. The viewer taps "Women, 35–44, this year": the same three reads run against that view’s index prefix and histogram. Gender, age bracket and period are columns on the best-effort row, so every shared view is a prefix of one index and nothing extra is written per view. Histograms exist only for views of boards above ~10 k athletes and are built on first read; smaller views just count rows.
    6. The following tab fetches the viewer’s follow list and looks up just those athletes’ best rows. This view differs for every viewer, so it is never precomputed. Following ~150 people means 150 primary-key lookups on the segment’s shard, sorted in memory, ~5 ms. On a small segment the service scans the board and intersects instead, whichever side is smaller.
  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. From the phone’s side the request failed; from the server’s side it succeeded and processing has started. A server-generated id cannot handle this case: the phone would never learn the id it was given.
    2. The app’s outbox retries with backoff, sending the same id and the same bytes. Retries back off exponentially with jitter and survive an app restart, because the outbox is on disk. The athlete sees "uploading", not an error.
    3. The service finds the existing row with the same hash and returns its status without creating or enqueueing anything. Same id and same hash is a retry: return 200 with the current status. Same id with a different hash is refused with 409, so a buggy client cannot overwrite a recorded activity. A different id for the same run (watch and phone both uploading) is another problem, caught later by a start-time and track-overlap check.
    4. Meanwhile a processor crashed after writing the stats but before publishing; the job is redelivered to another worker. The worker commits its queue offset only after its last side effect, so a crash anywhere means the whole job runs again. That is safe only because every step is written to be repeated.
    5. The second worker recomputes from the immutable file; its conditional write finds version 7 already stored and changes nothing. Processing is a pure function of the raw file, the athlete’s settings and the processing version, so the rerun produces the same output, including the privacy cut, whose jitter is seeded by the activity id. Stream objects live under versioned keys, so rewriting them is harmless.
    6. It publishes the feed event and the segment job again; every consumer drops the duplicate by key. The feed adds the activity id to each follower’s feed as a set member, so a second fan-out is a no-op. Efforts carry effort_id = hash(activity_id, segment_id, pass), so a second match writes the same rows, and taking the minimum of a best time is idempotent anyway.
  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. The link carries an unguessable 128-bit token scoped to this one session, so it works without an account and cannot be enumerated. Strava calls the feature Beacon; the mechanics here are a design, not Strava’s documented internals.
    2. Every 15 s the app sends its latest position and battery level, while recording continues locally at one point a second. One small request every 15 s, coalesced with other radio use, costs far less battery than a persistent socket that drops on every cell handoff. A failed send is not retried: the next point supersedes it.
    3. The Live service appends the point to a capped list that expires. Nothing queries live sessions by place, so unlike a ride-hailing system there is no geo index: just a list per session, trimmed to the last ~240 points (an hour) and expiring a day after the last write. Privacy zones are deliberately not applied: a safety contact needs the true position, and only they can see it.
    4. The contact opens the link and their browser polls for new points every 15 s. Polling at the update rate means a push channel buys nothing. If no point has arrived for 10 minutes the page says so, with the last battery level, which is the most useful safety signal the feature gives.
    5. At Save the session ends and the uploaded file becomes the activity; the live trail expires. The live trail is never merged into the activity: it has one point every 15 s and gaps, while the file has every second. Keeping the two paths independent means a broken live service cannot lose or corrupt a recorded run.

Deep dives

Upload first, process later

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

At the weekend peak 330 activities arrive a second, each needing about a second of CPU plus writes to the activity store, the feed and ten segment boards. The phone sending them is often at the end of a trail or in a car park with one bar of signal. A request that holds the connection open for several seconds of processing will often be cut, the app will retry, and without care the run is processed and stored twice. Meanwhile the athlete’s real fear is simpler: losing the run, which until the server has it exists only on the phone.

The only thing that must be synchronous is "your file is safe". Every other output (stats, map, streams, efforts, ranks, the feed entry) is derived from the file and can be recomputed at any time. So store the file immutably, acknowledge, and treat processing as a pure function of the raw file, the athlete’s settings and a processing version. An id created on the device when the athlete presses Start makes the whole chain idempotent, from the first upload attempt to the last effort row.

  • Store the raw file, acknowledge, process asynchronously; everything keyed by a device-generated activity id chosen
  • Process inline and return the finished activity rejected
  • Presigned direct upload to object storage situational: large files (a multi-day ride with hundreds of thousands of points), bulk history imports and partner integrations; for a 300 KB run one PUT through the upload service is simpler and faster
  • Server-generated id from a create call before upload rejected

The answer: 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.

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.

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.

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.

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.

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

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

Segments average a kilometre or two, each a polyline of ~100 points, and there are 30 M of them, concentrated where people run and ride. At the peak 330 activities arrive a second, each a track of ~3,600 points. Testing every segment against every activity is 10 B segment tests a second; even a bounding-box check per segment is 30 M operations per activity. And a real match is subtle: GPS wanders 10–30 m, an athlete may ride a segment three times on laps, ride it backwards, or cut a corner.

The key observation is that a segment can match only if the track passes within ~25 m of its start. So index segments by the cell that contains their start point: covering the track with cells returns every segment that could possibly match, plus some that cannot. A cheap check that the track also passes near the end prunes most of those, and only the survivors get the ordered geometric test. The cost per activity becomes proportional to the segments near the track, not to the total.

  • In-memory S2 start-cell index, end-point prefilter, ordered tolerance walk chosen
  • Spatial join in the database (PostGIS R-tree, ST_DWithin against the track) situational: the "explore segments in this map view" feature, offline analysis, or a product at under ~10 uploads a second
  • Index every cell each segment passes through rejected
  • Map-match tracks and segments to road-graph edges, then match edge sequences situational: road cycling on a well-mapped network, as a fast path in front of the geometric match

The answer: 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.

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.

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.

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.

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.

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

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

Each segment has an all-time and a this-year window, men and women, about nine age brackets, plus per-viewer views such as following. If every combination were a stored ranking, that is more than a billion structures, almost all of them nearly empty: the median segment has under 100 athletes, while ~100 k segments have over 10 k and the largest ~400 k. And the board counts only each athlete’s best, so a commuter with 500 efforts on a segment occupies one place.

A leaderboard is therefore a derived index over "best effort per athlete per window", one row each, that changes only when an athlete beats their own best. Views shared by every viewer (window, gender, age bracket) are finite and read by many people, so they should be maintained as orderings. Views that differ per viewer cannot be precomputed for 100 M viewers, but they are small: a viewer follows maybe 150 people, of whom a handful have done the segment.

  • One best row per (segment, period, athlete) in SQL sharded by segment; shared views as index prefixes; per-viewer views on read chosen
  • A Redis sorted set per segment per view situational: the few thousand hottest boards, if per-second histograms are not used
  • Compute boards on read from raw efforts (MIN per athlete, then sort) rejected
  • Precompute each viewer’s following board rejected

The answer: 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.

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.

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.

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.

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.

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

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?

Writes are not the problem here. Even a famous park loop gets a few efforts a second on a Saturday, and only an improvement changes the board. Reads are: every segment page shows the top 10 and the viewer’s place with neighbours. The top 10 is the same for everyone and almost never changes. The viewer’s rank is different for every viewer, and computing it as COUNT(*) of faster athletes over an ordered index costs a scan proportional to the rank: 237 k index entries, tens of milliseconds and a lot of buffer reads, 50 times a second on one shard.

Two facts about segment times change the answer. They are whole seconds (that is how they are displayed and ranked, with ties sharing a rank), and they fall in a narrow range: a loop the fastest do in 15 minutes and the slowest in about an hour spans ~3,100 distinct values. A count of athletes per second therefore gives an exact rank as a prefix sum over a few thousand numbers, and changes by one decrement and one increment when an athlete improves.

  • Cached top 10, per-second histogram for rank, index range scans for neighbours chosen
  • COUNT(*) over the ordered index per request situational: boards under ~10 k athletes in the view, which is almost all of them, and ranks in the top few hundred on any board
  • Redis sorted set with ZRANK situational: if ranking needs sub-second or non-integer scores, for the hottest boards only
  • Approximate rank ("top 12 %") situational: rare filtered views of huge boards where no histogram is kept

The answer: 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.

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.

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.

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.

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

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

An activity that starts and ends at the same front door, every day, publishes where someone lives. A privacy zone (a circle around home or work) must hide that, and the track is drawn in many places: the activity page on three platforms, feed thumbnails rendered as images and cached on a CDN, embeds on other sites, the public API that third-party apps store, exports to other athletes, segment effort views, flybys and aggregate maps. Filtering at render time means every one of those surfaces, now and in the future, must apply the zone correctly, and one bug, or one image already cached at the edge, leaks thousands of homes.

The safe design makes the trimmed track the only version that exists outside the owner’s private copy, so a new feature cannot leak a home by forgetting a filter: it never had the data. The cost moves to processing: a zone added or changed today must be applied to years of activities. And trimming has a subtlety: if every activity is cut exactly at the circle’s edge, the cut points of a few activities trace the circle, and its centre is the home.

  • Trim at processing into a display track; the full track readable only by the owner chosen
  • Store the full track; filter at render time rejected
  • Trim on the device before upload rejected
  • Blur or offset the whole track instead of cutting it situational: aggregate maps and heatmaps, where coarse cells and minimum counts are applied on top of trimmed tracks

The answer: 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.

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.

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.

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.

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.

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

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

The 40 M uploads a week are concentrated on weekend mornings, rolling west as Europe and then the Americas finish their long sessions: ~330 a second against a 66 average. Reads spike at the same moment, because a group ride ends at one café and everyone opens the app together. Autoscaling on CPU reacts in minutes, and in those minutes the queue grows by hundreds of jobs a second. The same fleet is also expected to run backfills for new segments, reprocessing and bulk imports.

Not every piece of processing is equally urgent. The athlete wants their map and pace now; segment results can arrive a minute later; reprocessing can wait hours. And the stats depend only on the athlete’s own file, while segments depend on shared, contended systems: the matcher index and the leaderboard shards. The surge is also predictable to the hour, every week.

  • Priority lanes, scheduled pre-scaling and autoscaling on queue age chosen
  • One FIFO queue, autoscaled on depth rejected
  • Provision for the peak all week situational: as the floor for the fast lane while the fleet is small; the lanes are still needed
  • Show device-computed stats instantly situational: as the placeholder on the owner’s own screen right after Save, replaced when the server finishes

The answer: 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.

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.

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.

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.

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.

The theory behind it

  • Geospatial indexing and proximity search: Geohash, S2 and quadtrees, nearest-neighbour and radius queries, indexing moving objects, map tiles, and the accuracy and hot-cell problems that come with each.
  • Media uploads and processing pipelines: How photo and video uploads work at scale: direct-to-storage uploads with presigned URLs, resumable and chunked uploads, processing pipelines for thumbnails and transcoding, metadata and status tracking, deduplication, moderation hooks and serving variants through a CDN.
  • Idempotency keys in practice: How to make retried API requests safe: what idempotency means, client-generated keys, storing keys with request fingerprints and responses, handling concurrent duplicates, expiry, keys across service boundaries, idempotent consumers, and the mistakes that still cause double charges.

Related

Report an issue

Something wrong with this question? A number that does not add up, a step whose answer key looks off, a typo. It goes straight to the author with the page you are on.

Watch a strong candidate answer thisTurn by turn, with the design drawn as they talkWalk through →Mock interview