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.
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?
A live "everyone within N km" query on every single swipe, at real scale. What breaks first?
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.
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.
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.
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?
B's like triggers a check for A's existing like, then a match insert. Under concurrency, what's the failure mode?
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.
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.
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?
Ranking depends on several signals and paid boosts. Compute it live on every swipe, or something else?
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.
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.
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.
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?
A block needs to hide two people from each other. Same eventual-consistency tolerance as the recommendation feed?
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.
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.
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.
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?
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.
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.