You finish a 10 km run around Cubbon Park in Bengaluru, your watch syncs, and a minute later your phone shows the distance, your pace per kilometre, a map, a personal record on the “Queen’s Road sprint” segment, 3rd place in your age group this year, and two kudos from friends. Behind that minute sits a pipeline that turns GPS points into statistics, a geometric search against tens of millions of user-drawn segments, leaderboards that competitive people check obsessively, a feed, and a world map of where people move. It also carries a famous privacy lesson: in January 2018, Strava’s global heatmap showed the outlines of military bases. In this problem you design the core of a fitness-tracking social network with 200 million registered athletes.
Understand the problem
Strava is three systems sharing one set of activities. The ingest pipeline is bursty and compute-bound, and must never lose a file. The competition layer — segments and leaderboards — is small in bytes but where users notice every mistake. The social and aggregate layer — feeds, the heatmap — is read-heavy and where privacy failures show up.
Functional requirements
- Athletes should be able to record an activity on a phone or watch and upload it, including hours or days later from a device that was offline on a trek.
- Athletes should be able to see the processed activity — distance, moving time, pace, elevation gain, splits and a map — within seconds of uploading.
- Athletes should be able to compete on segments. Each upload is matched against segments; every traversal becomes an effort on that segment’s leaderboards: overall (the KOM or QOM), personal records, this year, age group, people I follow, my club.
- Athletes should be able to see activities from people they follow in a feed and give kudos or comment.
- Anyone should be able to browse a global heatmap of where people run and ride, without it exposing individuals.
Non-functional requirements
- Scale. 200 million registered athletes (Strava’s own figure). Daily uploads are not published; we assume 10 million a day and derive the rest below.
- Fast acceptance. An upload is acknowledged in under 1 second at p99, even at the Sunday-morning peak.
- Fresh results. Stats and map visible in under 10 seconds at p50; segment results and leaderboards in under a minute at p99. Competitive athletes check their segment results the moment an upload finishes.
- Durability. An accepted file is never lost. It is the source of truth: everything else can be recomputed from it.
- Correct, eventually. Leaderboards may lag by seconds, but they must converge to exactly the right answer: one slot per athlete per board, and removals (private, deleted, flagged) always honoured.
- Availability. 99.9% for uploads and reads; processing may lag in a spike but must catch up.
- Privacy. Every privacy change or deletion reaches every derived dataset — feed, leaderboards, data lake, heatmap — within a stated time.
In the interview
Ask: “Do we need live tracking during the activity, or only after upload?”, “Which leaderboard filters matter — is ‘people I follow’ in scope?” and “Is the heatmap public?” The first decides whether you are building a streaming location system like Design Nearby Friends or a batch-style upload pipeline. The last one is where a sharp interviewer will take the privacy discussion.
Out of scope
- Live tracking and safety beacons during an activity: a streaming problem of its own.
- Routes, route planning and turn-by-turn navigation: see Design Google Maps.
- Live segments on the watch, training plans, challenges, subscriptions and payments.
- Device firmware and the vendor clouds (a watch maker’s own sync service) that forward files to us.
The set-up
Plan the approach
Do the simplest thing for each requirement, then spend most of the time in the deep dives. Say the shape early: “The write path is a pipeline. Accept the file fast, process it asynchronously, then fan the result out to independent consumers: segments, feed, data lake.” The deep dives then pick the hard parts: absorbing upload spikes, finding segments in a track, keeping leaderboards correct, storing streams for analytics, and building a heatmap that doesn’t leak.
Core entities
- Athlete. ID, sex, birth year (for age groups), weight class, privacy defaults, hidden addresses, heatmap opt-out.
- Upload. ID, athlete, file hash, raw file location, status (processing, ready, duplicate, error).
- Activity. ID, athlete, sport, start time, statistics, visibility (everyone, followers, only me), map visibility (hidden start and end distances), summary polyline, flagged or not.
- Streams. Per-activity arrays of equal length: time, latitude/longitude, altitude, distance, heart rate, cadence, power, a moving flag.
- Segment. ID, polyline, start and end points, length, sport, creator, status.
- Effort. One traversal of one segment in one activity: segment, athlete, activity, start and end index into the streams, elapsed time, start date, eligibility for public boards.
- Leaderboard entry. Derived: the athlete’s best eligible effort on one board of one segment.
- Follow, Kudos, Comment. Ordinary social-graph rows.
- Heatmap tile. Derived: one byte per pixel for a zoom/x/y tile, rebuilt monthly.
API
Uploads are a multipart POST that returns before any processing happens. The client sends a hash of the file so that a retry after a dropped connection is harmless.
POST /v1/uploads
Content-Type: multipart/form-data; boundary=x
Idempotency-Key: sha256:9f2c41d0…e41a
file=@morning-run.fit data_type=fit device=watch external_id=wx-1189734
HTTP/1.1 202 Accepted
Location: /v1/uploads/u_91
{ "upload_id": "u_91", "status": "processing" }
The phone polls the upload (or receives a push) and then reads the activity. The map comes as a simplified encoded polyline, a compact string of coordinate deltas, so a feed card does not download thousands of points.
GET /v1/uploads/u_91 → { "status": "ready", "activity_id": 8812 }
GET /v1/activities/8812 → stats, splits, summary_polyline, segment efforts
GET /v1/activities/8812/streams?keys=time,latlng,heartrate
Leaderboards take a board and optional filters; the response carries your own rank even when you are not on the page being shown.
GET /v1/segments/2291/leaderboard?board=age:35_44&period=this_year&filter=following&page=1
{
"segment": { "id": 2291, "name": "Nandi Hills climb", "distance_m": 6400 },
"entries": [
{ "rank": 1, "athlete_id": 77, "elapsed_s": 1123, "date": "2026-01-18" },
{ "rank": 2, "athlete_id": 4102, "elapsed_s": 1170, "date": "2026-09-06" }
],
"me": { "rank": 14, "of": 23, "elapsed_s": 1489 }
}
The rest is ordinary:
GET /v1/feed?cursor=… activities from people I follow, newest first
POST /v1/activities/8812/kudos idempotent per athlete
PUT /v1/athlete/privacy hidden addresses, hide-start-end metres, heatmap opt-out
GET /tiles/heat/run/hot/16/46893/30386.png one heatmap tile (zoom/x/y), via the CDN
Back-of-envelope estimates
Each number is either a stated assumption or derived from one (method: back-of-envelope estimation).
Uploads. Assume 10 million a day. Strava’s leaderboard write-up quoted 1.4 million a day some years ago, so this assumes about sevenfold growth. Average: 10M ÷ 86,400 ≈ 120 uploads/s. Long runs and group rides finish on weekend mornings. Assume a Sunday carries 1.5× an average day and its busiest hour 15% of that: 15M × 0.15 ÷ 3,600 ≈ 625 uploads/s, about 5× the average. A city marathon adds a local spike on top.
Points per activity. A one-hour activity recorded at 1 Hz has 60 × 60 = 3,600 points; a five-hour ride has 18,000.
Raw bytes. A binary FIT record (time, position, altitude, heart rate, cadence, power, distance) is about 30 bytes, so an hour is 3,600 × 30 B ≈ 108 KB; GPX is several times larger. Assume 150 KB per upload on average: 10M × 150 KB = 1.5 TB a day, about 550 TB a year of raw files. Keep them in cheaper tiers forever, so better algorithms can re-run over history.
Processed streams. Strava’s per-activity gzipped JSON averaged about 150 KB (150 TB for a billion activities); in Parquet, about 10 KB. Our lake grows by 10M × 10 KB = 100 GB a day, about 37 TB a year.
Segment efforts. Strava quoted 75–100 efforts a second at 1.4 million uploads a day (about 16 uploads/s), so roughly 5–6 efforts per upload. Using 6: 10M × 6 = 60M efforts a day ÷ 86,400 ≈ 700 efforts/s, and 625 × 6 ≈ 3,750/s at the Sunday peak.
Leaderboard writes. One effort can affect up to about eight boards (overall, sex, age group, weight class, year, month, week, today), but it only writes where it beats the athlete’s current best. Assume three writes per effort: 3,750 × 3 ≈ 11,000 row writes/s at peak, about 34,000 replica writes/s with three replicas.
Matching compute. Budget 0.2 CPU-seconds per activity, matching included: 625 × 0.2 ≈ 125 busy cores at peak. Compute is cheap; spikes and hot keys are the risk.
Feed fan-out. Assume 100 followers on average: 10M × 100 = 1 billion feed inserts a day ≈ 11,600/s, and 625 × 100 ≈ 62,500/s at the peak.
Heatmap input. Two years of activities is 10M × 730 = 7.3 billion; if 70% are public and not opted out, about 5.1 billion × 10 KB ≈ 51 TB to read per monthly build. At the roughly 100 MB/s per machine Strava measured from S3, 500 machines read it in 51 TB ÷ 50 GB/s ≈ 17 minutes; drawing takes longer than reading.
Tip
Two numbers shape this design: a 5× weekend peak, and a few segments everybody rides. Neither needs exotic hardware; both need queues, careful partition keys and caches.
High-level design
Build one requirement at a time, ending with a design that works before any scaling.
Athletes record an activity and upload it, even hours later
Watches record locally and sync later, through the phone or the watch maker’s cloud. Either way the device holds a finished file — usually binary FIT, sometimes GPX or TCX — and sends it when it has a connection. A runner back from three days on the Kudremukh trek uploads three activities at once from a bus stand with one bar of signal.
So the Upload API does as little as possible: authenticate, check size and format, hash the file, write the bytes to object storage, record an upload row, put a message on a queue, and return 202 Accepted. If the connection drops after the bytes arrived but before the phone heard 202, the retry carries the same hash, finds the existing upload row and gets the same upload_id back — the idempotency key is the file itself.
Athletes see the processed activity within seconds
A pool of processing workers consumes the queue. Each worker takes one upload and runs five stages:
- Parse the file into streams; a truncated or corrupt file fails here, visibly, instead of being half-processed.
- Clean the streams: drop impossible jumps, mark stopped stretches, smooth the altitude.
- Compute statistics: distance, moving and elapsed time, pace, climb, splits, heart-rate zones.
- Simplify the line for maps and feed cards.
- Persist the activity row, the streams as one object, and an
activity.processedevent.
Partition the activities database by athlete ID: almost every query is “this athlete’s activities, newest first”, so a page comes from one shard.
Athletes compete on segments
A segment matcher consumes activity.processed. It finds the segments the track traversed, times each traversal, and writes an effort row per traversal. Each effort write produces an effort.mutated event, which a leaderboard service consumes to update that segment’s boards. In this first version the matcher checks the bounding box of every segment in the city, and boards are sorted sets in Redis — roughly where Strava started.
Followers see the activity in their feed
A feed fan-out worker also consumes activity.processed. If the activity is visible to followers, it pushes the activity ID onto each follower’s cached feed list; reading a feed is one list read plus a batch fetch of cards. Kudos and comments are rows keyed by activity, with a counter on the card. This is the news feed pattern; celebrities come up in the follow-ups.
Anyone browses the heatmap
The heatmap is not a request-time query. A lake export consumer appends processed streams to a columnar data lake, and a monthly batch job turns the lake into image tiles that a tile server and a CDN serve to the map.
This works for a small community. At our numbers it breaks in five places: the Sunday spike, matching against millions of segments, leaderboard writes, analytics over billions of small files, and a heatmap that publishes more than anyone intended.
Deep dives
How do we absorb Sunday-morning spikes without losing or duplicating uploads?
Bad: process inside the upload request. Parse, match and update leaderboards before replying. It is simple, but every slow dependency — a leaderboard store having a bad minute — becomes a failed upload. Phones retry, multiplying the load exactly when it is highest, and each half-succeeded retry creates a duplicate activity.
Good: store raw, return 202, process from a queue. The upload path does one object write and one message, so it stays fast whatever the workers are doing, and a message queue holds the backlog. You pay with a status to poll or push, and with eventual results: in a spike, segments may take minutes.
Great: a queue plus the rules that make it safe.
- Dedupe twice. The file hash catches retries of the same bytes. It misses the same run arriving by two routes — as FIT from the watch maker’s cloud and as GPX from the phone. So also check in processing: same athlete, start within a minute, tracks overlapping by over 90% → duplicate.
- Autoscale on queue lag, not CPU. The signal that matters is the age of the oldest unprocessed upload (see the chart below).
- Separate lanes. Fresh uploads go on a high-priority topic; bulk work — a ten-year history import, reprocessing after a bug fix — goes on a low-priority one with a per-athlete rate limit.
- Retry, then park. A corrupt file fails every time. Retry with backoff, then move it to a dead-letter queue and show the athlete an error.
- Make every stage idempotent. Delivery is at least once, so a worker that dies before acknowledging runs again. Key the activity on the upload ID and make each write an upsert.
The red bars: the three busiest hours exceed 400/s by 140, 225 and 120 uploads a second, so (140 + 225 + 120) × 3,600 ≈ 1.75 million uploads queue up, and after noon the fixed fleet drains only about 1.3 million by 15:00. It is an argument for autoscaling on lag and backpressure on bulk lanes, not a forecast.
Cleaning GPS. Phones in Mumbai’s high-rise lanes jump by tens of metres. Drop points whose implied speed is impossible for the sport (a runner at 60 km/h), and mark a point stopped when the speed averaged over a few seconds falls below a threshold, so traffic-light waits don’t count as moving time. Blend GPS elevation with a terrain model before summing climbs. Keep the raw streams untouched so improved rules can re-run.
Simplifying the line. A feed card is about 300 pixels wide; sending it 3,155 points is waste. Douglas–Peucker keeps a line’s shape within a tolerance ε: join the first and last points, find the point farthest from that chord, and if it is farther than ε, keep it and recurse on both halves; otherwise drop everything between. Store the result as an encoded polyline — each coordinate a small difference from the previous one, in printable characters.
At one point a second, consecutive points are a few metres apart and cost about two characters each: 3,155 × 2 ≈ 6 KB. The 240 simplified points are about 40 m apart and cost about four: 240 × 4 ≈ 1 KB. Use the simplified line wherever a map is a thumbnail.
How do we find which of millions of segments an activity crossed?
Segments are drawn by members — a climb, a lakeside sprint, a park loop. Strava doesn’t publish how many exist; assume 40 million. For every upload you must find the handful this track traversed, and time each one.
Bad: test every segment. Even a 1-microsecond bounding-box test per segment is 40M × 1 µs = 40 seconds per activity; at 625 uploads/s that is 25,000 busy cores.
Good: a bounding-box index. Put segment bounding boxes in an R-tree (PostGIS does this) and query with the activity’s box. A park loop returns a short list. But a 120 km ride from Bengaluru to Nandi Hills and back has a box covering half the city, pulling in tens of thousands of candidates it never came near.
Great: cover the track with cells and index segments by the cell of their start point. A segment can only match if the track passes near its start, so that is all the index must answer. Map each segment’s start point to an S2 level-16 cell (edges of about 100–150 m; see geospatial indexes) and keep a map from cell ID to segment IDs. For an activity, compute the cells within the start-gate tolerance of the track — its covering — and look each one up. A 10 km run touches about 150 cells; the lookup returns about 200 candidates; verification keeps a handful.
The index is small enough to hold in every matcher’s memory: an 8-byte cell ID plus an 8-byte segment ID is 16 bytes, and 40M × 16 B = 640 MB. Segment geometry (about 1 KB each) lives in a key-value store behind a cache where popular segments stay hot; new and edited segments update the index through events.
Then verify each candidate. Strava’s support pages describe the essentials: the track must pass both start and end, with a buffer for GPS drift, and the effort is timed between the recorded points closest to the start and end. Spelled out:
- Start gate. Find every stretch where the track comes within about 25 m of the start; laps and hill repeats produce several.
- Corridor. Walk forward and check that the track stays within a corridor around the segment, passing checkpoints in order. A short gap (a tunnel) is allowed; leaving the corridor ends the attempt. This rejects riders who turn off halfway and roads that parallel a trail.
- End gate. The traversal must reach the end, with a length close to the segment’s — a guard against shortcuts.
- Timing. The time between start and end gates is the effort.
In the figure, the point at 612 s is 9 m before the start line and the one at 613 s is 3 m past it, so the crossing is at 612 + 9/12 = 612.75 s; at the end, 2 m short at 857 s and 6 m past at 858 s gives 857.25 s, so the effort is 244.5 s. Strava’s help pages note that nearest-point timing can cover a slightly longer or shorter stretch; interpolation removes most of that, though nothing rescues a device recording every 10 seconds on a short sprint.
Cost and parallelism. Each candidate is a walk over a few hundred track points, a fraction of a millisecond, so 200 candidates fit the 0.2 CPU-second budget. Activities are matched independently, so the matcher scales by adding consumers. Match against the activity’s current version and stamp it on every effort, so a deleted activity can’t sprout efforts from a stale message.
How do we keep leaderboards correct under thousands of efforts a second?
A segment leaderboard sounds like a sorted set: athlete → best time. Strava’s history shows why it is harder. Its first version queried the efforts table with SQL; around 2012 it moved to Redis, with the logic later moved into a Scala service. When retired, it handled 1.4 million uploads a day and 75–100 new efforts a second on 60 Redis nodes with 1.8 TB of memory, and it failed in instructive ways:
- Memory forced shortcuts. Only the overall and women’s boards were sorted sets; every other board meant fetching a per-segment hash of all efforts and sorting it in the app — 50 ms of transfer alone for big segments.
- Sharding by segment made hot spots. Just 100 shards keyed by segment put a popular climb’s traffic on one single-threaded Redis instance.
- Read-modify-write needed locks. Under load, requests timed out waiting for locks, retries added load, and background repair jobs added more.
Bad: read-modify-write with locks on a shared store. Correct while quiet, it collapses under the very spike it must serve — a textbook retry storm.
Good: one writer per slot, through a keyed log. An athlete has exactly one slot on a board — their best time — so only updates to the same (segment, athlete) pair conflict. Publish every effort change to Kafka keyed by that pair: one slot’s changes land in one partition, consumed in order by one consumer, so nothing needs a lock and partitions run in parallel. You pay with eventual consistency — a PR appears seconds later — and a subtle ordering bug remains.
Great: events are notifications; the consumer re-reads the truth. Effort changes come from many places — the matcher, privacy changes, crops, flags, deletions — in parallel, with no common order. If job A (make private) and job B (make public again) both publish, B’s message can arrive first, and a consumer applying payloads in arrival order ends with the effort removed. Strava’s fix: the message says only “something about this effort changed”, and the consumer re-reads the current rows from the source of truth and recomputes the slot. Arrival order stops mattering, and duplicates and replays are harmless.
The consumer’s logic is short; the key is the (segment, athlete) pair and the body can be empty.
def on_effort_changed(segment_id, athlete_id):
efforts = efforts_db.eligible(segment_id, athlete_id) # re-read the truth
for board in boards_for(segment_id, athlete_id): # overall, sex, age, year, week…
best = min((e for e in efforts if board.accepts(e)),
key=lambda e: e.elapsed_ms, default=None)
current = boards.best_row(segment_id, board, athlete_id)
if best != current:
boards.replace(segment_id, board, old=current, new=best) # idempotent
publish_board_change(segment_id, board) # for caches, KOM alerts
Commit before you notify
Re-reading only works if the effort row is committed before the notification is published; otherwise the consumer can read the old row and never hear of the change again. Publishing after the commit loses the event on a crash in between. Write it to an outbox table in the same transaction, or stream the table with change data capture. See transactional outbox.
The store. Strava chose Cassandra with one table, partitioned by segment, rows ordered by board then elapsed time, so the top 20 of any board is one contiguous slice. A sketch close to the published schema:
CREATE TABLE leaderboards (
segment_id bigint,
board text, -- 'overall', 'f', 'age:35_44', 'year:2026', 'week:2026-W43'
elapsed_ms int,
start_time timestamp,
effort_id bigint,
athlete_id bigint,
PRIMARY KEY ((segment_id), board, elapsed_ms, start_time, effort_id)
);
CREATE INDEX ON leaderboards (athlete_id); -- "what is this athlete's current row?"
To find an athlete’s current row, which this ordering scatters, Strava weighed a second table keyed by athlete (two tables to keep in sync) against a secondary index, benchmarked the index, and used it. It runs three replicas with quorum reads and writes; with one writer per slot, last-write-wins conflicts are rare. A popular climb is no longer a hot spot: a few small writes a second, with reads served from a cache.
Ranks and filtered boards. Two read questions are expensive in that store:
- “What is my rank?” Rank is the number of faster rows, so Cassandra must read the partition up to your row — costly on boards of tens of thousands of athletes. Strava’s answer was caching and aggregate counters. Cache whole boards, not pages, because one new PR shifts everyone below it. A sorted array of 100,000 times answers rank by binary search in about 17 steps (log₂ 100,000 ≈ 16.6); a Redis sorted set does it with
ZRANK. Feed the cache from the board-change stream, keyed the same way, so a fill and an update can’t race. See the live leaderboard problem for ranking structures. - “Among people I follow” and “in my club.” One per viewer or club per segment is far too many to materialise. Compute them at read time: intersect the follow set (a few hundred IDs) with the cached board; if it isn’t cached, fetch each followed athlete’s best effort and sort.
| Board | Built | You gain | You pay |
|---|---|---|---|
| Overall, by sex | Precomputed rows in the segment’s partition | One slice per page | One more row write per PR |
| Age group, weight class | Precomputed, one board type per bracket | Same cheap reads | Write amplification: up to ~8 rows per effort |
| Year, month, week, today | Precomputed per period; old periods expire | “This year” boards for free | Roll-over and expiry jobs |
| Following, club | At read time: follow set ∩ cached board | No storage per viewer | CPU per read; needs the board cached |
| My own efforts | Efforts table keyed by athlete and segment | Already exists | — |
Corrections. An effort leaves a board when its activity is deleted, made private, cropped or flagged (a car ride, the wrong sport), or when its start or end falls in a hidden part of the map (Strava keeps such matches private). Each is just another change to effort rows, so it flows through the same stream: the consumer re-reads, finds the effort ineligible, and promotes the athlete’s next-best effort or removes the slot. If it held the KOM, the board-change stream notifies the new holder. A “run” at 40 km/h can be held back from public boards before anyone flags it.
Where do a billion activities’ streams live?
Streams are the bulk of the data, and two readers want them: the activity page wants one activity’s streams, fast; analytics jobs — the heatmap, backfilling a new segment — want all of them, cheaply.
Bad: rows in the relational database. One row per point is 3,600 rows per activity, or 3,600 × 10M = 36 billion rows a day. Indexes, vacuum and backups drown — monolithic persistence at its most extreme.
Good: one compressed object per activity. Strava stored each activity’s streams as a gzipped JSON array in S3, keyed by the activity ID. Perfect for the activity page: one GET, no database load. But with more than 150 TB from over a billion activities, Strava estimated one pass over everything at about $5,000 in request charges alone — one GET per activity — plus about 1,000 CPU-hours to parse the JSON. And because keys were auto-incrementing IDs, a sequential scan hammered one S3 key range and hit rate limits — a classic hot key problem in blob storage.
Great: keep the per-activity object, and add a columnar lake for bulk reads. Strava built a second copy in Parquet with Snappy compression, where delta encoding shrinks slowly changing series like time and position. It came out about 15× smaller (10 TB instead of 150 TB), in files of about 100 MB, so a job reads all history in few requests. At about 100 MB/s per machine, 30 machines finish a full pass in 10 TB ÷ 3 GB/s ≈ 3,300 s, about an hour. You pay with a second copy to keep in sync and a lake that must honour deletes.
| Rows in a relational DB | One object per activity | Columnar lake (Parquet) | |
|---|---|---|---|
| Activity page read | Thousands of rows | One GET — ideal |
Awkward: a 100 MB file holds thousands of activities |
| Full-history scan | Impossible at this size | ≈ $5,000 in GETs, hot prefixes |
≈ 1 hour on 30 machines |
| Size for 1 billion activities | Far larger than the files | ≈ 150 TB | ≈ 10 TB |
| Deletes | Easy | Easy: delete one object | Rewrite the affected files |
| Use it for | Nothing at this scale | The product’s point reads | Heatmap, analytics, backfills |
Three details make it work. Write the lake from events: hourly files partitioned by date and coarse region, so a job about India in 2026 skips most files — the OLTP versus OLAP split in miniature. Fix the prefixes: key per-activity objects by a hash of the ID (ab/12f…/8812.json.gz). Honour deletes: a compaction job rewrites every file holding a deleted or opted-out activity before the next heatmap build. Events drive the product in seconds; the lake feeds monthly jobs — batch and stream processing side by side.
How do we build a heatmap of everywhere people move?
Bad: draw tiles on demand from raw points. Finding every activity that crossed a tile means a lake scan per request; a busy tile on Mumbai’s Marine Drive has millions of activities behind it.
Good: a batch job that counts points per pixel. Strava’s first heatmap did this in C on a single machine and went two years without an update, partly because input meant one GET per activity. Counting points also leaves gaps behind fast cyclists and makes slow uphills look hotter than downhills.
Great: a distributed job that draws paths, normalises locally, and serves bytes through a CDN. Strava’s Spark rewrite reads the Parquet lake and ran on several hundred machines in a few hours, for a few hundred dollars:
- Filter. Only public activities, honouring every privacy setting and opt-out. Remove impossible speeds per sport (cars and planes labelled as rides), stopped points and virtual rides. Strava also offsets each activity randomly by about 2 m, because phones snap points to road centrelines.
- Rasterise at zoom 16. That zoom has 2¹⁶ × 2¹⁶ tiles of 256 × 256 pixels, about 2 m per pixel. Draw each activity as lines between consecutive points (about 4 pixels apart at this zoom), cut at tile edges, and group by tile. More than 30 million tiles have heat; held as dense 8-byte counters they would need 30M × 256 × 256 × 8 B ≈ 15.7 TB of memory, so the job runs many more tasks than machines and compresses each finished tile to disk.
- Suppress sparse cells. The privacy step added after 2018; see the next deep dive.
- Normalise. Strava maps each pixel to its percentile among pixels in its tile and neighbours within five tiles — histogram equalisation — blending neighbours’ curves to hide seams. Every area uses the full colour range, so quiet towns are as readable as big cities.
- Build lower zooms by summing four tiles into one and normalising again, down to one tile for the world.
- Serve. Store one byte per pixel (64 KB per tile before compression), grouped into meta-tiles; a tile server renders a PNG in the requested colours, and the CDN caches it.
The addressing is the Web Mercator pyramid from Google Maps; what is new is that the content comes from people.
How do we publish aggregate location data without exposing people?
What happened. In November 2017 Strava released a new global heatmap built, by the company’s description quoted in The Guardian, from a billion activities and more than 3 trillion GPS points. In late January 2018 analysts noticed that it was “not amazing for Op-Sec”: US bases were clearly identifiable. In Afghanistan, Djibouti and Syria, where users seemed to be almost all foreign military personnel, bases glowed against a dark map, and soldiers’ jogging loops traced internal layouts that commercial satellite maps did not show. A lone cyclist’s ride from Area 51 in Nevada showed up as a thin line; RAF Mount Pleasant in the Falklands lit up, along with the lakes its personnel swam in.
Why “public and aggregated” was not enough. The heatmap respected the settings it had: private activities were excluded and athletes could opt out. But privacy settings protect individual records, and the leak came from the shape of the aggregate. Heat measures how many runs, not how many people: a base where 30 soldiers run the same loop every day produces as much heat as a busy park, from almost nobody. In an empty desert that heat is unique and attributable. Local normalisation, designed to make quiet towns readable, also helps a lone cluster in an empty region stand out at full brightness. The general lesson: aggregation is not anonymisation when density is low, and the danger is a sparse cluster, not a single point.
Strava’s changes. In a post on heatmap updates, the company listed what changed:
- the heatmap is rebuilt monthly, partly so that an athlete who opts out is removed at the next update;
- it shows only the last two years of data;
- roads and trails with very little activity show no heat until several different athletes have uploaded activities there;
- the map stays public, but only registered athletes can zoom to street level.
These sat on top of controls that already existed: no background tracking, an opt-out setting for aggregated data, private activities excluded, and privacy zones that remove an athlete’s own activity near a chosen place.
Hiding where people live. The same reasoning applies to an athlete’s own maps. Hiding a circle around home sounds safe, but researchers at the University of Illinois (USENIX Security 2018) collected a month of public posts from a popular fitness service — 21 million posts by 3 million users — found that 16.5% used such endpoint privacy zones, and showed that 95.1% of moderately active users were at risk of having the protected location recovered. The intuition: when every visible activity starts at the edge of the same circle, the circle’s centre is the home. They proposed fuzzing the zones, and the affected companies adopted their countermeasures. Strava today hides the first and last 200 m of every activity by default once an athlete has uploaded their first one, lets athletes hide up to a mile around chosen addresses, and warns that hiding does not make a location impossible to deduce.
Design rules to take away. They apply to any product that publishes location aggregates — delivery density maps, ride demand maps, “popular times” at shops.
- Threshold on distinct contributors, not on volume. For every cell (a pixel at a coarse zoom, or an H3 cell), count distinct athletes over the window, and suppress the cell entirely below k. Counting exactly is a
countDistinctper cell in the Spark job; at larger scale, a HyperLogLog sketch per cell does it in a few kilobytes. Suppress rather than dim: a faint line is still a line. - Bound the window and the rebuild cadence. Two years of data and a monthly rebuild give every opt-out and deletion a maximum lifetime in the map.
- Propagate deletes and opt-outs to every derived dataset. Feeds, boards, caches, the lake, tiles and the CDN all hold copies. Treat a privacy change as an event every consumer must apply, and track completion per dataset.
- Gate precision by access. Coarse zooms for everyone, street level for signed-in accounts, and rate limits on tile scraping.
- Make the safe choice the default. Hidden start and end points by default, and randomised hiding radii, protect the people who never open the settings page.
- Look for sensitive sparse clusters on purpose. Before publishing, review cells with high heat but few contributors. It is cheaper than learning from a newspaper.
In the interview
The sentence to say: “Aggregated isn’t anonymous when density is low. I’ll threshold every published cell on the number of distinct athletes, not on heat; rebuild monthly over a two-year window so opt-outs and deletes expire on a schedule; and require sign-in for street-level zoom.”
The final design
Put the pieces together. The upload path is short and synchronous; everything after the 202 is a consumer of an event, which is what lets each part scale, fail and catch up on its own.
Follow one Sunday run through it:
- Accept. The phone posts the FIT file. The Upload API checks the hash, writes the bytes to object storage, records the upload, publishes to the
uploadstopic and returns 202 in about 150 ms. - Process. A worker reads the raw file, parses, cleans, computes statistics and the simplified line, upserts the activity keyed by upload ID, writes the streams object, and publishes
activity.processed. Workers autoscale on queue lag; bulk imports use a separate low-priority lane. - Match. The segment matcher covers the track with level-16 cells, looks up candidate segments in its in-memory index, verifies start gate, corridor and end gate, interpolates times, and writes efforts plus outbox rows in one transaction.
- Rank. The outbox relay publishes effort events keyed by segment and athlete. One consumer per partition re-reads the athlete’s eligible efforts, rewrites their slot on each affected board in Cassandra, and publishes board changes that keep the leaderboard cache, rank counters and KOM notifications current.
- Share. The feed fan-out pushes the activity ID into followers’ feed lists, unless the athlete has more than about 10,000 followers, in which case followers pull it at read time.
- Aggregate. The lake export writes hourly Parquet files; once a month a Spark job builds the heatmap with privacy filters and a distinct-athlete threshold, and the tile server serves it through the CDN, with street-level zoom only for signed-in athletes.
- Protect. A privacy change or deletion updates the activity and its efforts, which flows through the same events to the boards and feeds; the lake compaction and the next monthly build remove it from the aggregates.
- Read. The phone reads activities, boards (from the cache, with following and club filters applied at read time) and feeds through the read APIs, and tiles from the CDN.
Check it against the requirements: uploads acknowledged after one object write and one message; results in seconds on a normal day and minutes at the worst peak, never lost; leaderboards converge without locks; feeds don’t block anything else; and the heatmap publishes nothing a handful of people could be identified from.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Requirements | Upload, stats, a leaderboard | Separates ingest, competition and aggregates; sizes the weekend peak | Names privacy of derived data as a requirement with deadlines |
| Ingest | Uploads to a service that processes them | Object storage + 202 + queue; hash dedupe | Duplicate detection across sources, lanes, lag-based autoscaling, reprocessing from raw |
| Segment matching | “Find segments near the route” | Spatial index of start points, gates and corridor | Coverings, in-memory index sizing, interpolated timing, repeats, backfill for new segments |
| Leaderboards | Redis sorted set per segment | Partitioning, hot segments, one slot per athlete | Keyed log, notifications plus re-read, outbox, Cassandra layout, rank caching, corrections |
| Data at rest | Streams in the database | Per-activity objects in blob storage | Columnar lake for scans, prefix design, deletes in the lake |
| Heatmap and privacy | Pre-rendered tiles on a CDN | Batch job, normalisation, opt-outs | Distinct-contributor thresholds, windows, zoom gating, delete propagation, the 2018 lesson |
Follow-up questions
What if a professional cyclist with 4 million followers uploads?
Pushing 4 million feed inserts for one ride uses the whole fan-out fleet’s peak throughput for about a minute (4M ÷ 62,500/s ≈ 64 s) and fills caches with an item most followers never see. Use the hybrid from Design a news feed: push for ordinary athletes, and for accounts above about 10,000 followers write the activity only to an “authored” list that followers merge in at read time. Kudos on that ride become a hot counter: buffer increments and flush every few seconds rather than updating one row thousands of times a minute.
What if someone creates a new segment — who goes on its leaderboard?
A new segment starts with an empty board, and athletes expect their past efforts to appear. Run a backfill: find historical activities whose tracks pass its start, using the lake partitioned by region and a covering of the start cell, verify them with the same matcher code in a Spark job, and write efforts through the normal effort-events path at low priority. Rate-limit it: a segment drawn on the Bandra–Worli Sea Link would pull in an enormous history.
What if an athlete imports ten years of activities at once?
Treat it as bulk work. Put the files on the low-priority lane with a per-athlete limit, skip feed fan-out and notifications for activities older than a few days, and let efforts land with their original dates so they update all-time and yearly boards but not “today”. The athlete sees a progress bar; nobody else’s Sunday run waits behind the import.
What if an athlete deletes their account?
Every derived copy has to go, and you have to prove it. Mark the athlete deleted; delete activities, which turns every effort ineligible and removes them from boards through the normal re-read path; purge feed entries; tombstone their rows in the lake so compaction rewrites those files; let the next monthly heatmap build drop them; and purge cached CDN tiles if the deletion is urgent. Track each dataset’s completion in a deletion ledger, and delete the raw files last, after a short grace period.
What if one segment’s board grows to a million rows?
One Cassandra partition per segment then becomes large and slow to read in full. Split the partition by board type (overall, each age group, each year in its own partition), let “today” and “this week” rows expire, and serve the overall board from a cached copy so reads never page through the whole partition. Precompute counts per board — “1,248 of 98,310” — as aggregates updated from the board-change stream, rather than counting rows on every view.
How to say it in the interview
“I’ll design Strava for 200 million registered athletes. I’ll assume 10 million uploads a day — about 120 a second on average and around 600 at the Sunday-morning peak — each about an hour long at one point a second, so 3,600 points and roughly 100 to 150 kilobytes of FIT file.
Uploads are accepted fast and processed later. The API writes the raw file to object storage, dedupes by its hash, puts a message on a queue and returns 202. Workers parse, clean and summarise the activity, simplify the line with Douglas–Peucker for maps, and publish an ‘activity processed’ event. Workers autoscale on queue lag, so the Sunday spike becomes a short backlog, not failed uploads.
Segment matching covers the track with S2 cells and looks up segments whose start falls in those cells — the index is about 640 megabytes, so every matcher holds it in memory. Each candidate must pass the start gate, stay in a corridor and pass the end gate, and I interpolate between points to time it.
Leaderboards are where correctness matters. Each athlete has one slot per board, so I publish effort changes to Kafka keyed by segment and athlete: one consumer owns each slot, no locks. The event only says ‘this changed’; the consumer re-reads the efforts and recomputes the slot, so out-of-order or duplicate events are harmless. The efforts table writes an outbox row in the same transaction so no change is lost. Boards live in Cassandra, one partition per segment sorted by time, with a cache for ranks and for following or club filters computed at read time.
Feeds fan out on write, with pull for athletes with huge followings. Streams go to a Parquet lake for analytics, and a monthly Spark job builds the heatmap. There I apply the 2018 lesson: aggregated isn’t anonymous in sparse places, so I suppress any cell with fewer than k distinct athletes, keep a two-year window, honour opt-outs at each rebuild, and limit street-level zoom to signed-in users.”
Concepts used in this design
- Geospatial indexes: geohash, quadtrees, S2 and H3 — cells and coverings for segment candidates.
- Proximity search — “what is near this track” as a set of cell lookups.
- Blob storage (S3) — raw files, per-activity streams, meta-tiles and key prefixes.
- Large file uploads — accepting files reliably from flaky mobile connections.
- Message queues — absorbing the Sunday spike between upload and processing.
- Kafka — keyed partitions that give each leaderboard slot one writer.
- Message ordering: per-key queues — why ordering per key is enough.
- Effectively-once: idempotent consumers — re-reading the truth makes replays harmless.
- Transactional outbox — commit the effort and its event together.
- Idempotency — the file hash as the upload’s key.
- Cassandra and DynamoDB — a partition per segment, sorted by elapsed time.
- Redis — sorted sets for cached boards and ranks, lists for feeds.
- Design a live leaderboard — ranking structures and time-window boards in depth.
- Design a news feed — fan-out on write, on read, and the hybrid.
- OLTP vs OLAP: warehouses and columnar storage — why Parquet beats a billion small objects for scans.
- Batch vs stream processing — events for the product, monthly batch for the heatmap.
- Count-min sketch and HyperLogLog — counting distinct athletes per cell.
- Content delivery network — serving heatmap tiles.
- Autoscaling done right — scaling workers on queue lag.
- Design Google Maps and Design Uber — the tile pyramid and noisy GPS in sibling problems.
References and further reading
- About Stravastrava.comCurrent scale: over 200 million athletes in more than 185 countries.
- Rebuilding the Segment Leaderboards Infrastructure, Part 1: Background (Strava Engineering)medium.comLeaderboard dimensions, the SQL → Redis → Scala history, 1.4M uploads/day and 75–100 efforts/s, 60 Redis nodes, 100 shards by segment, and why locks plus retries failed.
- Rebuilding the Segment Leaderboards Infrastructure, Part 3: Design of the New System (Strava Engineering)medium.comEffort-mutation events as notifications, re-reading canonical state, the Cassandra primary key, the user_id secondary index, and why rank needs a cache.
- From Data Streams to a Data Lake (Strava Engineering)medium.com150 TB of per-activity gzipped JSON on S3, the $5,000 full-scan problem, hot sequential prefixes, and the 15× smaller Parquet lake.
- Building the Global Heatmap (Strava Engineering)medium.comSpark rewrite, input filters, zoom-16 path rasterisation, CDF normalisation across tiles, and serving one byte per pixel through a CDN.
- Fitness tracking app Strava gives away location of secret US army bases (The Guardian, 28 January 2018)theguardian.comHow analysts found military bases and their internal layouts in the heatmap released in November 2017.
- Heatmap Updates (Strava, 2018)blog.strava.comMonthly rebuilds, opt-outs removed at the next update, a two-year window, no heat until several athletes, street-level zoom only for registered athletes.
- Segment Matching Issues (Strava Support)support.strava.comMatching uses the recorded points closest to a segment’s start and end, tolerates GPS drift with a buffer, and makes matches in hidden map areas private.
- How Do I Edit My Map Visibility on Strava? (Strava Support)support.strava.comHidden start and end points: 200 m by default after the first upload, up to a mile, per address or per activity.
- Analysis of Privacy Protections in Fitness Tracking Social Networks (Hassan, Hussain and Bates, USENIX Security 2018)usenix.orgAn attack that recovers homes hidden by endpoint privacy zones for 95.1% of moderately active users, and the fuzzing countermeasure.