Design Tinder: Geo Feeds, Swipes and Mutual Matches
Design Tinder: a geo-indexed candidate feed, a swipe firehose sharded by swiper, derived matches with deterministic IDs, and a Bloom-filter seen set.
Design the core of a location-based dating app: a user sees a feed of nearby candidate profiles, swipes like or pass on each, and when two people like each other they match and can start talking. In scope: the candidate feed, recording swipes and detecting mutual matches. Out of scope: post-match messaging (a separate service downstream of a match; see the chat design practice round), photo storage (large blobs behind a CDN), ranking quality, payments and identity verification.
Clarifying questions
- What counts as a match? Both users like each other (double opt-in); one like alone is not a match.
- Do we build chat? No; this design stops at "two users are now matched".
- How fresh must the feed be? Seconds to minutes of staleness is fine; a new nearby user needn't appear instantly.
- What's the scale unit? Swipes per day, not profile views; that number sizes the system.
- Ranking or payments? Out of scope; assume a ranker already scores each candidate.
What makes this problem distinctive
It looks like nearby search (find profiles near me, filter by preference), but that's the smaller half. Every swipe is a write, one per card shown, so the feed is read far less often than its contents are acted on: the swipe path, not the search path, must survive the load. Two invariants sit under that write path: a match is a derived consequence of two independent likes (detected without scanning anything), and a profile a user has already swiped must never resurface (an exclusion check cheap enough to run on every request).
Key idea: the swipe write firehose shapes this design, and every swipe must respect two invariants: a match is derived, and a swiped profile never comes back.
Key concepts
- Geospatial indexing: divide the map into cells (geohash strings or H3 hexagons) and tag each profile with its cell; "who's nearby" becomes "look up my cell and its neighbors" instead of scanning every user, the same nearby-search problem ride-hailing solves for drivers. See geospatial indexing.
- Likes and derived matches: a swipe is a one-way decision (A likes B). A match exists only when both directions are likes, so detecting it means checking whether the reverse edge exists, not scanning for pairs. If the match record were lost but both swipes remained, it could be rebuilt.
- The seen filter is a Bloom filter: keeping every swiped ID per user is exact but grows without bound. A Bloom filter (a bit array plus a few hash functions) answers "definitely not seen" or "probably seen". It never gives false negatives, so a swiped profile is never re-shown; a false positive merely hides an unseen candidate, which is harmless.
- Shard keys and access patterns: when two queries read the same data along different axes, one shard key makes one cheap and the other a scatter across shards. Pick the axis of the more frequent pattern. See sharding.
1. Requirements
Unlike typical 100:1 read-heavy sites, Tinder is write-heavy: users act on almost everything they read.
- Candidate feed: given location and preferences, return nearby profiles the user hasn't swiped.
- Swipe: record a like or pass (the highest-volume write).
- Mutual match: when two users like each other, create a match and notify both.
- List matches: a user can see their current matches.
- Swipe write throughput: billions of writes a day without falling over.
- Match correctness: a mutual like produces exactly one match and notifies both: never zero, never two.
- Feed latency: low hundreds of milliseconds.
- No repeats: never show an already-swiped profile.
- Availability and durability: swipes and matches survive failures; the feed may degrade before the swipe path does.
The property never to compromise: match correctness under a race (two near-simultaneous likes still give exactly one match). The constraint driving everything else: swipe write throughput.
2. Back-of-the-envelope
Swipes: 50M DAU × 100 swipes/day = 5 billion swipes/day
5×10⁹ ÷ 86,400 s ≈ 58,000 writes/s average
× 3 evening peak ≈ 174,000 writes/s
Feed: 50M DAU × 20 feed pulls/day = 1 billion reads/day ≈ 11,600 reads/s (≈ 1/5 of writes)
Storage: swipe row ≈ 32 bytes (swiper, swipee, direction, ts)
5×10⁹ × 32 B ≈ 160 GB of raw swipes per day, before indexes
Seen filter at 1% false positives: bits/entry ≈ −log₂(0.01) / ln 2 ≈ 9.6 bits (vs 64 bits for an exact ID)Matches need two likes, so they're a trickle compared with swipes; the matches list and notifications never approach swipe volume. Size for the writes.
3. API
Every endpoint takes the acting user from the authenticated session, never from the body.
| Endpoint | Request | Response |
|---|---|---|
GET /v1/candidates?limit=20 | (session user; location and preferences server-side) | { "candidates": [{ "user_id": "u_88", "name": "Sam", "age": 29, "distance_km": 3.2, "photos": ["https://cdn…"] }, …] } |
POST /v1/swipes | { "swipee_id": "u_88", "direction": "like" } | { "matched": true, "match_id": "m_7f3…" }, or matched: false (a later match arrives by push) |
GET /v1/matches | (session user) | { "matches": [{ "match_id": "m_7f3…", "user": {…}, "created_at": "…" }] } |
Should the match check block the swipe? Swipes return immediately; if the reverse like already exists, the match is returned inline; otherwise a match discovered later arrives as a push notification.
4. Data model
| Entity | Fields | Key / notes | Lives in |
|---|---|---|---|
| User | user_id, name, birthdate, gender, preferences, location, geo_cell | PK user_id; geo_cell derived from coordinates | Durable, replicated store |
| Geo index | cell_id → set of user_ids | Inverts geo_cell so search is a few cell lookups | Durable, replicated |
| Swipe | swiper_id, swipee_id, direction, created_at | PK (swiper_id, swipee_id): one row per directed pair; a re-swipe overwrites | Durable, sharded by swiper_id |
| Match | match_id, user_a, user_b, created_at | match_id = hash(min(a, b), max(a, b)): identical from either side | Small durable table |
| Seen filter | Bloom filter bits | One per user at seen:{user_id}; updated per swipe, read per feed | Cache (Redis): rebuildable from swipe history |
One user forces a geo index; one swipe forces a swiper-keyed table; one mutual pair forces a deterministic match ID; one "never re-show" rule forces a rebuildable seen filter.
5. High-level design, one fix at a time
Naive: one database for users and swipes. The feed is a distance query over all users, a swipe is an insert, a match check scans for the reverse row. Five things break: a full scan to find candidates; billions of swipe writes overwhelming one store; scanning for mutual likes; re-showing swiped profiles; and assembling a filtered, ranked feed live on every request.
- Geo index + candidate service: a feed request touches a handful of cells, not every row. Fixes search; swipes still overload the store.
- Swipe service sharded by swiper_id: each user's swipes land on one shard, so writes spread across shards and scale independently; it also keeps a user's exclusion data local.
- Reverse-key read for matches: when A likes B, write to shard(A), then point-read shard(B) for
(swiper=B, swipee=A). A hit means B already liked A: insert the match by its deterministic ID with insert-if-absent. No scans. - Per-user seen filter: a Bloom filter in Redis at
seen:{swiper_id}, updated on each swipe and tested by the candidate service. 10,000 profiles as 64-bit IDs in a hash set cost about 300 KB per user with overhead; a Bloom filter at 1% false positives is 9.6 bits × 10,000 = 96,000 bits ≈ 12 KB. If a cache node dies, rebuild from the user's swipe history. - Precomputed candidate queue: a background job pre-filters by geo cell and preferences into a per-user queue; a feed request pops from it, applies the fast-changing seen check, ranks and returns, with no live geo query on the hot path.
import hashlib
import math
class BloomFilter:
"""Seen filter: 'definitely not seen' or 'probably seen'. No false negatives."""
def __init__(self, capacity: int, fp_rate: float = 0.01):
self.m = math.ceil(-capacity * math.log(fp_rate) / math.log(2) ** 2) # bits
self.k = max(1, round(self.m / capacity * math.log(2))) # hash functions
self.bits = bytearray((self.m + 7) // 8)
def _positions(self, item: str):
h = hashlib.sha256(item.encode()).digest()
a, b = int.from_bytes(h[:8], "big"), int.from_bytes(h[8:16], "big")
return [(a + i * b) % self.m for i in range(self.k)] # double hashing
def add(self, item: str):
for p in self._positions(item):
self.bits[p // 8] |= 1 << (p % 8)
def __contains__(self, item: str) -> bool:
return all(self.bits[p // 8] >> (p % 8) & 1 for p in self._positions(item))
swipes = {} # (swiper, swipee) -> "like" | "pass" (sharded by swiper in production)
matches = {} # match_id -> (user_a, user_b)
seen = {} # user -> BloomFilter
def match_id(a: str, b: str) -> str:
lo, hi = min(a, b), max(a, b) # same id whichever side detects it
return hashlib.sha1(f"{lo}:{hi}".encode()).hexdigest()[:16]
def swipe(swiper: str, swipee: str, direction: str) -> bool:
swipes[(swiper, swipee)] = direction # 1. durable write on shard(swiper)
seen.setdefault(swiper, BloomFilter(10_000)).add(swipee) # 2. never show again
if direction == "like" and swipes.get((swipee, swiper)) == "like": # 3. reverse point read
matches.setdefault(match_id(swiper, swipee), (swiper, swipee)) # insert-if-absent
return True
return False
print(swipe("alice", "bob", "like")) # False: bob hasn't liked alice yet
print(swipe("bob", "alice", "like")) # True: mutual like -> match
print(swipe("bob", "alice", "like")) # True again, but still exactly one match row
print(len(matches), "bob" in seen["alice"], "carol" in seen["alice"]) # 1 True False
bf = BloomFilter(10_000)
print(bf.m, bf.k, round(bf.m / 10_000, 1)) # 95851 7 9.6 -> ~12 KB per user at 1% FP6. Deep dives
Mutual-match detection under a race
In the common case (no reverse like yet) the check is one cheap point read, safe to run inline. The race: Alice and Bob like each other in the same millisecond, each write lands before the other's reverse read, and both checks report a hit. The deterministic match_id = hash(min(A, B), max(A, B)) collapses it: both sides compute the same ID, and creating the match is insert-if-absent, so the second detection is a no-op, giving exactly one row and one pair of notifications, with no locks or deadlocks. If the swipee's shard is briefly unavailable, the like is still durably written and the check retries asynchronously: a match can be delayed, never lost. See idempotency.
-- Matches table: the primary key makes concurrent detections idempotent
INSERT INTO matches (match_id, user_a, user_b, created_at)
VALUES (:match_id, :lo, :hi, now())
ON CONFLICT (match_id) DO NOTHING; -- second detector is a no-op; only the inserter notifiesThe swipe firehose and hot profiles
Sharding by swiper_id spreads writes evenly: a popular user's admirers write to their own shards. But each admirer's like triggers a reverse-lookup read on the popular user's shard ("has she liked me back?"), concentrating reads there. Reuse the Bloom-filter trick: keep a small filter of each user's outgoing likes, cached or replicated off the owning shard. A popular user has liked only a tiny fraction of admirers, so the filter answers "definitely not" for almost all of them without touching the shard. See hot partitions.
Candidate generation: order the filters cheap to expensive
- Geo: query the user's cell plus neighbors (boundary-aware), narrowing millions of users to a local set.
- Preferences: age, distance, gender, ideally mutual (keep only candidates whose own preferences admit this user).
- Seen filter: test each survivor against the Bloom filter; no false negatives means a swiped profile never comes back.
- Rank by recency, activity and the ranker's compatibility score, then store in the per-user candidate queue so a swipe session is served from memory and refilled in the background.
Degrade gracefully: if ranking is down, fall back to distance-sorted candidates; a brief seen-filter outage may resurface a few profiles (annoying, not a correctness failure). Monitor candidate-queue hit rate, empty-feed rate and geo-query latency. A weak answer queries everyone nearby unfiltered; a good one applies geo, preference and seen filters then ranks; a great one orders them cheap-to-expensive, states the filter's guarantee, and precomputes with fallbacks.
7. Variants
- 10× scale: more swipe shards, more batching per shard, and the match check fully behind an async queue so a slow reverse read never adds swipe latency. Shard the geo index finer in dense metros, where one cell can hold more users than a shard should (the same hot-cell problem ride-hailing has).
- Global distribution: dating is geo-local, so partition by region: per-region profiles, geo index, swipe shards and matches. Travel mode ("show me people where I'm visiting") is the rare cross-region case. See geo-replication.
- Recommendation quality: ranking is its own ML system that learns from the swipe stream and scores candidates at generation time; treat it as a dependency. See recommendation systems.
The transferable pattern
Separate a write-heavy stream of independent events (swipes) from a read path over derived state (matches, feed). Detect a relationship between two entities with a reverse-key point read plus a deterministic ID and insert-if-absent, not a scan. Enforce "never show again" with a probabilistic set whose error runs in the safe direction. The same shape fits anywhere two independent actors must discover a mutual condition without a coordinator: mutual follows, friend requests, two-sided marketplaces.
- Two paths: a swipe write firehose and a candidate-feed read; the firehose sizes the system.
- Nearby candidates are a geospatial-indexing problem.
- A match is a mutual like detected on write with a reverse-key read, never scanned for.
- A Bloom filter guarantees a swiped profile is never shown again.
- Matches are rare; swipes are not.
Where it’s used
Tinder, Bumble and Hinge: geo-filtered feeds, swipe streams and mutual matching.
Mutual follows and friend requests use the same reverse-edge check.
Two-sided matching (riders and drivers, candidates and recruiters) with derived matches.
Bloom-filter "already seen" sets keep feeds from repeating items.
In an interview
- Show that swipes, not feed reads, size the system: ~58K writes/s average, ~174K at peak.
- Shard swipes by swiper_id, detect matches with a reverse point read, and make match creation idempotent with a deterministic ID.
- Use a per-user Bloom filter for the seen set and explain why its error direction is safe.
- Precompute candidate queues with filters ordered cheap to expensive, and degrade gracefully.