The Blueprint
Geo & marketplaces intermediate

Design Tinder

Geo-scoped candidate feed + double-opt-in match detection — a matching problem masquerading as a feed problem.

asked at Match Group · Bumble · Meta Sometimes asked ~35 min walkthrough ✎ Practice on canvas →
01

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.
Say this out loud

The mutual-like check has to be atomic and cheap. Everything else — feed, chat, preferences — is web-app plumbing.

02

Back-of-envelope estimates

Swipe QPS is high but writes are tiny. The candidate pool is what needs planning.

QuantityEstimateHow you got there
Swipes/s~25k/s avg, ~100k peak2B/day ÷ 86,400; evening peak in each timezone.
Matches/s~120/s10M/day ÷ 86,400. Small write volume.
Candidates per user~200/dayprefetched pool refreshed nightly + top-up during session.
Storage swipes (1 yr)~750 GB2B × 365 × ~100 B (uid, target, decision, ts). Cassandra.
Match rows~4B (cumulative)10M/day × 365. Small compared to swipes.
Geo index size~5 GB100M users × (lat, lng, prefs). Redis GEO.
Say this out loud

Swipes are a firehose but write-once, small-row. The mutual-like check is the only sub-millisecond requirement on the write path.

03

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.

GET/api/v1/candidates?after=→ next stack of ~20 candidates. Reads from user's Redis pool.
POST/api/v1/swipesbody: { targetUserId, dir: like|pass }. Runs mutual-like check on 'like'.
GET/api/v1/matches→ list of matched users, ordered by last-message ts.
GET/api/v1/matches/{id}/messagesChat history via the messaging service.
WS/rt/matchesPush new-match + message events to the client.
  • 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.
04

Data model

Three concerns: user + preferences (transactional), swipes (append-only stream), matches (small transactional). Geo lives in Redis.

users
id PK · name · age · gender · location · prefs (json) · photos[]
Postgres. Read-heavy on the candidate build path.
swipes
(actor_id, target_id) PK · dir · ts
Cassandra. Partition by actor_id — queries are 'has A ever swiped B?'.
matches
(user_a, user_b) PK · created_at · last_message_at
Postgres. user_a < user_b canonical to prevent duplicates.
geo_index
user_id → (lat, lng)
Redis GEO. Used at candidate-build time only.
likes_received
target_id → SET of who liked them
Redis SET. The mutual-like check is SISMEMBER against this.
The database call

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.

05

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.

client edge service data async Swipe UI API gateway Feed svc Swipe svc mutual check Match svc Candidates Redis LIST likes_received Redis SET swipes Cassandra matches Postgres Candidate builder nightly + hourly Redis GEO Push notif next 20 SISMEMBER on match append
swipe hits a redis SISMEMBER first; if a match, writes to matches. everything else is async.
  1. 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.
  2. Feed request → feed service pops next 20 from user's Redis list, returns.
  3. Swipe → swipe service. If 'pass', append to Cassandra + move on. If 'like': (1) SISMEMBER on target's likes_received set — did they already like me?
  4. If mutual: create a match row atomically (user_a < user_b canonical), delete both SET entries (cleanup), notify both users.
  5. If not mutual: SADD my id to target's likes_received set (they'll see the match when they like me). Append swipe to Cassandra either way for history + fraud.
06

Deep dives — where the interview is won

Phase 01

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.

A likes B redis outcome Swipe like B SISMEMBER lr:B A SADD lr:A B Match created Wait for B hit miss
one lua script: check membership, either create match or record pending like. no DB round-trip on the miss path.
Trade-offSET storage per popular user grows unbounded (a celebrity gets millions of likes). Cap at N entries with LRU eviction — a few very old likes may never trigger match retroactively; acceptable for a dating UX.
Phase 02

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.

nightly session pool Nightly builder Top-up builder when < 20 User pool Redis LIST +50 on-demand 200 nightly
nightly seeds the pool. on-demand refills when low. no candidate scoring on the swipe path.
Trade-offUsers can outpace the top-up during heavy use — the queue empties briefly and they see 'nobody nearby'. Small UX bug; mitigate with prefetch when list drops below 40.
Phase 03

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.

swipes match svc matches A likes B B likes A Match svc PK (min, max) ON CONFLICT DO NOTHING canonical
primary key uses (min(a,b), max(a,b)) so races collapse to one row.
Trade-offWhichever swipe committed first gets attributed as the 'match creator' — fine for UX but shows in analytics as slightly non-symmetric.
07

Follow-ups you should expect

How do you prevent a user from seeing the same candidate twice?
The candidate builder consults the swipes table (or a bloom filter of recent swipes) and excludes already-swiped targets. Bloom filter with ~1% false-positive rate is fine — it filters some fresh candidates unnecessarily but never shows a duplicate.
Ranking: how do you sort candidates in the pool?
A learned ranker combines: like-rate reciprocity (does this user's typical swipe pattern match with mine?), activity recency, geo proximity, mutual friends, photo freshness. Rank runs offline at build time so serving is a Redis LPOP.
What about the 'match with someone across the world' feature (paid)?
Skip the geo filter for paid users. Candidate pool builder has a flag; downstream logic identical. The catch is that the reciprocity model was trained on local matches, so quality drops — worth flagging in the UX.
Chat message delivery for 10M matches/day?
Separate service on top of the match relationship. Messages are event-shaped, delivered over WebSocket with fallback to push notifications. Chat storage is a per-match log — small, cheap. The message rate is ~1/s per active match on average; totally fine on standard messaging infra.
Fraud: bots, catfishing, mass-swiping?
Rate-limit swipes per second per user (~5/s soft cap). Detect abnormal swipe patterns via a batch job (all rights, all lefts). Photo verification (liveness check) for high-signal accounts. Fraud downweights matches in the notification pipeline and can suspend accounts.

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.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.