Engineering Atlas › System Design Interviews

Design a swipe-based dating app

21 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope: chat internals (see Design WhatsApp), payments, photo moderation models, and the ranking model itself (treated as a scoring function).

Back-of-envelope

Round assumed numbers for planning, not Tinder's published figures.

API

GET  /v1/recs?limit=50
     -> { cards: [ { user_id, name, age, distance_km: 4, photos: [url...], rec_token } ] }

POST /v1/swipe   { target_id, direction: "like" | "pass", rec_token, client_swipe_id }
     -> 200 { result: "recorded" | "match", match_id?: "m_81c2" }
     -> 429 { result: "limit", resets_at }

PUT  /v1/location   { lat, lng, accuracy_m }          (throttled on the client)
     -> 204

GET  /v1/matches?after={cursor}
     -> { matches: [ { match_id, user_id, matched_at, last_message } ], after }

DELETE /v1/matches/{match_id}                         (unmatch)
POST   /v1/block    { target_id, reason }

rec_token is a signed token issued with each card. The swipe service checks it, so a script cannot like arbitrary user IDs that were never shown to it. client_swipe_id makes a retried swipe idempotent. Distance is rounded server-side; coordinates never leave the backend.

Data model

profiles, partitioned by user_id: identity, bio, photos, birth date, gender, preferences, last_active_at. The source of truth for profile edits.

rec_index, a search index (Elasticsearch at Tinder) split into geoshards. Each document is a projection of a profile: user_id, location (geo_point), s2_cell, age, gender, interested_in, max_distance, activity_score, quality_score. The geoshard is chosen from the user's S2 cell, so a radius query only hits shards that intersect the circle.

swipes, partitioned by swiper_id, clustered by target_id: direction, swiped_at, with a 90-day TTL on passes. This is the history that builds the "seen" set and answers "did I already swipe this person".

pairs, partitioned by pair_key = min(a, b) + ":" + max(a, b): a_likes_b, b_likes_a, match_id, matched_at, state. Both directions live in one row so the match decision is a single-row atomic operation.

matches_by_user, partitioned by user_id, clustered by matched_at desc: match_id, other_user_id. The match list screen is one partition read. Written by the match consumer for both users.

seen_sets, keyed by user_id: a compressed bitmap of user IDs swiped in the retention window, rebuilt from swipes if lost.

blocks, partitioned by user_id in both directions, so both sides can filter each other forever.

High-level design

flowchart LR
  App["Mobile app"] --> GW["API gateway"]
  GW --> RS["Recs service"]
  GW --> SS["Swipe service"]
  GW --> LOC["Location service"]
  RS --> DC[("Deck cache")]
  RS --> IDX[("Geosharded rec index")]
  RS --> SEEN[("Seen sets")]
  SS --> SW[("Swipes by swiper")]
  SS --> PR[("Pairs by pair key")]
  SS --> Q["Match events"]
  Q --> NT["Notifier: push + socket"]
  Q --> ML[("Matches by user")]
  LOC --> IDX

Write path (a swipe).

  1. The app shows the next card instantly from its local deck and sends the swipe in the background.
  2. The swipe service verifies the rec_token, checks the daily like counter, and writes the swipe row (idempotent on client_swipe_id). It adds the target to the user's seen set.
  3. For a like, it performs a conditional update on the pair row that sets the caller's flag. If the update result shows both flags set and no match_id yet, it assigns one in the same conditional write and returns match.
  4. A match event goes to a queue. Consumers write matches_by_user for both users, create the chat channel, and notify both: a socket message if they are online, a push notification otherwise.

Read path (a deck).

  1. The app asks for recs when its local deck drops below about 10 cards.
  2. The recs service returns cached candidates if a fresh deck exists. Otherwise it computes the S2 cells covering the user's radius, maps them to geoshards, and sends one filtered, scored query to each shard in parallel with the user's seen set as an exclusion filter.
  3. It merges the results by score, removes blocked users, interleaves a few profiles who already liked this user, and caches 50 to 100 cards with a short TTL.

Deep dives

1. Geosharded recommendations

Option one is a single search index with a geo_distance filter. Every query then visits every shard though 99% of users are far away. Option two is a geohash grid with fixed-size cells as shard keys. Fixed cells are unbalanced: one over central Tokyo holds millions of users, the same size in rural Montana holds hundreds.

I choose geoshards built from S2 cells, the approach Tinder has described publicly. S2 projects the sphere onto a cube and numbers cells along a Hilbert curve, so cells at one level have similar areas and nearby cells have nearby IDs. Group contiguous runs of cells into shards balanced by active-user count: sparse regions get one large shard per region, dense cities get several shards covering smaller areas. A query computes an S2 cell covering for the user's circle (say 50 km), maps those cells to the two to five shards that own them, and runs the same query on each. Tinder sized this by load-testing a "load score" per cell and found 40 to 100 geoshards worldwide balanced p50, p90 and p99 latency against fan-out; the sharded index handled 20 times the computation of its old single index with five shards (Tinder Tech Blog, 2019). Elastic reports the same change cut CPU use by a factor of four (Elastic customer story). See S2 and H3 and Finding and Tracking Locations for the indexing basics.

Users move. A location update only matters when the user crosses a shard boundary or moves more than a kilometre or so, so the location service rate-limits updates and re-indexes asynchronously. A user who crosses a boundary is briefly in both shards and deduplicated on merge; that is cheaper than a two-phase move. Tinder notes that these moves are not atomic, so every extra boundary crossing is another chance for inconsistency, which is one reason not to make shards too small.

2. The mutual like, exactly once

The naive design checks "has the target already liked me?" and then writes "I like the target". If Alice and Bob swipe right on each other within the same few milliseconds, both checks can run before either write lands. Both see no prior like, both write, and the match is never created. A distributed lock fixes it but puts a lock service on the hottest write path.

The chosen design makes the pair the unit of consistency. The row keyed by min(a, b):max(a, b) holds both flags. A like is one conditional update on that row, for example a DynamoDB UpdateItem that sets a_likes_b = true and returns the new image (a failed condition rejects the whole write rather than applying part of it (DynamoDB docs)), or a Redis Lua script that does the same atomically. Because one partition serialises all writes to one pair, exactly one of the two concurrent updates observes both flags set, and that one sets match_id with a condition attribute_not_exists(match_id). A retry finds match_id already set and returns it, so the operation is idempotent.

Notification is at least once: the match event is written to a queue in the same step (or via an outbox, see Outbox Pattern), and consumers dedupe on match_id. Decide on one row, then propagate: the same shape as the last seat in Design Movie Booking System.

3. Never showing the same face twice

A typical Tinder user has acted on about 10,000 profiles and the heaviest on up to 70,000 (Elastic customer story); take a heavy user at 20,000. Sending 20,000 IDs as a must_not terms clause to every shard on every deck refill costs about 160 KB per request and a large boolean query. Options considered: filter after retrieval (wasteful: in a small town most results are already seen, so you fetch 10 times what you need), a Bloom filter per user (compact, but a false positive hides a profile the user never saw), or a compressed exact set.

I keep a Roaring bitmap of seen IDs per user and pass it to the index through a custom plugin that applies it as a filter during scoring; Elastic's customer story on Tinder credits such a Roaring bitmap exclusion filter with a 60% filtering improvement. Roaring splits 32-bit IDs into 65,536-value chunks and stores each chunk as a sorted array, a bitset or run lengths, whichever is smallest (RoaringBitmap). If memory gets tight, a Bloom filter with a 1% false positive rate is an acceptable fallback here: hiding one unseen profile in a hundred is invisible to the user, unlike showing a repeat.

Passes expire after 90 days so people who changed their photos can resurface. Blocks and unmatches are permanent and live in a separate set that is checked again on the final merge, because a stale seen set must never resurrect a blocked person.

Down the stack

Layer 1The contract

  • Swipe. Idempotent on client_swipe_id. Once acknowledged, the swipe is durable and the target will not appear in this user's future decks within the retention window.
  • Match. For any pair, a match exists if and only if both users liked each other and neither has unmatched or blocked. It is created exactly once, regardless of timing or retries. Both users receive at least one notification.
  • Deck. Every card satisfies both users' hard preferences (age, gender, distance) at the time of the query. Blocked users are never returned. Distance is reported rounded to the nearest kilometre or coarser.

Layer 2The mechanism

S2 assigns every cell a 64-bit ID: 3 bits for the cube face, then 2 bits per level down to level 30, then a trailing marker bit, so the level can be read from the lowest set bit (S2 cell hierarchy). A level 7 cell averages about 5,200 km² and a level 8 cell about 1,300 km², which brackets a city; areas at one level vary by about 2x between the smallest and largest cells (S2 cell statistics). Store the shard map with S2's string tokens rather than raw 64-bit integers: Tinder found that its Node, Java and Go libraries produced different integer forms for the same cell, while the tokens agreed. RegionCoverer turns a circle into a small set of cells at mixed levels, and because the IDs follow a Hilbert curve, each shard owns a few contiguous ID ranges, so the cell-to-shard lookup is a binary search over range starts.

Inside a shard, Elasticsearch indexes geo_point fields in Lucene's BKD tree, which answers distance and bounding box filters by pruning whole blocks of points. The query is a bool with filter clauses for distance, age range and gender (cached as bitsets, no scoring cost), the seen-set exclusion, and a function_score or plugin script for ranking. Tinder has written that over 90% of its recommendations come from a single Elasticsearch cluster with geo-sharded indices and an in-house Java scoring plugin, and that geosharding brought CPU use to 12% against 50% for one index. Its move from Elasticsearch 6 to 8 forced a rework of ranking because version 8 rejects negative scores (Life at Tinder), a reminder to keep custom scores non-negative from the start.

The pair row's conditional write is a compare-and-set on one partition: in DynamoDB, a ConditionExpression evaluated by the partition's leader replica; in Cassandra, a lightweight transaction (UPDATE ... IF) that runs Paxos among replicas for that partition and carries a real performance cost (Cassandra CQL docs).

Layer 3The machine

  • Seen set size. With 100M user IDs (27 bits), there are about 1,500 Roaring chunks. 20,000 seen IDs spread across them is about 13 per chunk, stored as 2-byte array entries: roughly 40 KB plus container headers, versus 160 KB as a list of 8-byte IDs. Intersecting it with a shard's candidate bitset is a few microseconds of SIMD work.
  • Index memory. A profile projection is about 300 bytes in the index plus doc values. 100M profiles with one replica is about 60 GB of index, small enough to keep entirely in the page cache across a modest cluster, so geo filters never touch disk.
  • Swipe write. A 60-byte swipe row in an LSM store lands in the commit log (sequential append, group-committed fsync every few ms) and a memtable. At 100k/s cluster-wide, that is 6 MB/s of log writes, trivial for NVMe; the cost is replication and compaction, not raw I/O.
  • Pair write. A conditional update needs a read and a write on the leader, about 1 to 3 ms within a zone. A Cassandra LWT costs four round trips per write under the legacy paxos_variant: v1, which is still the default, and two under the newer v2, which the docs recommend (cassandra.yaml reference). That is roughly 2 to 10 ms in one zone, which is why the pair update only runs for likes (40% of swipes) and never for passes.

Why it's built this way

  1. Candidates must be near the user, and density varies wildly, so shard the index by population-balanced geographic cells rather than by user ID.
  2. Every query must exclude what the user has seen, and that set grows forever, so represent it compactly and apply it inside the index rather than after retrieval.
  3. Swipes are huge in volume but individually unimportant, so write them to a cheap append-friendly store; matches are rare but must be exact, so decide them on one row per pair with a conditional write.
  4. Users swipe faster than any backend round trip, so the deck is fetched in batches and preloaded on the device.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
One global geo indexSimple, one queryVisits every shard on every querySmall user base
Fixed geohash grid shardsEasy mappingUnbalanced by populationUniform density
S2 geoshards balanced by loadFew shards per query, balancedRebalancing logic, boundary movesGlobal scale with dense cities
Check-then-write for likesSimple codeMisses matches under racesNever at scale
Pair row with conditional writeExactly-once match, no locksTwo partitions to read for match listsMutual relationships
Roaring bitmap seen setExact, compact, fast intersectsNeeds a custom index filterLarge seen histories
Bloom filter seen setSmallest, constant sizeFalse positives hide unseen profilesMemory is the limit

Leadership lens

Check yourself

  1. Alice and Bob swipe right on each other within the same few milliseconds. Why can a check-then-write design miss the match, and how does the pair row fix it?

    Show answer

    Both checks can run before either write lands, so each sees no prior like and the match is never created. Keying one row by min(a, b):max(a, b) with both flags makes the like a single conditional update on one partition, so exactly one update observes both flags and sets match_id.

  2. Why shard the recommendation index by population-balanced S2 cells instead of a fixed geohash grid or one global index?

    Show answer

    A global index visits every shard on every query, and fixed cells are unbalanced, with central Tokyo holding millions and rural Montana hundreds. S2 cells grouped by active-user count keep shards balanced and let a 50 km query touch only two to five shards.

  3. Why is a Bloom filter an acceptable fallback for the seen set when memory is tight, even though it has false positives?

    Show answer

    A false positive only hides a profile the user never saw, which at 1% is invisible to the user, while the failure that matters is showing a repeat. Blocks are kept in a separate permanent set checked at the final merge, so a stale or approximate seen set never resurrects a blocked person.

  4. With 50M DAU swiping about 60 times a day, how rare are matches compared with swipes, and how does that shape storage choices?

    Show answer

    That is 3B swipes a day (about 100k/s at peak) but only about 24M matches a day, roughly 280/s, four orders of magnitude rarer. Swipes go to a cheap append-friendly store, while the more expensive conditional pair write runs only for likes, never for passes.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →