Design a swipe-based dating app
Open in the interactive app: practice, quiz, mock interview →In one minute
- Recommendations are a filtered geo query, sharded by place. Partition the candidate index by population-balanced S2 cells and query only the few shards your radius touches.
- Never show the same face twice. Keep a compact per-user "seen" set (a Roaring bitmap or Bloom filter) and push it into the index as an exclusion filter.
- Swipes are the firehose; matches are rare and must be exact. Swipes go to an append-friendly store, but a match must be created exactly once, even if both people swipe at the same instant.
- Key the match decision on the pair, not on either person. Store both directions of a pair in one record keyed by
(min(a, b), max(a, b))and set your side with a conditional write. Whoever completes the pair creates the match, with no lock. - Serve the deck in batches. Fetch 50 to 100 ranked candidates at once and refill in the background, so swiping never waits on the index.
Requirements
Functional
- Users create a profile with photos, age, gender, and preferences (age range, genders shown, maximum distance).
- The app shows a stack of nearby profiles that match the user's preferences. The user swipes right (like) or left (pass).
- When two users like each other, both are told immediately and a chat opens between them.
- Users can unmatch, block and report. A blocked or unmatched user never appears again.
- Free users have a daily like limit; paid users do not.
Non-functional
- Swipe acknowledgement under 100 ms p99. The next card is already on the device.
- A match is created exactly once and both users are notified at least once.
- A swiped profile is not shown again for a long time (we will say 90 days for passes, never for likes).
- Location is private: other users see only a rounded distance, never coordinates.
- Highly available swiping; recommendations may be minutes stale.
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.
- DAU: 50M. Each swipes about 60 times a day: 3B swipes a day.
- Swipe QPS: 3B / 86,400 ≈ 35k/s average. Usage peaks in local evenings, so peak is about 3× average: roughly 100k swipes/s.
- Matches: if 40% of swipes are likes and about 2% of likes are reciprocated, that is 3B × 0.4 × 0.02 = 24M matches a day ≈ 280/s. Matches are four orders of magnitude rarer than swipes.
- Deck requests: a deck of 50 cards lasts about one session. At 60 swipes a day that is about 2 refills per user per day: 100M queries a day ≈ 1.2k/s average, 4k/s at peak. Each query touches a handful of index shards.
- Swipe storage: a swipe row (two 8-byte IDs, direction, timestamp) is about 30 bytes, about 60 bytes with index overhead: 3B × 60 B = 180 GB a day. Passes expire after 90 days (16 TB resident); likes are kept, 1.2B a day × 60 B × 1,825 ≈ 130 TB over five years.
- Profiles: 100M stored profiles at 2 KB of metadata is 200 GB, small enough to keep fully in a search index with replicas. Photos: 6 per profile at 300 KB in several sizes is about 2 MB per profile, 200 TB total, served from a CDN.
- Bandwidth: each swipe preloads about 150 KB of photos: 100k/s × 150 KB = 15 GB/s at peak, all from CDN edges.
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 --> IDXWrite path (a swipe).
- The app shows the next card instantly from its local deck and sends the swipe in the background.
- The swipe service verifies the
rec_token, checks the daily like counter, and writes the swipe row (idempotent onclient_swipe_id). It adds the target to the user's seen set. - 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_idyet, it assigns one in the same conditional write and returnsmatch. - A match event goes to a queue. Consumers write
matches_by_userfor both users, create the chat channel, and notify both: a socket message if they are online, a push notification otherwise.
Read path (a deck).
- The app asks for recs when its local deck drops below about 10 cards.
- 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.
- 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
fsyncevery 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 newerv2, 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
- Candidates must be near the user, and density varies wildly, so shard the index by population-balanced geographic cells rather than by user ID.
- 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.
- 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.
- Users swipe faster than any backend round trip, so the deck is fetched in batches and preloaded on the device.
Failure modes
- 10x load (Sunday evening, or a launch in a new country). Deck refills spike first. Serve cached decks longer, lower the deck size, and shed the expensive personalisation step before the geo query. Swipes keep flowing because they only need the swipe store and, for likes, one pair write.
- Hot shard. A festival puts 200,000 active users in a few square kilometres. Their shard sees concentrated queries and documents. Split the S2 range for that shard into smaller cells and rebalance, add replicas for read load, and cap query radius in the event area.
- Region outage. Users are served from the nearest healthy region. Pairs are partitioned by pair key with cross-region replication; if two people in different regions like each other during a partition, the conditional writes may land in different regions. Route pair writes to the pair's home region, and if that region is down, queue likes durably and evaluate them when it recovers. A match a few minutes late is fine; a duplicate match or a missed one is not.
- Notification failure. Push providers drop notifications. The match list is the source of truth, so the app reconciles on open.
- Location abuse. Researchers have shown that precise distances let an attacker trilaterate a user (Include Security, 2014). Rounding the output distance is not enough on its own: an attacker can move a fake position until the rounded value flips and so recover exact distances (The Register). Snap both users' coordinates to a coarse grid before computing distance, never send coordinates or unrounded distances to clients, and rate-limit location changes so a fake GPS cannot probe positions.
In the wild
- Tinder, 2019: geosharding Elasticsearch by load. Tinder had run recommendations for years on one Elasticsearch index with the default five shards, adding replicas and bigger nodes as shards grew. It scored the load of each S2 cell, packed cells in Hilbert order into 40 to 100 geoshards of roughly equal load, and moved users between indices as they crossed boundaries. Production measurements showed the geosharded index handling 20 times the computation of the single index (Tinder Tech Blog).
- Tinder, 2013–2014: distance leaks location. After an earlier bug sent matches' exact coordinates to the app, Tinder switched to sending distances, but the
distance_mifield was a 64-bit double with 15 decimal places. Include Security trilaterated users to within about 100 feet from three vantage points, reported it on 23 October 2013, and saw high-precision values stop by 1 January 2014. The researchers recommended computing locations server-side and returning only coarse values (Include Security). - Bumble, 2021: rounded distances still leak. Bumble rounded distances to whole miles, but with a floor function, so a researcher moved a spoofed position until the reported mile flipped, found three such points to about 0.001 miles, and trilaterated a target in about 10 seconds. Reported on 15 June 2021, it was fixed on 18 June; the researcher had proposed rounding coordinates before computing distance (The Register).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| One global geo index | Simple, one query | Visits every shard on every query | Small user base |
| Fixed geohash grid shards | Easy mapping | Unbalanced by population | Uniform density |
| S2 geoshards balanced by load | Few shards per query, balanced | Rebalancing logic, boundary moves | Global scale with dense cities |
| Check-then-write for likes | Simple code | Misses matches under races | Never at scale |
| Pair row with conditional write | Exactly-once match, no locks | Two partitions to read for match lists | Mutual relationships |
| Roaring bitmap seen set | Exact, compact, fast intersects | Needs a custom index filter | Large seen histories |
| Bloom filter seen set | Smallest, constant size | False positives hide unseen profiles | Memory is the limit |
Leadership lens
- Build first: the swipe store, the pair-row match logic with idempotent notifications, and a simple single-region geo query. Geosharding and the custom exclusion plugin come when the index outgrows one cluster's query budget.
- Team ownership: a recommendations team owns the index, sharding and ranking; a core platform team owns swipes, pairs and matches; a trust and safety team owns blocks, reports,
rec_tokenvalidation and location privacy; a messaging team owns chat after the match. - Cost drivers: photo storage and CDN egress dominate, followed by the search cluster's RAM. Swipe storage is cheap if passes expire.
- Rollout: introduce geosharding one region at a time with dual reads (old index and new shards) and compare result sets before cutting over. Ranking A/B tests must watch both sides of the market: helping swipers can starve the people being swiped on.
- Review questions: what happens when two people like each other within one millisecond; how long can a deck be stale before users see someone who blocked them; how is a lost seen set rebuilt.
Check yourself
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 setsmatch_id.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.
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.
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
- Geosharded Recommendations Part 1: Sharding Approach (Tinder Tech Blog): load scores per S2 cell, how many geoshards, and the cost of moving users between them.
- How I was able to track the location of any Tinder user (Include Security): the trilateration write-up, with the disclosure timeline.
Systems docs
- S2 cell statistics: average, minimum and maximum cell area for every level, for choosing shard granularity.
- DynamoDB condition expressions: conditional puts and updates, including
attribute_not_exists, the building block for the pair row. - cassandra.yaml: paxos_variant: the round-trip cost of lightweight transactions under v1 and v2.
- RoaringBitmap: the chunked container format and the libraries that implement it.
Books
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, Ch 6 "Partitioning" and Ch 9 "Consistency and Consensus": partitioning by key range versus hash, and why a compare-and-set on one record gives a uniqueness guarantee.
- System Design Interview, Volume 2, Alex Xu and Sahn Lam, Ch 1 "Proximity Service" and Ch 2 "Nearby Friends": geohash, quadtree and S2 indexes, and location updates at interview depth.
Other curricula
- Azure Architecture Center: Sharding pattern: frames geoshards as a geographic strategy that skews load where users cluster, and recommends combining it with lookup or hash sharding inside each region, which is what load-balanced S2 ranges do.
- donnemartin/system-design-primer: has no dating-app solution, but its sharding section names a user's geographic location as a common shard key and warns that power users make shards lopsided and rebalancing complex.
Watch
- System Design: Tinder (System Design Fight Club): a full walkthrough of recommendations, swipes and matches.
- Design a Dating App like Tinder/Bumble (BitStorm): focuses on swipe volume and the path from swipe to match.
Free resources
- Tinder's migration to Elasticsearch 8 (Life at Tinder): how recommendations run on a geo-sharded Elasticsearch cluster with an in-house scoring plugin.
- Tinder customer story (Elastic): the Roaring bitmap exclusion filter and geo-indexed retrieval gains.
- S2 cell hierarchy (S2 Geometry): cell IDs, levels and the Hilbert curve ordering behind geoshards.
- Consistently faster and smaller compressed bitmaps with Roaring (Lemire et al.): the paper behind the seen-set representation.
- Designing Tinder (High Scalability): an end-to-end design write-up covering geosharded indexing and match notification.
- Geo-distance query (Elasticsearch docs): the filter each geoshard query relies on.