Design Fitness Tracking
Design an app that records runs and rides with GPS, works offline, syncs safely, shares live location and shows a feed of friends' activities.
On this page
You start a run, put the phone in your pocket, and lose signal on a forest trail for 40 minutes. When you stop, the app shows a map of your route, your pace per kilometre and your climb. Back home on Wi-Fi it uploads, your friends see it in their feed, and you learn you were fourth fastest this week up the hill by the lake.
Interviewers ask it because the hard part is not the server. Data is born on a phone, often with no network, so a late, repeated or half-finished upload must be safe. Around that sit trace storage, live location and a social feed. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.
Clarify the problem
Design a fitness tracking app, for runs and bike rides.
I'll assume the core is: record an activity with GPS, see its stats, and share it with friends who see it in a feed. Must recording work with no signal? And is there live tracking, where a friend watches me move?
Yes to both. Recording must work fully offline. Live sharing is opt-in, mostly for safety.
Scale? How many activities a day, how long are they, and how many followers does a typical user have?
About 10 million activities a day, 45 minutes on average. Most users have tens of followers; a few pro athletes have millions.
Then my top priority is never losing a recorded activity and never storing it twice, even with bad networks and retries. Feed freshness of a minute or so is fine. Live positions should be at most about 15 seconds old. I'd leave out heart-rate sensors, training plans and payments. Should I cover segment leaderboards?
In outline.
- Functional: record an activity with GPS, offline; upload it when online; show stats (distance, time, pace, climb) and a map; follow people and see their activities in a feed; share a live position with chosen people; segment leaderboards in outline.
- Non-functional: a finished activity is never lost and never duplicated; recording never depends on the network; a new activity reaches followers' feeds within about a minute of upload; live positions at most about 15 seconds old; activity pages load in under 300 ms.
- Out of scope: sensors beyond GPS, training plans, payments, messaging.
Estimate the scale
- Points per activity: one GPS point a second for 45 minutes is 45 × 60 = 2,700 points. At about 20 bytes each (time, latitude, longitude, altitude, accuracy) that is 54 KB raw.
- Uploads: 10 million ÷ 86,400 ≈ 116 a second on average. Activities bunch on weekend mornings; assume a 5x peak: about 580 a second.
- Feed writes: if an average activity goes to 50 followers' feeds, that is 10 million × 50 = 500 million feed entries a day, about 5,800 a second.
- Live sharing: assume 50,000 people share live at peak, each sending an update every 15 seconds: 50,000 ÷ 15 ≈ 3,300 writes a second. Small.
How much storage do the GPS traces need per year if you keep every point?
- 10 million activities a day
- 2,700 points per activity, 20 bytes per point raw
- Delta encoding plus compression shrinks traces to about a fifth
Show the working
- Raw: 2,700 × 20 B = 54 KB per activity
- Compressed: 54 KB ÷ 5 ≈ 11 KB
- Per day: 10 million × 10.8 KB ≈ 108 GB
- Per year: 108 GB × 365 ≈ 39 TB, about 40 TB
Estimate 40 TB (anything from 10 TB to 150 TB is a good answer)
Tens of terabytes a year of files read whole is a job for object storage, not database rows.
If you stored each GPS point as its own database row, how many rows would you insert per day?
- 10 million activities a day
- 2,700 points per activity
Show the working
- 10,000,000 × 2,700 = 27,000,000,000 rows a day
- 27 × 109 ÷ 86,400 ≈ 312,500 inserts a second on average
Estimate 27 billion (anything from 10 billion to 80 billion is a good answer)
Every one of those rows is only ever read together with its 2,699 neighbours, which is the argument for one file per activity.
Write the upload call so that a phone can retry it any number of times without creating two activities. Then add the feed and live-sharing calls.
Compare with a model answer
The phone gives the activity its own id when recording starts, and uploads with PUT /activities/{id}. Putting the same id twice is a no-op, so retries are safe. The feed is a paginated GET; live sharing is a small POST of recent points and a GET for viewers.
Define the API
PUT /activities/{activity_id} id = UUID made on the phone at start
headers: Content-SHA256: 9c1e...
body: compressed trace file + { "type": "run", "title": "...", "visibility": "followers" }
201 Created { "activity_id": "a7f3...", "status": "processing" }
200 OK same id, same file: already stored, returns the same activity
409 Conflict same id, different file (a bug on the phone; never overwrite)
GET /activities/{activity_id} stats, simplified route line, segment efforts
GET /feed?cursor=...&limit=20 friends' activities, newest upload first
POST /live start sharing -> { "live_id": "lv_81", "viewer_link": "..." }
POST /live/{live_id}/points batch of the last 15 s of points
GET /live/{live_id} latest position + short trail, for viewers
200 { "pos": [...], "updated_at": "...", "ended": false }The decision worth arguing is who creates the id. If the server assigned it on upload, a timeout leaves the phone not knowing whether the activity exists, and a retry makes a second one. With an id made on the phone when recording starts, the upload is a PUT to a known name, and any number of retries lands on the same record. This is the idempotency key idea, with the key born before the network is even involved.
The file is small enough that resumable uploads are not needed: even a 10-hour ride is 36,000 points × about 4 compressed bytes ≈ 144 KB. Retrying the whole file is simpler than tracking chunks.
Where do the GPS points live, and what goes in the database? Think about what each screen reads.
Compare with a model answer
One compressed file per activity in object storage, read whole. One summary row per activity in a database, holding the stats and a simplified route line for feed cards. Feeds hold activity ids per user. Live positions are short-lived keys in an in-memory store.
Model the data
activities -- partitioned by user_id
activity_id UUID PRIMARY KEY -- made on the phone
user_id BIGINT
file_sha256 CHAR(64) -- detects "same id, different file"
trace_key TEXT -- "traces/u42/a7f3-9c1e.gz": id + hash
status VARCHAR(12) -- processing | ready | failed
started_at TIMESTAMP -- from GPS time, not the phone clock
uploaded_at TIMESTAMP
distance_m, moving_s, climb_m INT
route_line TEXT -- about 100 points, for cards and thumbnails
visibility VARCHAR(10) -- only_me | followers | everyone
follows (follower_id, followee_id) -- both directions indexed
feed_items (user_id, uploaded_at, activity_id) -- ids only, newest first
segment_efforts (segment_id, user_id, elapsed_s, activity_id) -- every effort
segment_bests (segment_id, user_id, elapsed_s) -- index (segment_id, elapsed_s)
live:{live_id} -> last 20 points, expires 10 min after the last updateAccess patterns decide the stores. A trace is written once and read whole when someone opens the activity, so it is a file in object storage. The summary row is what feeds, profiles and stats pages read; at about 2 KB each that is 10 million × 2 KB = 20 GB a day, about 7 TB a year, so the activities table is partitioned by user_id, because "my activities" is the most common query. A relational database or a wide-column store both work here; the unique key on activity_id is what matters.
Sketch the path from "the phone gets signal" to "a friend sees the run in their feed". Which steps happen before the phone gets its answer, and which after?
Compare with a model answer
Before the answer: store the file, insert the row if absent, publish an "uploaded" event. After: workers compute stats, match segments and fan the activity out to followers' feeds. Keeping the slow work after the answer makes uploads quick and retries cheap.
High-level design
The upload does the minimum that makes the activity safe, then answers. Everything else happens in workers that can be retried.
request and replyasynchronous message
Step through a run recorded offline, an upload whose reply is lost, and a friend reading the feed.
- points saved
- 1,200
1Leo presses Start on a trail with no signal. The phone makes id
a7f3and writes points to a local file, flushing every few seconds so a crash loses seconds, not the run.- points saved
- 2,700
2He stops after 45 minutes: 2,700 points, 11 KB compressed. Stats are computed on the phone, so he sees them at once. The upload waits in a local queue.
3Signal is back. The phone sends
PUT /activities/a7f3with the file and its hash.4The service writes the file to
traces/u42/a7f3-9c1e.gz, a key that includes the file's hash. A retry writes the same bytes to the same key; a different file could never overwrite this one.- rows for a7f3
- 1
5It inserts the row only if
a7f3is absent, with statusprocessing, and publishes an "uploaded" event.- rows for a7f3
- 1
6The 201 reply is lost when the phone switches from mobile data to Wi-Fi. The phone cannot tell whether the upload worked, so it keeps the file.
- rows for a7f3
- 1
7It retries the same
PUT. The insert findsa7f3already there with the same hash, so the service returns 200 with the existing activity. Still one row.8A worker reads the event and the file, cleans GPS jumps, computes distance and climb, simplifies the line to about 100 points, and matches segments.
- feed entries
- 50
9It writes the activity id into the feeds of Leo's 50 followers. Feed entries are keyed by follower and activity, so a redelivered event writes the same 50 entries, not 100.
10Leo's friend opens the app. The service reads her feed ids, loads the summary rows, drops any she may no longer see, and returns cards with the small route line.
Deep dives
Interviewers push on four things: recording and syncing safely, storing traces, live sharing, and how the feed differs from a general social feed.
Recording offline and syncing
Treat the phone as the source of truth until the server confirms. That gives four rules.
- Write locally first. GPS needs no data connection, so recording never touches the network. Points go to an append-only local file, flushed every few seconds. If the app is killed, it reopens the file and continues the same activity.
- Name it at the start. The phone makes a random UUID when recording starts. Every later step uses it.
- Upload from a queue. Finished activities wait in a local upload queue that retries with growing delays whenever the network returns. A file is deleted from the phone only after a 200 or 201.
- Make every server step repeatable. Store the file under a key that includes its hash; insert the row only if absent; feed entries keyed by follower and activity. Any step can run twice and the result is the same.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Stream every point live | The server always has the latest data. | Breaks with no signal, which is exactly where people run. Drains battery with constant radio use. | Never as the only path; use it only for live sharing. |
| Upload the whole file at the end Usual default | Works fully offline. One small request, easy to make idempotent with a phone-made id. | If the phone is lost or broken mid-run, the run is gone. | The usual answer: simple and offline-first. |
| Upload chunks during the activity | A lost phone loses only the last chunk. | The server must assemble chunks that arrive late and out of order, and the activity exists half-finished. | Very long events, such as a 24-hour race, where losing everything is unacceptable. |
A subtler race: a user edits the title on the website while the phone still has the activity queued with the old title. Send edits as separate small updates, and never let the upload overwrite a field already edited on the server. The trace file never changes after upload.
Storing GPS traces
Every reader takes a trace whole; nobody asks for "point 1,734 of activity a7f3". That decides the format.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| One row per point | Easy to query with SQL. | 27 billion rows a day, about 312,500 inserts a second, each with row overhead bigger than the point. | Small apps, or analytics copies, not the main store. |
| Time-series database | Compresses well and answers time-range queries. | Built for many series read by time range; here every read is one whole activity. | You need queries across many activities by time. |
| One compressed file per activity Usual default | About 11 KB each, about 40 TB a year in cheap object storage; one read gets everything. | No queries inside the file; anything you search on must be copied into the summary row. | Traces read whole. The usual answer. |
Delta encoding makes the file small: consecutive points are a few metres apart, so storing each as the difference from the previous one turns 4-byte coordinates into a byte or two. Keep the original upload as well as the cleaned version, so improved cleaning code can recompute from it.
Live sharing
Live sharing is a separate, lossy path. It must never be able to break recording.
The phone sends the last 15 seconds of points while still writing its local file. The live service overwrites a key holding the latest 20 points, with an expiry, and viewers poll every 15 seconds: 3,300 writes a second and a few times that in reads, which one in-memory store with a replica handles. Push would cut the delay, but for a few viewers and a 15-second rate, polling is simpler; see realtime communication for when push is worth it.
The safety detail interviewers like: show how old the position is. If the runner loses signal, the viewer should see "last update 6 minutes ago", not a dot that looks current. Losing the in-memory store loses only the current positions, which come back with the next updates.
The friends feed
The mechanics match Design a News Feed: fan-out on write into per-user lists of ids for most people, fan-out on read for the few pro athletes with millions of followers. Spend your time on what is different here.
- Posts arrive late. A run recorded on Saturday may upload on Monday. Order the feed by upload time, so friends actually see it, and show the activity date on the card.
- Fan-out waits for processing. The card needs distance and the route line, so the worker fans out after stats are ready, not at upload.
- Visibility changes after fan-out. Feeds store ids only, and the read path loads each row and drops activities the reader may not see. Making a run private takes effect at once, with no feed rewrite.
- Privacy zones. Users hide the first and last few hundred metres near home. Apply it when making the route line, so no public copy holds the real endpoints.
Segment leaderboards, in outline
A segment is a named stretch of road or trail. After processing, a worker looks up segments in the map cells the trace passes through, checks that the trace passes near the segment's start, follows it, and reaches its end, and records the time between those two points as an effort. Each segment's board keeps each athlete's best time, smallest first. Design a Leaderboard covers ranking at scale; most segments have small boards that a database index on (segment_id, elapsed_s) serves well.
Bottlenecks and failures
At 10x (100 million activities a day, 5,800 uploads a second at peak), the activity service stays stateless and scales out, the object store absorbs about 400 TB a year, and the activities table adds partitions by user. The first real strain is feed fan-out: 5 billion entries a day, about 58,000 a second. Raise the threshold above which an athlete's posts are fetched on read, and let fan-out lag a little at peaks.
| When this dies | What happens | What you do |
|---|---|---|
| The network | Nothing for recording. | The upload queue on the phone retries later. |
| An activity server | In-flight uploads fail. | The phone retries the same PUT; the result is one activity. |
| The event queue | Rows sit in processing; nothing reaches feeds. | Uploads still succeed. A sweeper republishes events for rows stuck over 10 minutes; workers are idempotent, so a duplicate is harmless. |
| A processing worker | Its event is not acknowledged. | The queue redelivers it to another worker. |
| The live store | Viewers lose the position until the next update. | Recording is unaffected; positions return within 15 seconds. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Streams GPS points to a server that stores them in a table, and adds a feed from a followers query. |
| Mid | Records locally and uploads at the end, stores traces as files with summary rows, and reuses fan-out on write for the feed. |
| Senior | Makes the id on the phone so every retry is safe, makes each server step repeatable, keeps live sharing on a separate lossy path, and handles late posts and visibility changes in the feed. |
The upload reply times out. What does the phone do?
It retries the same PUT /activities/a7f3. The id was made when recording started, so the server either creates the activity or finds it and returns 200. The phone deletes its copy only after one of those. Every step behind it is repeatable: file keyed by its hash, insert if absent, feed entries keyed by follower and activity.
What if two phones upload the same run, a watch and a phone?
They have different ids, so we would store two. I'd detect overlap after processing, same user and mostly the same time range, and ask the user which to keep, rather than guess.
Follow-up questions
The phone's clock is wrong. Does it matter?
Use GPS time in each point, which is accurate, for pace and ordering. The phone clock is used only for display, and the server sets uploaded_at itself.
How do you handle GPS noise?
Drop points with poor reported accuracy, reject jumps that imply impossible speed, and smooth the rest. Compute distance from the cleaned line; raw noise adds distance on every wiggle.
How would you show a heatmap of popular routes?
A nightly batch job reads public traces, counts passes per small map cell, and renders tiles from the counts. It runs on the files in object storage and never touches the live path.
How do you delete an account?
Delete the summary rows and trace files, and remove feed entries by id in the background. Readers already drop missing activities on hydration, so feeds look right at once.
Can you tell a cyclist from a runner logging a ride as a run?
Flag activities whose speed does not fit the type, for example a "run" with long stretches at 35 km/h, and keep them off running segment boards until the user confirms.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".