Design a Dating App — the walkthrough in full
A written version of the interactive walkthrough above — the same steps, decisions and trade-offs, laid out for reading, reference and search.
The big idea
What is a dating app, systems-wise?
Millions of people, each wanting a fresh deck of nearby candidates they haven't seen before, each swipe needing to feel instant. Buried inside that simple feed are two genuinely hard problems: a mutual-match has to be detected exactly once even when both people act at the same instant, and safety actions (blocks, reports) need guarantees the rest of the system doesn't.
We'll build the candidate feed, the matching logic, and the safety path as three different consistency problems wearing one UI: the feed can be a little stale, the match has to be exactly right, and blocking has to be immediate. Getting each one the right level of "strict" is the whole design.
How to read this: Each step opens with a real design decision — make the call before I show you what ships. Watch the diagram grow, hover the boxes, and at the end race a simultaneous mutual swipe and fire a block to see which guarantees actually hold. Hit Begin.
Step 1 · The baseline
Show everyone nearby, sorted by distance
The simplest version: on every swipe, query the users table for everyone within N km, sorted by distance, and show the next one. What breaks first as this scales?
Design decision: A live "everyone within N km" query on every single swipe, at real scale. What breaks first?
The call: The live full-table geo scan gets slower and more expensive as the user base grows, and with no exclusion tracking, already-swiped or already-matched people reappear in the deck. — Scanning distance for every nearby user on every single swipe (there are far more swipes than any other action in this product) doesn't scale past a small user base, and without remembering who's already been shown, the same people resurface — an obviously broken experience.
A live full-scan-by-distance on every swipe doesn't scale, and without tracking who's already been shown, candidates repeat. You need both a fast geospatial lookup and a way to never re-show someone the user already swiped on.
Two different problems in one feed: "Find people near me" (a geospatial query) and "don't show me the same person twice" (an exclusion set) are separate concerns that both have to be solved before the naive version is even correct, let alone fast.
Step 2 · Geospatial index + exclusion set
Fast lookup, no repeats
Fix both problems from Step 1: make "nearby" fast at scale, and make "already seen" reliable.
Index users by geohash cells (or S2/geospatial index) so "nearby" becomes a handful of cell lookups instead of a distance calculation across the whole table — the same principle as the proximity-service pattern. Pair it with a per-user swiped-exclusion set (a Bloom filter or a fast set-membership store) so candidate generation filters out anyone already swiped on before the deck is even built, not after.
Index the query, remember the history: Geohashing turns "who is near this point" into a small number of bucket lookups by encoding location into a string prefix — nearby points share a prefix. The exclusion set is a separate, cheap membership check layered on top, so the two concerns (fast geo lookup, no repeats) stay independently solvable.
Step 3 · The mutual match
A like is one-directional. A match needs two.
A swipe-right just records "A likes B." A match only exists once BOTH directions are true. When B swipes right on A, the system has to check "does A already like B?" and if so, create exactly one match. What could go wrong under real concurrency?
Design decision: B's like triggers a check for A's existing like, then a match insert. Under concurrency, what's the failure mode?
The call: If A and B swipe right on each other at the exact same instant, both checks can run before either like commits — risking zero matches created (both see "no reverse like yet") or a duplicate match row, unless the match creation is atomic and idempotent. — This is a genuine race: both swipes' "does the reverse exist?" checks can interleave before either write lands, so a naive implementation can miss the match entirely or create it twice. The fix is a DATABASE-level guarantee — typically a unique constraint on the unordered (userA, userB) pair plus an atomic conditional insert — so the database itself, not application logic, decides there is exactly one match row no matter the timing.
Enforce a unique constraint on the unordered user pair in the matches table, and create matches via an atomic conditional insert (or an upsert) rather than a separate check-then-insert. Whichever swipe commits second simply hits the constraint, discovers the match already exists, and confirms it — guaranteeing exactly one match row regardless of how closely timed the two swipes are.
Let the database enforce the invariant: "Exactly one match per pair, no matter the race" is not something application-level timing checks can reliably guarantee — it's exactly what database uniqueness constraints exist for. Push the invariant down to the layer that can actually enforce it atomically.
Step 4 · Ranking the deck
More than distance — but not computed live
Distance alone is a weak signal — recency of activity, mutual interest signals, and paid "boosts" all matter for who shows up near the top of the deck. Should this ranking be computed fresh for every single swipe?
Design decision: Ranking depends on several signals and paid boosts. Compute it live on every swipe, or something else?
The call: Precompute ranked candidate batches periodically (or on meaningful signal change) and serve from that batch until it's exhausted or stale. — Just like calendar or hotel pricing, ranking doesn't need per-request freshness — a batch of, say, 50 ranked candidates computed every few minutes (or refreshed when the user's own activity changes meaningfully) serves many swipes cheaply, with a small, imperceptible staleness window.
Precompute ranked candidate batches periodically per user, incorporating recency, interaction signals, and boosts, and serve swipes from that batch until it's exhausted or a refresh triggers. Only a small fraction of traffic (the refresh itself) touches the expensive ranking computation.
Same caching principle, different domain: This is the identical trade-off as hotel pricing and calendar reminders: an expensive, multi-signal computation gets computed periodically and served from a cache/batch, because per-request freshness isn't worth its cost at this traffic volume.
Step 5 · Chat unlocks on match
No messaging a stranger
Once a match exists, both people should be able to message each other. Before a match, they shouldn't.
A confirmed row in the Likes/Matches store is the gate for opening a chat channel — the chat service checks match existence before allowing any message, and the channel itself is created (or lazily opened) the moment the match is confirmed. This reuses standard real-time messaging architecture (fan-out, presence, delivery) with one extra precondition layered on top: a match must exist first.
One store, two consumers: The matches table isn't just a record of "who matched" — it's the authorization check for an entirely different feature (chat). Keeping match-state as the single source of truth for that gate avoids duplicating "are these two allowed to talk" logic anywhere else.
Step 6 · Blocking is not eventually consistent
Safety gets the strongest guarantee in the system
One user blocks another. Should that propagate on the same "eventually, within a few minutes" timeline as the ranked candidate feed?
Design decision: A block needs to hide two people from each other. Same eventual-consistency tolerance as the recommendation feed?
The call: No — a block must take effect immediately and bidirectionally: hidden from each other's candidate deck, chat, and search, right away, everywhere. — Safety actions are the one place in this system where "eventually consistent" is unacceptable. A block writes synchronously to the authoritative store and every read path (candidate generation, chat, search) checks it before serving results — no caching layer is allowed to serve a stale "not blocked" answer.
Blocks and reports write synchronously to the authoritative safety store, and every read path that could expose the blocked pair to each other — candidate generation, chat, search — checks it before serving results, with no caching layer permitted to serve a stale answer here. This is deliberately the strictest consistency guarantee in the entire system, even though it's the rarest action.
Not every path deserves the same consistency model: The recommendation feed optimizes for scale and tolerates staleness because the cost of being wrong is low (you see a slightly outdated candidate). Safety optimizes for correctness and accepts extra cost because the cost of being wrong is high. A good system design explicitly chooses different guarantees for different paths — it doesn't apply one blanket policy everywhere.
Step 7 · Catching fake profiles and bots
Score behavior, quietly, off the hot path
Bots and fake profiles swipe right on everyone, reuse stolen photos, and cluster on the same devices. Detecting this needs to analyze behavior patterns across many accounts — expensive, and if a suspicious account is instantly and visibly restricted, sophisticated bots simply adapt and evade next time.
Run a trust-scoring pipeline entirely asynchronously, off the hot swipe path: it ingests signals (swipe velocity, image-hash reuse across "different" profiles, device/IP clustering) and produces a trust score per account over time. Low-trust accounts are quietly throttled or shadow-restricted in candidate generation — shown to fewer real users, not blocked outright and not told why — so the swipe path never waits on this analysis, and bad actors don't get a clear signal telling them exactly what tripped the detection.
Decouple detection from enforcement, and enforcement from disclosure: Fraud/trust systems generally work best asynchronous (don't slow down the real product for everyone to catch a minority of bad actors) and quiet (an adversary who can see exactly what got them flagged will simply avoid that specific signal next time). Both properties matter as much as the detection accuracy itself.
Step 8 · The sharp edges
Rate limits and the freemium wall
Most dating apps cap free swipes per day. How do you enforce "N swipes per 24 hours" cleanly, and combine everything built so far into one coherent system?
Enforce swipe limits with a token bucket per user per day-boundary — cheap to check on every swipe, refills on a schedule, and naturally supports paid tiers by simply configuring a larger (or unlimited) bucket. Combined with everything else: fast geo candidates with no repeats, an atomically-guaranteed single match per pair, precomputed ranking, chat gated on match state, safety enforced with the strictest consistency in the system, and bot detection running quietly in the background — that's a dating app that survives real concurrency, real abuse, and real scale.
Design for the unhappy path: Simultaneous mutual swipe → atomic conditional insert with a uniqueness constraint. A block → synchronous, strongest-consistency enforcement everywhere. A bot farm → async, quiet trust scoring. Each unhappy path gets the specific guarantee it actually needs, not a one-size-fits-all fix.
You did it
You just designed a dating app.
- A —
- A —
- R — a
- C — h
- B — l
- B — o
- S — w