Scope the requirements
Two decisions dominate the design: how to build a good candidate pool (geo + preferences + ranking) and how to detect a mutual like without race conditions. The rest is straightforward.
Functional — what it must do
- User sees a stack of candidates within their preferences (geo, age, gender).
- Swipe right (like) or left (pass); track the decision.
- When A likes B and B likes A → match created, both notified.
- Chat between matched users.
- Explicitly cut: video, subscriptions, boost — offer them back.
Non-functional — the numbers that shape the design
- Swipe latency under 100 ms — feels instant.
- 100M users, ~50M DAU, ~2B swipes/day, ~10M matches/day.
- Candidate pool refreshed at least daily; no user sees the same candidate twice within N days.
- Match detection is exactly-once — no double-match, no missed-match.
- 99.9% on swipe writes; feed can degrade to cached candidates.
The mutual-like check has to be atomic and cheap. Everything else — feed, chat, preferences — is web-app plumbing.
Back-of-envelope estimates
Swipe QPS is high but writes are tiny. The candidate pool is what needs planning.
| Quantity | Estimate | How you got there |
|---|---|---|
| Swipes/s | ~25k/s avg, ~100k peak | 2B/day ÷ 86,400; evening peak in each timezone. |
| Matches/s | ~120/s | 10M/day ÷ 86,400. Small write volume. |
| Candidates per user | ~200/day | prefetched pool refreshed nightly + top-up during session. |
| Storage swipes (1 yr) | ~750 GB | 2B × 365 × ~100 B (uid, target, decision, ts). Cassandra. |
| Match rows | ~4B (cumulative) | 10M/day × 365. Small compared to swipes. |
| Geo index size | ~5 GB | 100M users × (lat, lng, prefs). Redis GEO. |
Swipes are a firehose but write-once, small-row. The mutual-like check is the only sub-millisecond requirement on the write path.
Sketch the API
Three main calls: get next candidates, register a swipe, list matches. Chat is a separate service that inherits from the match relationship.
- Swipe write is fire-and-forget from the client — server acknowledges quickly, the mutual-like check happens synchronously but doesn't block the next candidate.
- No 'undo swipe' by default — paid tier only. Prevents users from gaming the algorithm and keeps writes append-only.
Data model
Three concerns: user + preferences (transactional), swipes (append-only stream), matches (small transactional). Geo lives in Redis.
Cassandra for swipes because they're append-heavy and partitioned naturally by actor. Redis for the mutual-like SET because the check is per-swipe and must be <1 ms. Postgres for matches because it's small, relational, and anchors chat.
High-level design
Three services: candidate builder (offline + online), swipe handler (with mutual-like check), match/chat (owns the relationship). Nothing exotic — the interesting bits live in the mutual-like flow.
- Candidate builder runs nightly per user: query Redis GEO for users within radius + filter by prefs + rank by activity → write ~200 candidates to user's Redis list.
- Feed request → feed service pops next 20 from user's Redis list, returns.
- Swipe → swipe service. If 'pass', append to Cassandra + move on. If 'like': (1) SISMEMBER on target's
likes_receivedset — did they already like me? - If mutual: create a match row atomically (user_a < user_b canonical), delete both SET entries (cleanup), notify both users.
- If not mutual: SADD my id to target's
likes_receivedset (they'll see the match when they like me). Append swipe to Cassandra either way for history + fraud.
Deep dives — where the interview is won
Mutual-like detection: one SISMEMBER + one SADD, atomic
The naive check reads a database row asking 'has B liked A?'. Every swipe does this — millions of reads. The pattern: maintain a Redis SET per user of 'people who liked me'. When A likes B, check SISMEMBER likes_received:B A. If yes → mutual match. If no → SADD A to likes_received:B. Both operations are O(1) in Redis, and neither hits Postgres on the hot path.
For atomicity, wrap both operations in a Lua script — SISMEMBER + conditional SADD + conditional match-emit. A concurrent race where A and B both like each other in the same millisecond is resolved by whichever hits Redis first creating the match; the loser's SISMEMBER sees the match already exists and skips the SADD.
Candidate pool: precompute nightly, top-up hourly
Running geo + preferences + ranking on every feed request is expensive at 100k swipes/s. Instead, precompute: nightly job per active user reads Redis GEO for users within radius, filters by mutual preference match, applies a ranker (attractiveness inferred from swipe rates, activity recency, recency of last profile update), and writes ~200 candidates to a Redis list.
Session-time top-up handles users who blow through their nightly pool. When list drops below 20, an on-demand builder appends 50 more. Cold-start (new users) skips nightly and always uses on-demand — the ranker has less signal but the pool needs to exist immediately.
Avoiding duplicate matches with a canonical row
When A likes B and B likes A simultaneously, two match-write attempts race. The Postgres primary key (user_a, user_b) with user_a < user_b canonical form + ON CONFLICT DO NOTHING collapses both attempts into one row. The second one is a no-op.
The Redis-side of the story is symmetric: the Lua script guarantees only one path creates the match. But belt-and-braces: even if Redis logic drifted, the Postgres constraint is the last line of defense. This is the whole 'defense in depth' pattern — never trust a single layer for critical uniqueness.
Follow-ups you should expect
How do you prevent a user from seeing the same candidate twice?
Ranking: how do you sort candidates in the pool?
What about the 'match with someone across the world' feature (paid)?
Chat message delivery for 10M matches/day?
Fraud: bots, catfishing, mass-swiping?
Where candidates lose the room
- Running geo + preferences on every feed request — 100k QPS × few-ms per query is expensive and unnecessary when the pool can be pre-baked.
- Checking mutual like in Postgres — hot-row contention on popular targets, and adds a round trip per swipe.
- No canonical form on matches — race conditions insert duplicate rows and confuse chat.
- Deleting swipes on undo — makes fraud detection impossible; keep them, add an 'undo' flag.
- Serving candidate rankings from raw geo query at swipe time — burns CPU on every swipe.