Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

System Design

Design a Dating App

Step 1 / 9

Learn system design by building a swipe-based dating app like Tinder or Hinge step by step.

The numbers to beatgeohash cellsinstead of a full distance scanBloom filtercheap "have I seen this person" checkO(cells)candidate generation cost, not O(all users)

The whole design, in writing

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

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.

Youswiping
New in this step: You.

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.

What the new pieces do

Youclient
Someone swiping through a deck of nearby candidates, hoping the next one likes them back.

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?

APIswipe, match, chatUser Table Scannaive nearby query
New in this step: API, User Table Scan.

A live "everyone within N km" query on every single swipe, at real scale. What breaks first?

  1. 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.

  2. Running out of nearby candidates is a product/density problem, not what breaks FIRST from an engineering standpoint — the scan cost and repeat-candidate bug bite immediately, at any user density.

  3. Distance sorting itself isn't the hard part — computing and querying it efficiently for millions of concurrent users, on every swipe, without repeats, is.

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.

Why this piece earns its place

The only knob this version has is the radius, and it fails in both directions at once. Widen it and a dense city returns far more rows than the deck will ever show, all of which the database still has to sort by distance before nearly all of them are thrown away. Narrow it and someone in a sparse region opens the app to an empty deck, which is the fastest way to lose them. Expanding the radius dynamically until enough candidates come back looks like the fix, but it makes the cost of a swipe unpredictable, and the slowest, most expensive queries then land on exactly the users who already have the fewest options to begin with. The deeper problem is that one query is doing two jobs — finding people nearby, and remembering which of them this user has already seen — and only the first of those is a geo problem. Until they are separated, tuning the radius just moves the pain between latency and an empty screen.

What the new pieces do

APIgateway
The front door for every swipe, match check, and message.
User Table Scanservice
Wherever the next deck of candidates comes from — starts naive, becomes a proper geospatial index by Step 2.

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.

YouAPIGeo Candidate Index
The system as it stands at this step. · swipe to pan the diagram

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.

Why this piece earns its place

Worth being precise about what the exclusion set costs, because its errors are invisible. A Bloom filter is one-sided: it never forgets someone the user really did swipe on, but it can claim a profile was seen when it never was, and that profile is then silently dropped from the deck forever. Nobody reports a face they never saw, so this failure produces no signal at all — it just quietly shrinks the candidate pool, and it shrinks it worst for the heaviest users, because the false-positive rate climbs as the set fills and heavy swipers fill it fastest. So size the filter against the heaviest swipers rather than the median, and treat it as a pre-filter rather than the authority: it cheaply removes most already-swiped profiles from the cell lookup, and when the deck comes up short the likes store re-checks the handful it rejected, so a false positive costs one extra lookup instead of a face lost forever. The cheap check runs over the long list, the exact check only over what that list threw away.

  • geohash cellsinstead of a full distance scan
  • Bloom filtercheap "have I seen this person" check
  • O(cells)candidate generation cost, not O(all users)

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?

APIswipe, match, chatLikes / Matchesstrongly consistent
New in this step: Likes / Matches.

B's like triggers a check for A's existing like, then a match insert. Under concurrency, what's the failure mode?

  1. There absolutely is a race: "check if the reverse like exists, then insert a match" is exactly the check-then-act pattern that breaks under concurrency, same shape as an inventory race.

  2. A duplicate match row could double-fire "it's a match!" notifications or create two separate chat threads for the same pair — a confusing, buggy experience even if no data is technically lost.

  3. 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.

Why this piece earns its place

The constraint is only as strong as the key it sits on, and that key has one requirement that is easy to miss: the pair has to be canonically ordered before it is written. If one swipe inserts (A, B) and the other inserts (B, A), both rows are unique, the database is perfectly happy, and you get exactly the duplicate the constraint was meant to prevent. Sorting the two ids before the write is what turns “unordered pair” from a description into a real key. The second thing the constraint does not cover is everything hung off the match — the notification, the chat channel, the analytics event. The writer that loses the race still has to tell its user they matched, but it must not fire a second “it’s a match”. So those side effects belong in the same transaction as the insert, or keyed off the match row’s id so a repeat is a no-op. The row is exactly once; the consequences are not, unless you make them so.

  • unique(userA,userB)constraint, order-independent
  • 1 matchguaranteed, regardless of swipe timing
  • idempotentinsert — the second writer just confirms

What the new pieces do

Likes / Matchesdata
Records every swipe and, when both directions exist, exactly one match — this store's correctness guarantee is the whole product.

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?

APIGeo Candidate IndexRanking
New in this step: Ranking. · swipe to pan the diagram

Ranking depends on several signals and paid boosts. Compute it live on every swipe, or something else?

  1. Recomputing a multi-signal ranking model on every single swipe, at swipe-scale traffic (far higher than page-view scale in most apps), is expensive for a benefit users won't perceive between one swipe and the very next one.

  2. 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.

  3. Distance-only ordering ignores real signal (activity, mutual interest, paid boosts) that meaningfully improves match quality — the product's core value.

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.

Why this piece earns its place

The knob this introduces is the batch — how deep it is, and how often it refreshes. Keep it shallow and refresh constantly and you drift back toward live ranking, because the refresh is the expensive part: a batch that covers only a few swipes puts most of the traffic straight back on the model you were protecting. Make it deep and long-lived and the staleness concentrates in the tail, where the last cards were ranked against a world that has since moved — someone went quiet, someone bought a boost, someone new arrived in the cell — and no amount of extra depth repairs that, because depth is what produced it. So the refresh trigger is the more interesting half of this knob, not the size. I would refresh on meaningful change — the user moves, a boost is bought, the batch is nearly spent — rather than on a clock alone, and set the depth from the real distribution of swipes per session rather than a round number. One rule holds whatever the numbers are: the batch is a ranking, not a permission. Exclusion and safety filters apply when a card is served, never only when the batch was built.

What the new pieces do

Rankingservice
Orders the candidate deck by more than raw distance — recency, mutual signals, boosts — recomputed periodically, not live per swipe.

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.

YouAPILikes / MatchesChat
New in this step: Chat. · swipe to pan the diagram

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.

Why this piece earns its place

The thing to be careful about here is when the gate is checked. If the match is only consulted at channel-open time, the channel outlives the condition that created it: an unmatch, a deleted account or a block leaves a live thread whose participants are no longer allowed to talk, and the check that would have caught it already passed. So the match row is the authority on every send, not just the first one — which is cheap, since it is one lookup on a store the send already touches. The other half is what happens to what was already said. Revoking the right to speak does not retract delivered messages, so the product has to decide explicitly whether an unmatch hides the history for both sides, keeps it for the person who did not act, or deletes it — and whichever it is, the chat service enforces it, not the client. A thread a client can still render because it cached the messages is the same bug one layer up.

What the new pieces do

Chatservice
A messaging channel that only opens once a match exists — no messaging a stranger who hasn't matched back.

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?

YouAPILikes / MatchesChatBlock / Report
New in this step: Block / Report. · swipe to pan the diagram

A block needs to hide two people from each other. Same eventual-consistency tolerance as the recommendation feed?

  1. A few minutes where a blocked user can still see, message, or match with the person who blocked them is a real safety failure, not a minor inconsistency — this path cannot inherit the feed's relaxed tolerance.

  2. 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.

  3. The blocked user still needs to be prevented from reaching the blocking user — one-directional enforcement leaves the blocked user free to keep messaging or reappearing in the other person's feed, defeating the purpose of the block.

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.

Why this piece earns its place

Worth saying out loud what this choice costs, because the write is not the expensive part — blocks are rare. The cost lands on the read side: candidate generation, chat and search are the highest-volume paths in the product, and all three now consult the strictest store in the system. What makes that affordable is checking per request rather than per candidate. A user’s block list is small and bounded, so it can be loaded once per deck request and applied to the whole batch in memory, instead of a lookup per row. What cannot be done is putting an ordinary expiring cache in front of the answer — and the asymmetry is the useful part. Caching blocked is safe: the worst case is that a block outlives itself and two people stay hidden from each other a little too long. Caching not blocked is the safety failure. If anything here is cached at all, it can only be cached in the deny direction.

What the new pieces do

Block / Reportcontrol
Enforces blocks and reports immediately everywhere — the one path in this system that is never allowed to be eventually consistent.

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.

YouAPIGeo Candidate IndexLikes / MatchesTrust Scoring
New in this step: Trust Scoring. · swipe to pan the diagram

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.

Why this piece earns its place

The uncomfortable property of quiet enforcement is that its mistakes are quiet too. A real person who gets shadow-restricted sees no error and no appeal prompt — they see an app where nothing happens any more, and they leave. In the metrics that is indistinguishable from ordinary churn, so the false-positive rate of the trust model is the one number this design is structurally unable to observe. That is worth building around rather than accepting. Keep the score a graded lever instead of a switch, so a borderline account is dampened rather than erased and can climb back on good behaviour. Hold out a small share of low-trust accounts unrestricted as a control group, because without one there is nothing to compare the restricted population against. And treat reports as adversarial input rather than ground truth — coordinated mass-reporting is a cheap way to turn a safety feature into a weapon — so a report feeds the score as one signal and never acts as a label on its own.

What the new pieces do

Trust Scoringevents
Scores accounts on fake/bot signals (swipe velocity, image-hash reuse, device clustering) without slowing down or tipping off the swipe path 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?

YouAPIGeo Candidate IndexLikes / MatchesRankingChatBlock / ReportTrust Scoring
The system as it stands at this step. · swipe to pan the diagram

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.

Why this piece earns its place

Two details decide whether this bucket is boring or a source of outages. The first is whose day it is. Refilling every bucket on one fixed clock boundary means the entire user base regains its swipes in the same second, and they all arrive to find their precomputed batches empty — a self-inflicted daily spike that hits candidate generation and ranking together. A rolling window per user, or a boundary derived from the account itself, spreads the same allowance out and removes the correlation. The second is where the check sits. It belongs in front of candidate generation, not behind it, or the system pays for the cell lookup, the batch read and the filtering on requests it is about to refuse anyway. There is a product consequence worth naming too: the tighter the free limit, the more of the experience is decided by the first few cards, so a scarce swipe budget quietly raises the price of every ranking mistake. The limit and the ranking are one conversation, not two.

You did it

You just designed a dating app.

YouAPIGeo Candidate IndexLikes / MatchesRankingChatBlock / ReportTrust Scoring
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • A live full-table nearby scan doesn't scale and repeats candidates — fix both with a geospatial index plus a swiped-exclusion set.
  • A mutual match under concurrent swipes needs a database-enforced uniqueness constraint and an atomic conditional insert, guaranteeing exactly one match regardless of timing.
  • Ranking is precomputed in batches and served from cache — swipe volume is far too high to rank live per request.
  • Chat is gated on the matches store: no messaging without a confirmed match.
  • Blocking is deliberately the strictest-consistency path in the system — synchronous, bidirectional, checked on every read, no staleness tolerated.
  • Bot/fake-profile detection runs asynchronously and quietly, off the hot path, so it never slows real users or tips off adversaries.
  • Swipe limits are a simple token bucket per user per day, which also naturally supports paid tiers.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why can't the mutual-match check just use a distributed lock instead of a uniqueness constraint?

    A lock would work, but it's solving the same problem with more moving parts and more failure modes (lock expiry, lock-holder crashes, cross-region lock coordination) than a uniqueness constraint the database already enforces for free. A unique constraint on (userA, userB) plus an atomic insert-or-confirm operation gets you the exact same "exactly one match" guarantee with none of the operational complexity of managing distributed locks — prefer the simpler primitive whenever the database can enforce the invariant natively.

  2. Why treat blocking as strongly consistent but the candidate feed as eventually consistent, instead of making everything strongly consistent to be safe?

    Making everything strongly consistent would mean every candidate-feed read pays the cost of the strictest guarantee in the system, at swipe-scale traffic — a huge, mostly wasted expense, since a slightly stale candidate deck causes no real harm. The right question for any given path is "what does it actually cost to be wrong here?" For the feed, being wrong for a few minutes is invisible to the user. For a block, being wrong for even a few seconds is a genuine safety failure. Matching the consistency model to the actual cost of staleness, per path, is more correct AND more efficient than a blanket policy.

  3. Why not tell a flagged account exactly why they were shadow-restricted?

    Because a sophisticated bad actor who receives specific, actionable feedback ("your swipe velocity is too high," "this photo was flagged as reused") can simply adjust that one specific behavior and evade detection next time. The honest cost is that a mis-flagged real user gets no error and no prompt to appeal either — they just see an app where nothing happens any more — so you cannot lean on an appeals queue to find them. That is why enforcement is graded rather than binary, and why a slice of low-trust accounts is deliberately held out unrestricted: it gives you something to measure the false-positive rate against, which quiet enforcement will never surface on its own. Quiet, unexplained throttling degrades the effectiveness of a bot's farm gradually and without giving it a map of exactly what to change — a meaningfully harder target than a system that explains its detection criteria.

  4. How would you extend this design to support "Super Likes" or other asymmetric signals beyond a plain like?

    A Super Like is still fundamentally a directional signal in the same likes table (with a type/weight column distinguishing it from a normal like), so the mutual-match logic doesn't change at all — it still becomes a match only once the reverse direction exists too. What changes is downstream: ranking can weight a received Super Like as a strong positive signal (surface that profile near the top of the recipient's deck), and notification logic can treat it as a distinct, higher-visibility event ("someone Super Liked you!") — the core matching invariant stays untouched, only the signals feeding ranking and notifications grow richer.

  5. What's the risk of geohash-based candidate generation near cell boundaries?

    Geohash cells are a grid, so two people who are physically very close but happen to fall on opposite sides of a cell boundary can end up in different cells and miss each other in a naive single-cell lookup. The standard fix is querying the user's own cell PLUS its immediate neighboring cells (typically the 8 surrounding cells at the same resolution), which costs a small, bounded number of extra lookups but correctly covers anyone genuinely nearby regardless of which side of an arbitrary grid line they landed on.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. A live full-table "everyone nearby" scan on every swipe mainly fails because…

    Both the query cost and the missing exclusion tracking break down as the user base and swipe volume grow.

  2. A guaranteed exactly-one match under simultaneous mutual swipes requires…

    Letting the database enforce the invariant atomically avoids the check-then-act race entirely.

  3. Ranking the candidate deck by multiple signals is computed…

    Swipe volume is far too high to justify recomputing a multi-signal ranking model on every request.

  4. A block should propagate…

    Safety-critical paths get the strongest consistency guarantee in the system, unlike the feed.

  5. Bot/fake-profile detection runs…

    Async keeps the hot path fast; quiet enforcement avoids teaching adversaries exactly what to evade.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Serve a deck: show a stream of nearby candidates the user hasn’t already swiped on.
  • Swipe & match: record each like; when both directions exist, create exactly one match.
  • Rank: order the deck by more than distance — recency, mutual signals, paid boosts.
  • Chat on match: open a messaging channel only once a match is confirmed.
  • Block & report: hide two users from each other immediately and bidirectionally.

The qualities that shape everything

Each one names the mechanism that buys it.

Deck lookup scales, and never repeats a face
Users are indexed by geohash cells so "nearby" is a few cell lookups, and a per-user swiped-exclusion set filters seen profiles before the deck is even built.
Exactly one match, even on a simultaneous swipe
A unique constraint on the unordered user pair plus an atomic conditional insert lets the database — not application timing — guarantee exactly one match row.
Ranking quality without paying per swipe
Ranked candidate batches are precomputed periodically and served until exhausted, so only the refresh touches the expensive multi-signal model.
No messaging a stranger
The chat service checks the matches store before allowing any message, reusing match-state as the single source of truth for who may talk.
Safety is the strictest path in the system
Blocks write synchronously to the authoritative store and every read path checks it, with no caching layer allowed to serve a stale "not blocked" answer.
Catch bots without slowing real users or tipping them off
A trust-scoring pipeline runs asynchronously off the swipe path and quietly shadow-restricts low-trust accounts, so it never blocks a swipe or reveals what tripped it.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

A geospatial index + swiped-exclusion set over a live full-table distance scan per swipe

Scanning distance for every nearby user on every swipe doesn’t scale past a small base, and with no memory of who’s been shown the same faces resurface. Geohash cells make "nearby" cheap; the exclusion set makes "no repeats" cheap.

A DB uniqueness constraint + atomic insert over a check-then-insert in application code

Two "does the reverse like exist?" checks can interleave before either write commits, creating zero matches or two. Pushing the invariant down to the database guarantees exactly one match row regardless of swipe timing.

Precomputed ranked batches over computing ranking live on every swipe

Recomputing a multi-signal model at swipe-scale traffic is expensive for a benefit users can’t perceive between one swipe and the next. A batch computed every few minutes serves many swipes with imperceptible staleness.

Strongly-consistent synchronous blocking over eventual consistency like the feed

A few minutes where a blocked user can still see or message the blocker is a safety failure, not a minor inconsistency. Safety gets the strongest guarantee in the system even though it’s the rarest action.

Async, quiet trust scoring over instant, visible account restriction

Synchronous fraud checks would slow every swipe to catch a minority of bad actors, and a visibly-restricted account tells a sophisticated bot exactly which signal to change. Decoupling detection from the hot path — and enforcement from disclosure — matters as much as accuracy.

The answer, out loud

What a strong answer to “Design a Dating App” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–3 min

    Scope the problem

    Let me scope this before I draw anything. A dating app looks like one feed, but there are really three different problems inside it with three different consistency needs. Serving a deck of nearby people can be a little stale and nobody is harmed. Creating a match has to be exactly right, exactly once, even when two people act at the same instant. And a block has to be immediate. If I get those three levels of strictness right, the rest is mostly standard plumbing, so that is where I would spend the time.

  2. 3–8 min

    Start from the naive deck

    The simplest version is: on every swipe, query everyone within some radius, sort by distance, show the next one. There are far more swipes here than any other action, so that is the one path I cannot afford to make expensive, and this query gets slower as the user base grows. It also has no memory, so the same faces come back, including people you already passed on — and users read that as the app being broken long before they read it as slow.

    Built in step 1: Show everyone nearby, sorted by distance
  3. 8–14 min

    Split it into two problems

    So I split that into two problems, because they are not the same problem. For nearby, I index users by geohash cells, so finding candidates becomes a handful of bucket lookups instead of a distance calculation across the whole table — and I would read my own cell plus its neighbours, since two people standing together can land on opposite sides of a grid line. For no repeats, every user gets a swiped-exclusion set, something cheap like a Bloom filter, and I apply it while the deck is being built rather than after. Filtering afterwards means paying to fetch people I am about to throw away.

    Built in step 2: Fast lookup, no repeats
  4. 14–22 min

    The match is a race

    The match is where I would slow down. A like is one-directional; a match exists only once both directions do. The obvious implementation is: when B likes A, check whether A already likes B, and if so insert a match. That is check-then-act, and under real concurrency it is a race. If they swipe at the same instant, both checks can run before either like commits, so you get no match at all, or two rows and two notifications for one pair. I would not fix that in application code. I would put a unique constraint on the pair in the matches table and create matches with an atomic conditional insert, so whoever commits second hits the constraint, finds the match already there, and confirms it. The database decides, not the order my requests happened to arrive in.

    Built in step 3: A like is one-directional. A match needs two.
  5. 22–28 min

    Rank in batches, not per swipe

    Ranking I would deliberately not compute live. Distance on its own is a weak signal — recency of activity, mutual interest, paid boosts all matter — but recomputing a multi-signal model on every swipe is paying a lot for freshness nobody can perceive between one card and the next. So I precompute a ranked batch per user, serve swipes from it until it runs out, and refresh periodically or when something meaningful changes. Only the refresh touches the expensive part. The staleness I am buying is invisible to the user; the saving is not.

    Built in step 4: More than distance — but not computed live
  6. 28–33 min

    Chat hangs off the match

    Chat I would hang off the same matches store rather than give it its own notion of who is allowed to talk. The match row is the authorization check, and the chat service asks it before letting any message through. That keeps the question of whether two people may speak in one place, and it can change after the channel is open.

    Built in step 5: No messaging a stranger
  7. 33–39 min

    Blocking breaks my own pattern

    Blocking is where I would push back on my own design. Everything so far has been happy to be slightly stale, and it is tempting to let a block ride the same path. I would not. A few minutes in which a blocked person can still see, message or match with the person who blocked them is not an inconsistency, it is a safety failure. So blocks write synchronously to the authoritative store, enforcement is bidirectional — each vanishes from the other’s deck, chat and search — and every read path checks before serving, with nothing cached in front of that answer. It is the rarest action in the product and it gets the strongest guarantee, and being able to justify that asymmetry matters more to me than any single component here.

    Built in step 6: Safety gets the strongest guarantee in the system
  8. 39–43 min

    Bots, off to the side

    Fake profiles and bots I would keep off the hot path entirely. Detecting them needs behaviour compared across many accounts — swipe velocity, one photo appearing on supposedly different profiles, devices clustering — which is expensive and nothing I want inside a swipe’s latency budget. So it is an asynchronous pipeline producing a trust score, and enforcement is quiet: low-trust accounts get shown to fewer real people rather than visibly banned. If I tell a bot exactly what tripped it, I have written its next version for it.

    Built in step 7: Score behavior, quietly, off the hot path
  9. 43–45 min

    Close on the trade-off

    So the trade-off I would close on is that this system runs at three different strictness levels on purpose — a feed that tolerates staleness, a match the database guarantees exactly once, and a safety path where no staleness is acceptable — and matching each path to what being wrong there actually costs is both cheaper and more correct than one blanket policy. With more time I would work through the brand-new account, which has none of the interaction history the ranking leans on and still has to be given a deck and shown to other people, and what happens when someone travels and their location moves underneath a deck that was already built.

What this teaches

Learn system design by building a swipe-based dating app like Tinder or Hinge step by step. An interactive guide covering why a live full-table nearby scan doesn't scale, geospatial candidate generation with a swiped-exclusion set, the mutual-match race condition and how to guarantee exactly one match record, precomputed ranked feeds, instant bidirectional blocking as a safety-critical path, and an async trust-scoring pipeline for bot and fake-profile detection.

Key takeaways

  • A live full-table nearby scan doesn't scale and repeats candidates — fix both with a geospatial index plus a swiped-exclusion set.
  • A mutual match under concurrent swipes needs a database-enforced uniqueness constraint and an atomic conditional insert, guaranteeing exactly one match regardless of timing.
  • Ranking is precomputed in batches and served from cache — swipe volume is far too high to rank live per request.
  • Chat is gated on the matches store: no messaging without a confirmed match.
  • Blocking is deliberately the strictest-consistency path in the system — synchronous, bidirectional, checked on every read, no staleness tolerated.
  • Bot/fake-profile detection runs asynchronously and quietly, off the hot path, so it never slows real users or tips off adversaries.
  • Swipe limits are a simple token bucket per user per day, which also naturally supports paid tiers.

Concepts covered

  • What is a dating app, systems-wise?
  • Show everyone nearby, sorted by distance
  • Fast lookup, no repeats
  • A like is one-directional. A match needs two.
  • More than distance — but not computed live
  • No messaging a stranger
  • Safety gets the strongest guarantee in the system
  • Score behavior, quietly, off the hot path
  • Rate limits and the freemium wall
built so two mutual right-swipes become exactly one match, never zero, never two — make the calls, race a double-swipe, run the gauntlet.
Finished this one? 0 / 65 System Designs done
NextDesign a Notification System Closest system design to this one

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs