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.

Case study32 minLesson 5 of 8
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

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

The first five minutes
Interviewer

Design a fitness tracking app, for runs and bike rides.

You

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?

Interviewer

Yes to both. Recording must work fully offline. Live sharing is opt-in, mostly for safety.

You

Scale? How many activities a day, how long are they, and how many followers does a typical user have?

Interviewer

About 10 million activities a day, 45 minutes on average. Most users have tens of followers; a few pro athletes have millions.

You

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?

Interviewer

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.
Estimate: Trace storage for a year

How much storage do the GPS traces need per year if you keep every point?

You know
  • 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
  1. Raw: 2,700 × 20 B = 54 KB per activity
  2. Compressed: 54 KB ÷ 5 ≈ 11 KB
  3. Per day: 10 million × 10.8 KB ≈ 108 GB
  4. 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.

Estimate: If every point were a row

If you stored each GPS point as its own database row, how many rows would you insert per day?

You know
  • 10 million activities a day
  • 2,700 points per activity

Show the working
  1. 10,000,000 × 2,700 = 27,000,000,000 rows a day
  2. 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.

Your turn: Define the API

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

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

Your turn: Model the data

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

Records
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 update

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

Your turn: High-level design

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.

The activity service stores the file and the row, then hands off. Workers compute stats, segment efforts and feed entries afterwards.Phone app to API gateway (PUT, GET /feed); API gateway to Activity service; Activity service to Trace files (raw file); Activity service to Activity DB (insert if absent); Activity service to Uploaded events (uploaded), asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB (stats, efforts); Processing workers to Feed store (fan-out); Activity service to Feed store (read feed).Phone appAPI gatewayActivity serviceTrace filesActivity DBUploaded eventsProcessingworkersFeed storePUT, GET /feedraw fileinsert if absentuploadedstats, effortsfan-outread feed

request and replyasynchronous message

The activity service stores the file and the row, then hands off. Workers compute stats, segment efforts and feed entries afterwards.

Step through a run recorded offline, an upload whose reply is lost, and a friend reading the feed.

Record offline, upload twice, appear onceUse the arrows or ← → keys
  1. Record offline, upload twice, appear once, step 1Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone appofflineAPI gatewayActivity serviceTrace filesActivity DBUploaded eventsProcessingworkersFeed store
    points saved
    1,200

    1Leo presses Start on a trail with no signal. The phone makes id a7f3 and writes points to a local file, flushing every few seconds so a crash loses seconds, not the run.

  2. Record offline, upload twice, appear once, step 2Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone app1 to uploadAPI gatewayActivity serviceTrace filesActivity DBUploaded eventsProcessingworkersFeed store
    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.

  3. Record offline, upload twice, appear once, step 3Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone appAPI gatewayActivity serviceTrace filesActivity DBUploaded eventsProcessingworkersFeed store

    3Signal is back. The phone sends PUT /activities/a7f3 with the file and its hash.

  4. Record offline, upload twice, appear once, step 4Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone appAPI gatewayActivity serviceTrace filesa7f3-9c1e.gzActivity DBUploaded eventsProcessingworkersFeed store

    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.

  5. Record offline, upload twice, appear once, step 5Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone appAPI gatewayActivity serviceTrace filesActivity DBa7f3: processingUploaded eventsProcessingworkersFeed store
    rows for a7f3
    1

    5It inserts the row only if a7f3 is absent, with status processing, and publishes an "uploaded" event.

  6. Record offline, upload twice, appear once, step 6Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone apptimeoutAPI gatewayActivity serviceTrace filesActivity DBUploaded eventsProcessingworkersFeed store
    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.

  7. Record offline, upload twice, appear once, step 7Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone appAPI gatewayActivity serviceTrace filesActivity DBexists: 200Uploaded eventsProcessingworkersFeed store
    rows for a7f3
    1

    7It retries the same PUT. The insert finds a7f3 already there with the same hash, so the service returns 200 with the existing activity. Still one row.

  8. Record offline, upload twice, appear once, step 8Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone appAPI gatewayActivity serviceTrace filesActivity DBa7f3: readyUploaded eventsProcessingworkersFeed store

    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.

  9. Record offline, upload twice, appear once, step 9Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone appAPI gatewayActivity serviceTrace filesActivity DBUploaded eventsProcessingworkersFeed store+50 entries
    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.

  10. Record offline, upload twice, appear once, step 10Phone app to API gateway; API gateway to Activity service; Activity service to Trace files; Activity service to Activity DB; Activity service to Uploaded events, asynchronous; Uploaded events to Processing workers, asynchronous; Processing workers to Activity DB; Processing workers to Feed store; Activity service to Feed store.Phone appAPI gatewayActivity serviceTrace filesActivity DBUploaded eventsProcessingworkersFeed store

    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.

1 / 10

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.
When the phone sends GPS data
OptionGood atBad atPick it when
Stream every point liveThe 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 defaultWorks 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 activityA 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.

Where GPS points live
OptionGood atBad atPick it when
One row per pointEasy 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 databaseCompresses 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 defaultAbout 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.

Live points take their own path: overwritten in memory, read by a few viewers, and gone ten minutes after the last update.Runner's phone to Live service (points every 15 s); Friend's phone to Live service (poll every 15 s); Live service to Live positions (set, expire 10 min).Runner's phoneFriend's phoneLive serviceLive positionspoints every 15 spoll every 15 sset, expire 10 min
Live points take their own path: overwritten in memory, read by a few viewers, and gone ten minutes after the last update.

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 diesWhat happensWhat you do
The networkNothing for recording.The upload queue on the phone retries later.
An activity serverIn-flight uploads fail.The phone retries the same PUT; the result is one activity.
The event queueRows 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 workerIts event is not acknowledged.The queue redelivers it to another worker.
The live storeViewers lose the position until the next update.Recording is unaffected; positions return within 15 seconds.

What a strong answer sounds like

LevelWhat the answer does
JuniorStreams GPS points to a server that stores them in a table, and adds a feed from a followers query.
MidRecords locally and uploads at the end, stores traces as files with summary rows, and reuses fan-out on write for the feed.
SeniorMakes 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.
A lost replyStrong answer
Interviewer

The upload reply times out. What does the phone do?

You

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.

Interviewer

What if two phones upload the same run, a watch and a phone?

You

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.

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.

Self-review: fitness tracking0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure