Tinder
Sharpened prompt. Design a swipe-based dating app for 75M monthly users generating 2B swipes/day, where a deck of nearby, filtered, unseen profiles loads instantly, two simultaneous right-swipes reliably produce exactly one match, nobody ever sees the same profile twice, and attention is not so concentrated that 90% of users get no matches at all.
The swipe is trivial. The interesting problems are a distributed race condition, a write volume that dwarfs the read volume, and a marketplace-fairness constraint that most candidates never mention.
1. Problem framing
Functional requirements
- Serve a deck of candidate profiles filtered by distance, age, and gender preference.
- Record swipes (left/right/super); create a match when two right-swipes are mutual.
- Notify both users on match; open a chat.
- Never re-show a profile the user has already swiped on.
Non-functional requirements
| Property | Target | Consequence |
|---|---|---|
| Deck load | P99 < 100ms | Decks are precomputed, not queried live |
| Swipe write | P99 < 50ms, fire-and-forget | Write path must be a single partition operation |
| Match detection | Correct under exact concurrency | Both writes must land on the same shard |
| Dedup | Zero repeats | Compact per-user seen-set with O(1) membership |
| Write volume | 2B swipes/day | This is the dominant scaling axis, not reads |
Back-of-the-envelope
Users: 75M MAU, ~10M DAU
Swipes: 2B/day = 23,000/sec avg, ~80,000/sec peak
Matches: ~1% of right swipes are mutual -> ~26M matches/day
Deck: 10M DAU × 150 profiles/day = 1.5B profile impressions/day
Swipe rows: 2B/day × 50 B = 100 GB/day = 36 TB/year -> Cassandra, TTL the left swipes
Seen-set: 75M users × 10,000 swiped ids
- naive set of int64: 75M × 80 KB = 6 TB
- Bloom filter @1% FP: 75M × 12 KB = 900 GB
- Roaring bitmap: 75M × ~6 KB = 450 GB <- and it's exact
Notice that writes exceed reads here, which inverts the usual social-app assumption and should visibly change your storage choice. Say it.
2. High-level architecture
The design's spine is that decks are precomputed into a per-user Redis queue and swipes write to a partition determined by the user pair, not the swiper. Those two decisions solve most of the hard parts.
3. Component inventory
| Component | Concrete choice | Why this one |
|---|---|---|
| Candidate index | Elasticsearch with geo_distance, or a custom H3 index | Multi-attribute filtering (geo + age + gender + activity) is an inverted-index problem |
| Deck queue | Redis list per user, ~200 entries | Popping a precomputed deck is O(1); computing one live is not |
| Swipe store | DynamoDB or Cassandra, partition key = pair key | Co-locates both directions of a potential match on one partition |
| Seen-set | Roaring bitmaps in Redis (or a scalable Bloom filter) | Exact, compact, O(1); see 4.3 |
| Recommendation | Spark/Ray batch + a nearline updater | Batch for the bulk, nearline for freshness after location changes |
| Match events | Kafka | Fan-out to notifications, chat provisioning, and analytics |
| Location | H3 cell index, updated on significant movement only | Continuous GPS streaming is unnecessary and expensive |
4. The toughest parts
4.1 The simultaneous right-swipe
Why it's hard. Alice right-swipes Bob and Bob right-swipes Alice within the same millisecond, on different app servers, in different regions. Each server executes "record my swipe, then check whether the other person already swiped." Both checks run before either write is visible. Both see "no reciprocal swipe." No match is created, and neither user ever learns. Alternatively, with a different interleaving, both see the other's write and both create a match — now there are two match rows and two chat threads.
Alice's server: write(A→B) ──────► read(B→A): none ✗
Bob's server: write(B→A) ────► read(A→B): none ✗
(both writes land, but neither read sees the other)
Solution — make both directions land in the same partition, then use a conditional write.
def pair_key(a: int, b: int) -> str:
# Order-independent: A→B and B→A produce the SAME key, so both writes go to
# the same shard, the same partition, and are serialised by the storage engine.
lo, hi = (a, b) if a < b else (b, a)
return f"{lo}:{hi}"
async def swipe(swiper: int, target: int, direction: str):
pk = pair_key(swiper, target)
side = "lo" if swiper < target else "hi" # which half of the pair am I?
# One atomic update. Returns the item AFTER the write, including the other side.
item = await ddb.update_item(
Key={"pair": pk},
UpdateExpression=f"SET {side}_dir = :d, {side}_ts = :t",
ExpressionAttributeValues={":d": direction, ":t": now_ms()},
ReturnValues="ALL_NEW",
)["Attributes"]
# Because the write is atomic and both sides share the partition, exactly one
# of the two concurrent calls observes both directions present.
if item.get("lo_dir") == "right" and item.get("hi_dir") == "right":
# Idempotent match creation: conditional insert keyed by the pair.
created = await ddb.put_item(
Item={"pair": pk, "matched_at": now_ms()},
ConditionExpression="attribute_not_exists(pair)",
table="matches", swallow=ConditionalCheckFailed)
if created:
await kafka.send("match.created", {"pair": pk}) # fires exactly once
Two mechanisms are doing the work, and it is worth separating them explicitly. Co-location (the pair key) guarantees the two writes are serialised by a single storage node rather than racing across shards. Read-after-write in the same atomic operation (ReturnValues=ALL_NEW) means the winner of that serialisation sees the complete state. The conditional insert on the matches table then makes match creation idempotent, so even if both callers somehow observed the mutual state, only one event fires.
The alternative to name: a Redis Lua script keyed by the pair achieves the same thing with lower latency but weaker durability. For a match — which the user will notice forever — durability wins.
4.2 Building a deck of 200 nearby, filtered, unseen profiles
Why it's hard. The query is "profiles within 25 km, aged 24–32, matching gender preference, active in the last 7 days, that I have not swiped on, ordered by relevance." Running that per deck refresh at 10M DAU with a live geo query and a NOT-IN against 10,000 swiped IDs would take hundreds of milliseconds and hammer the index. Multiply by the swipe rate and it is untenable.
Solution — precompute decks asynchronously; the request path only pops from a queue.
# Nearline worker: refills a user's deck when it runs low or their context changes.
async def refill_deck(user_id: int, target: int = 200):
u = await profiles.get(user_id)
if await redis.llen(f"deck:{user_id}") > 50:
return
# Over-fetch: the seen-set filter will remove a large fraction.
hits = await es.search(index="profiles", size=target * 5, query={
"bool": {
"filter": [
{"geo_distance": {"distance": f"{u.max_km}km", "location": u.geo}},
{"range": {"age": {"gte": u.min_age, "lte": u.max_age}}},
{"terms": {"gender": u.interested_in}},
{"range": {"last_active": {"gte": "now-7d"}}},
{"term": {"is_active": True}},
],
# Mutual eligibility: they must also be looking for someone like me.
"must": [{"range": {"pref_min_age": {"lte": u.age}}},
{"range": {"pref_max_age": {"gte": u.age}}}],
}})
seen = await seen_set(user_id) # Roaring bitmap
fresh = [h.id for h in hits if not seen.contains(h.id)]
scored = await recsys.score(user_id, fresh) # relevance model
await redis.rpush(f"deck:{user_id}", *[i for i, _ in scored[:target]])
await redis.expire(f"deck:{user_id}", 3600) # decks go stale
Three points worth making. The mutual eligibility filter (they must also want to see me) is what stops the deck from being full of people who will never swipe back — it roughly doubles effective match rate and candidates rarely think of it. The 1-hour TTL exists because people move and go inactive; a stale deck shows profiles who are no longer nearby. And the refill is triggered on low watermark, so the user never waits for it.
Cost control matters here too: 10M DAU × 200-profile decks refreshed a few times a day is a lot of Elasticsearch work. Batch refills for inactive users lazily (on app open) and only run the nearline path for active sessions.
4.3 Never show the same profile twice
Why it's hard. A heavy user swipes 10,000 profiles. Checking "have I seen this?" must be O(1) and must not require storing 10,000 IDs per user in a form you have to scan. Across 75M users a naive materialised set is terabytes, and it is consulted on every candidate during every deck refill — hundreds of millions of membership tests per minute.
Solution — Roaring bitmaps, with a Bloom filter as the fallback when exactness is negotiable.
# Roaring bitmap: compressed, exact, O(1) membership, set operations in the store.
# User IDs are dense integers, which is exactly Roaring's best case.
from pyroaring import BitMap
async def mark_seen(user_id: int, target_id: int):
await redis.execute_command("BITFIELD", f"seen:{user_id}", "SET", "u1", target_id, 1)
# Or with the RoaringBitmap module: R.SETBIT seen:{user} {target}
async def filter_unseen(user_id: int, candidates: list[int]) -> list[int]:
bm = BitMap.deserialize(await redis.get(f"seen:{user_id}"))
return [c for c in candidates if c not in bm] # O(1) each
Why Roaring rather than a Bloom filter, which is the answer most candidates give: a Bloom filter has false positives, and a false positive here means a profile is permanently and silently hidden from a user. Roaring is exact, comparably compact for dense integer IDs (~6 KB for 10,000 swipes), supports fast AND/OR against candidate sets, and — unlike a standard Bloom filter — supports deletion, which you need when a user unmatches or a profile is deleted and re-created.
Use a scalable Bloom filter only for the ultra-heavy tail (users with hundreds of thousands of swipes) where the memory saving is worth a 1% invisible-profile rate, and be explicit that this is a deliberate degradation.
Layer a second, short-lived dedup at the deck level (a 24-hour "shown but not yet swiped" set), so a profile served into a deck that the user abandons is not immediately re-served.
4.4 2 billion writes a day
Why it's hard. Most social apps are read-heavy; this one is not. 23,000 swipes/sec sustained, 80,000 at peak, each a durable write. Left swipes — roughly 70% of the volume — are pure cost: nobody ever queries them except for dedup, which the bitmap already handles.
Solution — tier the write path by the value of the data.
async def record_swipe(swiper, target, direction):
# 1. Seen-set: always, immediately. This is the only universally required write.
await redis.setbit(f"seen:{swiper}", target, 1)
if direction == "left":
# 2a. Left swipes: no synchronous durable write at all. Batch to Kafka,
# land in Parquet on S3 for model training, TTL after 90 days.
await kafka.send_async("swipe.left", compact_event(swiper, target))
return Ack()
# 2b. Right swipes: the durable, transactional path from 4.1.
return await swipe_with_match_check(swiper, target)
This cuts durable write volume by ~70% at a stroke. The justification to give: "A left swipe's only consumers are the dedup filter and the training pipeline. The bitmap covers dedup synchronously; the training pipeline is happy with an at-least-once event stream. So a left swipe never needs a transactional row."
Additional write-path levers worth naming: TTL on right-swipe rows that never matched (2 years, then archive to S3), client-side batching of rapid swipes into a single request (5 swipes per call cuts request count 5×, with the seen-set applied optimistically on-device), and write-behind for analytics counters.
4.5 Attention distribution: the marketplace problem
Why it's hard. Engagement-maximising recommendation converges on showing everyone the most-liked profiles. The result is a power-law collapse: a small fraction of users receive the majority of right swipes, most users receive almost none, and those users churn. The system optimises a metric while destroying the marketplace that produces the metric. This is a design constraint, not a product afterthought, and mentioning it unprompted is a strong differentiator.
Solution — treat it as a two-sided matching market with explicit exposure control.
def deck_score(viewer, candidate) -> float:
p_right = model.p_viewer_likes(viewer, candidate)
p_recip = model.p_candidate_likes_back(candidate, viewer)
p_match = p_right * p_recip # optimise for MATCHES, not for likes
# Exposure fairness: damp candidates who have already had many impressions
# in this window, so attention spreads instead of concentrating.
exposure = candidate.impressions_7d / max(1, candidate.avg_cohort_impressions)
fairness = 1.0 / (1.0 + 0.5 * math.log1p(exposure))
# New-user boost: a cold-start profile needs impressions to get any signal.
freshness = 1.5 if candidate.age_days < 3 else 1.0
return p_match * fairness * freshness
The core move is optimising p(match) rather than p(right swipe). Showing a viewer a profile far above their reciprocity range produces a right swipe (good for the naive metric) and no match (bad for the product). Multiplying by the predicted reciprocal probability naturally sorts people into ranges where matches actually happen, without any explicit "desirability score" — which is both better engineering and avoids the reputational problem such a score creates.
Also cap daily impressions per profile so a single highly-ranked user is not shown to everyone in their city each day, which protects both the marketplace and that user's inbox.
4.6 People move
Why it's hard. A user's deck is built for their location. They fly to another city, or simply commute 30 km. A precomputed deck of profiles near home is worthless. But re-running deck generation on every GPS update, for 10M users, would dominate the entire compute budget — and continuous location streaming destroys phone battery.
Solution — event-driven invalidation on significant movement, using H3 cells as the trigger.
SIGNIFICANT_KM = 10
async def on_location_update(user_id, lat, lon):
prev = await redis.hgetall(f"loc:{user_id}")
cell = h3.latlng_to_cell(lat, lon, 7) # res 7 ≈ 5 km edge
if prev.get("cell") == cell:
await redis.hset(f"loc:{user_id}", "ts", now()) # cheap heartbeat only
return
moved = haversine(prev["lat"], prev["lon"], lat, lon)
await es.update(user_id, {"location": [lon, lat], "h3_7": cell})
if moved > SIGNIFICANT_KM:
await redis.delete(f"deck:{user_id}") # stale by construction
await queue.send("refill_deck", user_id, priority="high")
The client reports location on app open and on OS-level significant-location-change events, not on a timer. H3 cell comparison makes the "did anything meaningful change?" check a single string comparison rather than a distance computation.
Passport / travel mode — deliberately browsing another city — is the same mechanism with an explicitly set location, plus a flag so the recommendation model does not learn that the user has genuinely relocated.
The reciprocal problem is subtler: other people move into and out of my radius after my deck was built. Handle it by validating distance at hydration time (when the profile is actually rendered) and dropping candidates who have moved out of range — a cheap check against the already-fetched profile, and a good example of validating late rather than recomputing early.
4.7 Bots, catfish, and the integrity floor
Why it's hard. A dating app is an unusually attractive target: fake profiles for scams, bots that right-swipe everything to harvest matches, and stolen photos. Unlike spam in a feed, the damage is direct and personal, and users leave permanently after one bad experience. Detection must also be fast — a bot can send thousands of swipes in minutes.
Solution — layered defence with different latencies and different costs.
| Layer | Mechanism | Latency |
|---|---|---|
| Signup | Phone verification, device fingerprinting, disposable-number blocklists | Synchronous |
| Photo | Reverse-image search against known-stolen sets; liveness selfie verification | Seconds |
| Behaviour | Swipe-rate anomalies (right-swipe ratio ~100%, superhuman cadence), session entropy | Near-real-time, Flink |
| Content | Chat-message classifiers for scam patterns (off-platform contact, payment requests) | Near-real-time |
| Community | Reports weighted by reporter reliability; graph clustering of co-reported accounts | Minutes to hours |
The behavioural layer is the highest-value one and it is cheap: a legitimate user's right-swipe ratio is far below 100% and their inter-swipe timing is irregular. A Flink job over the swipe stream flags accounts whose ratio and cadence are both anomalous, then shadow-bans — their swipes are accepted and recorded but never surface to anyone. Shadow-banning rather than blocking means the operator does not immediately learn which signal caught them, which materially slows adaptation.
5. What breaks first
| Event | First failure | Mitigation |
|---|---|---|
| Peak-hour swipe burst | Swipe-store partition throughput | Left swipes bypass the durable path entirely; client-side batching |
| Popular profile | Hot partition on their pair keys | Pair keys distribute by both IDs, so no single user concentrates on one partition |
| Deck refill storm after a location change (a festival, a conference) | Elasticsearch cluster saturation | Priority queue for refills; degrade to a purely geo-ordered deck |
| Redis seen-set loss | Users re-shown old profiles | Rebuild from the swipe store asynchronously; accept temporary repeats over blocking swipes |
| Match notification storm | Notification provider throttling | See the notification system |
| Bot wave | Match quality collapses, real users churn | Behavioural detection + shadow-ban; rate-limit swipes per account per hour |
6. Cheat sheet
- Match race: order-independent pair key co-locates both directions on one partition; atomic update with
ALL_NEW; conditional insert makes the event idempotent. - Decks: precomputed into a Redis queue, refilled on a low watermark, 1-hour TTL, mutual-eligibility filtered.
- Dedup: Roaring bitmaps — exact, ~6 KB/user, supports deletion. Bloom filters hide profiles permanently.
- Writes: 2B/day, and 70% of it is left swipes that never need a durable transactional row.
- Fairness: optimise
p(match) = p(like) × p(reciprocate), damp by recent exposure, boost new profiles. - Movement: H3 cell change as the trigger; validate distance at hydration rather than recomputing decks.
- The one-liner: "A write-heavy system pretending to be a read-heavy one. Everything follows from co-locating both halves of a pair on one partition and precomputing the deck so the request path never queries anything."