Scale Interview
System DesignMedium

Design Strava

Activity Tracking & Segments System Design

Overview

Strava records what an athlete did — a run or ride captured as a trail of GPS points over time — then makes it social and competitive. You go for a ride, your phone records your position every second or two, and when you finish you upload the whole thing. The app shows your route on a map with distance, pace, and elevation gained. The magic layer on top is segments: user-defined stretches of road or trail (a famous climb, a sprint straightaway) with their own leaderboards. When your ride happens to cover a segment, your time on it is recorded and ranked against everyone who has ever done it.

The product sounds like "store some GPS points and draw a line," but two parts are genuinely hard.

First, segment matching: given a freshly uploaded GPS track, which of the millions of segments in the world did it pass through? You cannot compare the track against every segment. This is a geospatial index problem, cousin to the "find nearby drivers" search in the ride-sharing walkthrough.

Second, per-segment leaderboards: each segment ranks every effort ever recorded on it by elapsed time, and a new matched effort must slot in cheaply. That is the ranking problem from the gaming leaderboard walkthrough — a sorted set per segment.

It is "Medium" because the pieces are known patterns, but the crux — matching one track against many segment geometries — is a spatial join you have to reason about carefully. In this walkthrough you'll scope it, size it, model the data, design the API, draw the ingest and segment paths, then go deep on activity storage, segment matching, and segment leaderboards.

Functional requirements

  • Record and upload an activity. An athlete records a activity (a time-ordered GPS track) and uploads it, usually after the workout finishes, not as a live stream.
  • View an activity. Show the route on a map with computed stats: distance, moving time, pace/speed, elevation gain.
  • Segments. A user defines a segment from a stretch of a track; thereafter any activity that covers that stretch produces an effort (the athlete's time over the segment).
  • Segment matching. On upload, automatically detect which segments the activity covered and record an effort for each.
  • Segment leaderboards. For each segment, rank all efforts by elapsed time — overall, and filtered (age, gender, friends) — and show the athlete "your rank."

Out of scope (state it): the social feed depth (kudos, comments), training-load analytics, and route/segment discovery UI. Naming these keeps the interview on the core: ingest a track, match it to segments, rank the efforts.

Non-functional requirements

  • Write-heavy on upload, read-heavy on view. Uploads are large but relatively infrequent; viewing activities, maps, and leaderboards is far more frequent. The two paths have very different shapes.
  • Matching can be asynchronous. Segment matching does not have to finish before the upload returns. A few seconds (even a minute) of delay before efforts and leaderboard placements appear is fine — the athlete is looking at their route first.
  • Leaderboard reads must be fast. "Top 10 on this climb" and "your rank" are hot reads and must be cheap (low-millisecond), even for a popular segment with hundreds of thousands of efforts.
  • Durable activities. A recorded activity is precious and unrepeatable — you cannot ask the athlete to redo the ride. Uploads must be durably stored and never silently lost.
  • Eventual consistency is acceptable for efforts and rank. The activity itself is the source of truth; efforts and leaderboard positions are derived and may lag a little.

Tip

Lead with "matching is async." It reframes the whole design: the upload path just has to durably store the track and return; the expensive spatial work happens off to the side. Saying this early shows you know where the latency budget matters.

Estimations

State assumptions; the goal is to justify async matching, a spatial index, and a per-segment sorted set — not to be exact.

Rounded assumptions

  • Users: ~100M registered, ~10M active/day.
  • Activities/day: ~5M (active users don't all record every day).
  • GPS points per activity: ~1–3K (a 1-hour ride at one point/second ≈ 3,600 points; call it ~2K average).
  • Segments: ~30M worldwide (they accumulate forever as users create them).
  • Efforts: an activity matches ~2–10 segments on average → tens of millions of new efforts/day.

How the numbers affect the design

Signal from the numbers Design decision
5M activities/day, each uploaded once Upload is write-heavy but not a firehose — batch upload, not live streaming.
~2K points × ~30 bytes ≈ 60 KB/activity Store the raw track as a compact blob / time-series, not one DB row per point.
30M segments, must match a track against them A spatial index over segment geometry; never scan all segments.
Matching is CPU-heavy and can wait Do it asynchronously in a worker off a queue after upload.
Tens of millions of efforts/day, hot leaderboards A sorted set per segment, updated on each new effort.

Storage

  • One activity's track ≈ ~60 KB (2K points × ~30 bytes: lat, lng, elevation, time deltas, compressed). 5M/day ≈ ~300 GB/day of track blobs — large, append-mostly, a great fit for object storage.
  • Per-activity stats (distance, elevation, etc.) are a small durable row — a few hundred bytes.
  • An effort is tiny: (segment_id, activity_id, user_id, elapsed_time, started_at) ≈ ~50 bytes. Tens of millions/day is a lot of rows but each is small and append-mostly.

Caution

Do not store each GPS point as its own database row. At ~2K points × 5M activities that is ~10 billion rows/day of data you almost always read back as a whole track. Store the track as one compressed blob (or a time-series chunk) keyed by activity, and keep only summary stats in the relational store.

Core entities / data model

Entity Key fields
User user_id (PK), name, age, gender, privacy settings
Activity activity_id (PK), user_id, type (run/ride), started_at, distance, moving_time, elevation_gain, track_ref (pointer to the blob)
ActivityTrack the raw GPS track — a compressed blob in object storage, keyed by activity_id
Segment segment_id (PK), name, polyline (its geometry), start_point, end_point, bbox, index cells
Effort effort_id (PK), segment_id, activity_id, user_id, elapsed_time, started_at
SegmentLeaderboard a sorted set per segment: member = user_id, ordering key = elapsed_time (lower is better)

Three stores with different jobs:

  • The Activity store is the durable source of truth: a relational row per activity with its stats, plus the raw track as a blob in object storage (referenced by track_ref). This must never be lost.
  • The Segment store holds segment geometry and, critically, the spatial index used to find candidate segments for a track (deep dive 2). Segments change rarely; this is read-heavy.
  • The Effort store + leaderboards: efforts are durable rows (the authoritative record that "user U did segment S in time T"); the per-segment sorted set is a derived, in-memory ranking rebuildable from the efforts (deep dive 3).

Note

The track blob and the activity row are split on purpose: the row is small, queryable, and relational (list my activities, sum my weekly distance); the track is a big opaque blob you fetch only when rendering one activity's map. Keeping them together would bloat every activity-list query with data it never uses.

API design

Four groups: uploading an activity, viewing it, creating segments, and reading segment leaderboards. We label the request payload Body: and the response Returns:.

Upload an activity

POST /api/activities
Body: multipart file (GPX/FIT) OR { "points": [ {"lat":..., "lng":..., "ele":..., "t":...}, ... ] }
201 Created
Returns: { "activity_id": "...", "status": "processing" }

The client uploads the recorded track (a GPX/FIT file or a JSON point array). The server durably stores the raw track, creates the Activity row, computes stats, and enqueues segment matching — then returns immediately with status: processing. Efforts appear moments later; the response does not block on matching.

Get an activity

GET /api/activities/{activity_id}
200 OK
Returns: { "activity_id": "...", "stats": {"distance_m":..., "elevation_m":..., "moving_time_s":...},
           "polyline": "<encoded>", "efforts": [ {"segment_id":..., "elapsed_time":..., "rank":...}, ... ] }

Returns the activity's stats, an encoded polyline for the map, and the list of segment efforts (with the athlete's rank on each). The full high-resolution track is fetched separately if the client needs every point.

Create a segment

POST /api/segments
Body: { "activity_id": "...", "start_index": 120, "end_index": 540, "name": "Old Mill Climb" }
201 Created
Returns: { "segment_id": "...", "distance_m": 2100 }

A segment is carved out of an existing activity's track — the athlete picks a start and end point along it. The server stores the segment geometry, computes its bounding box, and inserts it into the spatial index. (Optionally it back-matches existing activities to seed the leaderboard.)

Get a segment leaderboard

GET /api/segments/{segment_id}/leaderboard?filter=overall&limit=10
200 OK
Returns: { "entries": [ {"rank":1, "user_id":"...", "elapsed_time": 312}, ... ],
           "me": {"rank": 84, "elapsed_time": 401} }

Return the top N efforts by elapsed time, plus the caller's own rank. filter selects overall, age/gender group, or friends (deep dive 3). This is the competitive payoff and the hottest read.

High-level architecture

Two paths matter and they look very different: an upload/ingest path (durably store the track, compute stats, return fast) and an async segment path (match the track to segments, record efforts, update leaderboards). Reads (view activity, view leaderboard) hang off the stores those paths populate.

1. Upload and ingest path

The upload must durably capture the track and return quickly; the heavy spatial work is deferred.

flowchart LR
    App[Athlete app] -->|"upload track file"| Ingest[Upload Service]
    Ingest -->|"store raw track"| Blob[(Object storage - track blobs)]
    Ingest -->|"activity row plus stats"| ActDB[(Activity store - durable)]
    Ingest -->|"enqueue match job"| Queue[[Match queue]]
    Ingest -->|"201 processing"| App
  1. The app uploads the recorded track (after the ride, as a file or point array).
  2. The Upload Service writes the raw track to object storage and creates the durable Activity row, computing distance, elevation gain, and pace from the points.
  3. It enqueues a segment-matching job keyed by activity_id, then returns 201 processing — it does not wait for matching.

Warning

Do not run segment matching inside the upload request. It is CPU-heavy (a spatial join against many segments) and would make uploads slow and flaky. Enqueue it; let a worker do it. The athlete sees their route immediately and efforts fill in seconds later.

2. Async segment matching path

A worker picks up the job, finds candidate segments via the spatial index, verifies each, and records efforts.

flowchart LR
    Queue[[Match queue]] -->|"activity_id"| Worker[Match Worker]
    Worker -->|"read track"| Blob[(Object storage)]
    Worker -->|"candidate segments near track"| SegIdx[(Segment spatial index)]
    Worker -->|"verify + compute time"| Worker
    Worker -->|"write efforts"| EffDB[(Effort store - durable)]
    Worker -->|"add effort"| LB[(Segment leaderboards - sorted sets)]
  1. The Match Worker reads the activity's track from object storage.
  2. It queries the spatial index for segments whose geometry lies near the track — a small candidate set, not all 30M segments (deep dive 2).
  3. For each candidate it verifies the track actually followed the segment (start → end, right direction, within tolerance) and computes the elapsed time over it.
  4. Each verified match becomes a durable Effort row, and the effort is added to that segment's leaderboard sorted set (deep dive 3).

3. Combined architecture

flowchart LR
    App[Athlete app] -->|"upload"| Ingest[Upload Service]
    Ingest -->|"raw track"| Blob[(Object storage)]
    Ingest -->|"activity + stats"| ActDB[(Activity store)]
    Ingest -->|"enqueue"| Queue[[Match queue]]
    Queue --> Worker[Match Worker]
    Worker -->|"read track"| Blob
    Worker -->|"candidates"| SegIdx[(Segment spatial index)]
    Worker -->|"efforts"| EffDB[(Effort store)]
    Worker -->|"add effort"| LB[(Segment leaderboards)]
    App -->|"view activity"| ActDB
    App -->|"view leaderboard"| LB

The shape to notice: the upload path is short and durable (store the track, return), while the matching path is async and compute-heavy (spatial candidates → verify → efforts → leaderboards). Reads for viewing an activity hit the Activity store and blob; reads for a leaderboard hit the sorted set. The queue is the seam that lets uploads stay fast while matching takes its time.

Note

Everything downstream of the queue is rebuildable. If matching logic changes (better tolerance, a new segment), you can re-enqueue activities and recompute efforts — the raw track in object storage is the durable input, and efforts + leaderboards are derived from it.

Deep dives

1. Activity ingest and storage

An activity is a time-series of GPS points: latitude, longitude, elevation, timestamp, sampled every second or two. The question is how to store ~2K points × 5M activities/day cheaply while keeping the map render and stats fast. The key insight: you almost always read a track as a whole (to draw the route) — you rarely query "the point at 14:32:07." That argues against a row-per-point model.

Row per GPS point in a relational table (avoid — 10B rows/day, read back whole anyway)

Store one row (activity_id, seq, lat, lng, ele, t) per point, indexed by (activity_id, seq).

one 1-hour ride ≈ 3,600 rows.
5M activities/day × ~2K points ≈ ~10 BILLION rows/day.
to draw a route you SELECT * WHERE activity_id = X ORDER BY seq
  → you read the entire track back every time anyway
  → the per-row overhead (index, tuple headers) dwarfs the 30 bytes of data
  • Pro: SQL-queryable per point; simple to reason about.
  • Con: astronomical row counts, huge per-row overhead, and you never actually use the per-point queryability — you read whole tracks.
  • Verdict: avoid. You're paying for random-access to points you only ever read sequentially as a batch.
Store the track as a compressed blob / time-series chunk, stats in a row (recommended)

Serialize the whole track into one compressed blob — delta-encode timestamps and coordinates (consecutive points are close, so deltas are tiny) — and put it in object storage keyed by activity_id. Keep only summary stats (distance, elevation gain, moving time, an encoded polyline for quick map draws) in the durable Activity row.

track blob (object storage):  activity_id → gzip(delta-encoded points)  ≈ 60 KB
activity row (relational):    activity_id, user_id, distance, elevation, moving_time, track_ref
map render: fetch the row's polyline (coarse) for the feed;
            fetch the full blob only when the user opens the detailed activity
  • Pro: ~one blob write and one small row per upload; delta + gzip shrinks the track dramatically; object storage is cheap and durable; whole-track reads are one GET.
  • Con: you can't cheaply query inside a track in SQL — but you don't need to. A specialized time-series store (chunked columnar) is an alternative if you want server-side range queries over points.
  • Verdict: recommended. Blob-per-track (plus a coarse polyline on the row) matches how tracks are actually written once and read whole.

Computing stats. Distance is the summed geodesic distance between consecutive points; elevation gain sums positive elevation deltas (smoothed, since raw GPS altitude is noisy); moving time excludes stopped segments (speed below a threshold). These are computed once at ingest and stored on the row so the feed and activity page never recompute them.

Downsample for the map. Rendering every one of 2K points on a zoomed-out map is wasteful. Store a downsampled polyline (e.g. Douglas–Peucker simplification) on the activity row for feed thumbnails and overview maps; fetch the full-resolution blob only when the user zooms into the detailed view. This is the store-raw-vs-downsampled tradeoff: keep raw for fidelity, serve downsampled for speed.

Tip

Write once, read whole. Because a track is captured in one upload and almost always rendered in full, a compressed blob plus a small stats row beats a normalized point table on every axis — cost, write count, and read simplicity.

2. Segment matching — the crux

When an activity uploads, you must find which of ~30M segments its track covered. Comparing the track against every segment is hopeless. The pattern mirrors ride-sharing's proximity search — cross-link the geospatial-index discussion there — but here you match a line (the track) against many line segments, in two stages: cheap candidate generation via a spatial index, then exact verification.

Stage 1 — candidates via a spatial index. Index every segment by its location so you can ask "which segments lie near this track?" without scanning all of them.

Scan every segment and test overlap with the track (avoid — O(all segments) per upload)

For each of 30M segments, test whether the track passes through it.

for each of 5M uploads/day:
  for each of 30M segments:
    does the track overlap this segment?   → 30M geometry tests PER upload
  → 150 trillion tests/day. Never.
  • Pro: trivially correct.
  • Con: O(number of segments) per upload — completely infeasible.
  • Verdict: avoid. This is exactly what a spatial index exists to prevent.
Spatial index (geohash cells / bounding boxes) for candidate segments (recommended)

Index each segment by the geohash cells (or grid cells / R-tree bounding boxes) its geometry touches. To find candidates for a track, compute the set of cells the track passes through and look up only the segments registered in those cells.

segment index:  cell_id → [segment ids whose geometry touches this cell]
track upload:
  1. map the track's points to the set of cells they cover  (a handful of cells)
  2. union the segment lists in those cells  → candidate segments (hundreds-to-thousands, not 30M)
  3. quick bounding-box overlap filter to drop obvious non-matches

A segment is stored under every cell its geometry spans (short segments touch a few cells). The track likewise covers a handful of cells; the candidates are the segments sharing any of those cells. Size the cell to the typical segment length — at a ~1 km cell, a track spanning ~10 cells yields a candidate set in the low hundreds-to-thousands before bounding-box filtering, versus scanning all 30M. A cheap bounding box overlap test then trims the candidate set before the expensive stage 2. This is the same proximity pattern as ride-sharing — cells + neighbor awareness — applied to a line rather than a point.

  • Pro: turns O(30M) into O(candidates in a few cells) — hundreds-to-thousands per upload, trimmed to a handful after verification. Standard, well-understood (PostGIS/R-tree, geohash, S2/H3 cells).
  • Con: boundary care — a segment straddling a cell edge must be registered in all cells it touches, and the track must consider neighboring cells; false candidates still need stage-2 verification.
  • Verdict: recommended. Spatial index for candidates, then verify — the only viable shape.

Stage 2 — verify each candidate. A candidate segment being near the track doesn't mean the athlete rode it. You crossed the segment's cells; did you actually follow it?

Segment matching checks, for each candidate:

  • Start → end coverage. The track must pass near the segment's start point, then near its end point, with the segment's shape in between — not just clip a corner of its bounding box.
  • Direction. The track must traverse the segment the right way. A segment is directional; riding the same road the opposite way is a different segment (or no match). Order the matched points by time and confirm the track reaches the segment's own start endpoint before its end endpoint — that discriminates two athletes on the same two-way road going opposite directions.
  • Tolerance. GPS is noisy, so "on the segment" means "within ~10–25 m of the segment path for its whole length," not an exact overlay. Points must hug the segment polyline within tolerance throughout.
flowchart TB
    C["candidate segment (from spatial index)"] --> S{"track passes near START?"}
    S -->|no| Drop["drop - not a match"]
    S -->|yes| E{"then passes near END?"}
    E -->|no| Drop
    E -->|yes| D{"right direction + within tolerance whole way?"}
    D -->|no| Drop
    D -->|yes| M["MATCH - compute elapsed time start to end"]

On a match, compute the elapsed time = timestamp at the segment's end point minus timestamp at its start point (interpolating between GPS samples for accuracy). That time is the effort, written durably and pushed to the leaderboard.

Caution

Do not skip the direction and tolerance checks. Candidate generation only proves the track was near the segment's cells. Without verifying start-before-end, correct direction, and a distance tolerance the whole way, you record bogus efforts — someone who merely crossed the road at one point gets a fake time on the climb.

Why async. Stage 2 runs geometry checks over tens of candidates per activity, tens of millions of times a day. Doing it in a worker off the queue keeps it off the upload latency path and lets you scale workers independently and retry failures.

3. Segment leaderboards

Each segment has a leaderboard: every effort ever recorded on it, ranked by elapsed time (lowest is fastest = rank 1). A new matched effort must insert cheaply, and reads — "top 10" and "your rank" — must be fast even on a popular climb with hundreds of thousands of efforts. This is the gaming-leaderboard ranking problem; the winning structure is the same.

Rank by ORDER BY elapsed_time + COUNT(*) over the efforts table (avoid — O(rank) per read)

Store efforts in a table and compute rank on read: SELECT COUNT(*) FROM efforts WHERE segment_id = S AND elapsed_time < :my_time.

"your rank" on a popular segment:
  SELECT COUNT(*) WHERE segment_id = S AND elapsed_time < my_time
  → counts every faster effort, could be hundreds of thousands of rows
  → runs on every leaderboard open, for every viewer
  • Pro: trivial; efforts are already durable rows.
  • Con: rank is O(number of faster efforts) per read — expensive for a busy segment and repeated on every open.
  • Verdict: avoid as the live ranking path. The efforts table is a fine durable record, but not the read-time ranker.
A sorted set per segment — insert, rank-of, top-N all O(log N) (recommended)

Keep a sorted set per segment (a Redis ZSET), member = user/effort, ordering key = elapsed_time. A new effort is one ZADD (O(log N)); "top 10" is a range read of the first 10; "your rank" is a rank-of-member lookup — all logarithmic, no per-read scan. The skip-list span-count trick from the leaderboard walkthrough is what makes rank O(log N).

segment S leaderboard = sorted set { user → elapsed_time }, lower is better
new effort:      ZADD segment:S 312 user_U          (O(log N))
top 10:          ZRANGE segment:S 0 9  (ascending time = fastest first)
your rank:       ZRANK segment:S user_U  (+1 for 1-based)   O(log N)

For best-time-wins per user (keep an athlete's fastest time, not their latest), apply set-if-lower — only lower a member's stored time — so re-riding a segment slower doesn't worsen their leaderboard entry, and a retried match is idempotent.

  • Pro: insert, top-N, and rank all O(log N); the athlete's placement updates the instant their effort is matched.
  • Con: lives in memory, so it must be rebuildable — the durable Effort rows are the source of truth, and a lost sorted set is replayed from them.
  • Verdict: recommended. One sorted set per segment, driven off the durable efforts, exactly as in the leaderboard design.

Idempotency. Matching is async and jobs can retry, so a re-run must not create a duplicate effort or double-count. Make the effort write idempotent on (segment_id, activity_id) (unique key), and apply the leaderboard update with set-if-lower so replaying an effort is a no-op.

Filtered leaderboards (overall / age / gender / friends).

  • Overall is the plain sorted set above.
  • Age / gender groups are a bounded, known set of buckets — maintain a sorted set per (segment, group) so "top women 30–34 on this climb" is a direct read. Bounded fan-out (a handful of groups) makes this affordable.
  • Friends is like the leaderboard walkthrough's friends board: don't maintain a sorted set per friendship. Fetch the caller's friends' efforts for this segment (a batched multi-get) and sort that small set in the service. Friend lists are small; a per-friendship structure would explode writes for no benefit.
flowchart LR
    Eff["new matched effort (segment S, user U, time T)"] -->|"ZADD set-if-lower"| Overall["overall sorted set: segment S"]
    Eff -->|"ZADD"| Group["group sorted set: segment S, F30-34"]
    Read["GET friends leaderboard"] -->|"multi-get friends' efforts on S"| Overall
    Read -->|"sort small set in service"| Out["ranked friends"]

Popular segment / recompute. A famous climb may have hundreds of thousands of efforts, and its top-N is read constantly. Cache the top-N page (it barely moves — the same fast times sit at the top) and serve rank/around-me from the sorted set (a read replica). A new effort is still one O(log N) insert — you never recompute the leaderboard by scanning all efforts.

Warning

Do not recompute a segment leaderboard by re-sorting its efforts on each new match or each read. Maintain a sorted set updated in place per effort (O(log N)), cache the slow-moving top-N, and sort the small friends set on read.

Tradeoffs & bottlenecks

  • Live-stream vs post-upload ingest. Strava uploads the whole track after the workout, so ingest is a batch write, not a per-point firehose — far simpler than ride-sharing's live pings. Live tracking (a beacon) is a separate, opt-in path (see extensions); the core does not need it.
  • Precompute matches at upload vs match at query time. Matching at upload (async) means efforts and leaderboards are ready when anyone looks; the cost is upload-time compute and a matching backlog. Matching lazily at query time would make the first leaderboard view slow and repeat work — precompute wins because efforts are read far more than written.
  • Raw points vs downsampled. Store the raw track for fidelity and recomputation; serve a downsampled polyline for fast maps and feed thumbnails. Keeping only downsampled would lose the fidelity needed for accurate segment matching and re-matching.
  • Spatial index granularity. Coarse cells → fewer cells to check but more candidates to verify; fine cells → more precise candidates but more index entries and boundary cases. Tune cell size to the typical segment length.
  • Global vs filtered leaderboards. Overall and a bounded set of age/gender groups are affordable as maintained sorted sets; friends must be a read-time sort of a small set, never a per-friendship structure — the same fan-out trap as the leaderboard walkthrough.
  • Matching tolerance. Loose tolerance matches more (good for noisy GPS) but risks false efforts; tight tolerance is precise but misses valid rides on bad-signal days. It's a precision/recall dial, not a fixed value.

Extensions if asked

Add only the extension that changes the design discussion:

  • Live tracking / beacon. Real-time position sharing during an activity is a separate streaming path (like ride-sharing's live pings) layered on top — the recorded-then-uploaded core is unchanged.
  • Kudos, comments, social feed. A fan-out-on-write feed of friends' activities — a distinct social-graph subsystem consuming activity-created events.
  • Heatmaps. Aggregate many athletes' tracks into popularity heatmaps — a batch analytics job over the track blobs, off the hot path.
  • Route suggestions. Recommend routes from popular segments and historical tracks — an offline recommendation pipeline.
  • Cheating / GPS-spoof detection. Flag impossible efforts (superhuman speed, teleporting points, a car on a running segment) before they hit the leaderboard — a validation step in front of the effort write.
  • Privacy zones. Hide the start/end of a track near an athlete's home by trimming those points from public views — applied at render and when exposing tracks to matching.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Recognizing upload is batch, not a live stream — the track is recorded then uploaded, so ingest is durable-store-and-return, not a per-point firehose.
  • Storing the track as a compressed blob with stats on a small row, not a row per GPS point.
  • A spatial index for segment matching — candidates from geohash/grid/bbox cells, then verification — never scanning all segments.
  • The verify step — start→end coverage, direction, and distance tolerance — not just "the track was near the segment."
  • Doing matching asynchronously off a queue, keeping uploads fast and matching independently scalable and retryable.
  • A sorted set per segment for leaderboards (O(log N) insert/rank), cached top-N, and friends sorted on read — reusing the leaderboard pattern.

Before you finish, do a quick mistake check:

  • Did you upload the whole track and store it as a blob, not stream every GPS point live?
  • Did you use a spatial index to find candidate segments, instead of scanning all 30M?
  • Did you verify direction and tolerance, not just bounding-box nearness?
  • Did you run matching asynchronously off a queue, off the upload latency path?
  • Did you rank efforts with a per-segment sorted set, not COUNT(*) per read?
  • Did you make the effort write idempotent so a retried match doesn't double-count?
  • Did you cache the hot segment's top-N and sort the friends board on read?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →