Design Tinder
Show each person a deck of nearby profiles that fit both people’s preferences, never repeat one, take 2 billion swipes a day, and announce a match the instant two people like each other.
Last updated 2026-09-30. Difficulty: medium. Patterns: geo-sharding, recommendations, bloom-filters, match-detection, write-heavy. Reported at Meta, Amazon, Bumble, Snap, Google.
The interviewer asks, the candidate answers and draws, and you press Next. Pause to answer yourself at the key decisions, and ask the AI Mentor anything along the way.
Functional requirements
- Profile. Photos, bio, age, gender and preferences (who they want to see, age range, maximum distance).
- Get a deck. A stack of profiles near the user that match their preferences and whose preferences the user also matches. Never someone already swiped.
- Swipe. Left (pass) or right (like), recorded permanently. A right swipe on someone who already liked you is a match.
- Matches. Both people are told within seconds; the match appears in both match lists and opens a chat.
- Location updates. The user’s location refreshes when the app opens or they move, so decks reflect where they are now.
- Out of scope. Chat after the match (see Design WhatsApp), payments and boosts, photo moderation beyond a hook, and safety reporting.
Non-functional requirements
- Swipe volume (~2 B swipes/day · ~70 k/s peak). Every swipe is a write, and each one must be recorded before the next card loads.
- Deck latency (next card instantly; refill p99 < 300 ms). The app keeps a few cards buffered and refills in the background; a blank card is the failure users notice.
- Match latency (both notified within seconds). This is the requirement that drives the hardest trade-off: the match check must be synchronous and correct on the swipe path, at 70 k swipes a second, even when two people swipe on each other at the same instant.
- Never repeat. Showing someone you already passed on feels broken. The seen check runs on every deck build, across years of swipes.
- Locality (most queries hit one geo shard). Candidates are near the user by definition, so the index is split by geography, not by user id.
- Privacy. Exact locations are never exposed: distances are rounded and positions fuzzed.
Back-of-envelope estimates
- Swipes per second: ~23 k avg · ~70 k peak. 25 M DAU × 80 swipes a day = 2 B/day ÷ 86 400 ≈ 23 k/s; evenings run about 3× ≈ 70 k/s.
- Matches per second: ~300. 26 M matches a day ÷ 86 400 ≈ 300/s. Each needs two notifications and two match-list writes.
- Deck builds per second: ~3 k. Decks of 100 profiles; 80 swipes a day means about one refill per active user a day, plus app opens: 25 M × 10 builds a day ÷ 86 400 ≈ 3 k/s. Each is a geo query plus ranking.
- Swipe storage per year: ~20 TB. 2 B swipes × ~30 B (swiper, target, direction, time) = 60 GB a day × 365 ≈ 22 TB a year, before replication. Kept long-term because swipes are never shown again.
- Seen filter per user: ~12 KB. A heavy user has swiped ~10 k profiles. A Bloom filter at 10 bits per entry is 100 k bits ≈ 12.5 KB with ~1 % false positives, small enough to load on every deck build.
- Candidates in a dense city: ~50 k. Within 10 km of central London, matching basic age and gender preferences, there can be 50 k active profiles. The deck needs 100: candidate retrieval must cap and pre-score, not rank everyone.
Components
- Mobile app: Shows cards from a local buffer, sends swipes as they happen (batched briefly when offline), refills the deck in the background, and receives match notifications.
- API gateway: Authentication, per-user swipe rate limits (bots swipe right on everyone), and routing.
- Profile service: Profiles, photos (via CDN) and preferences. Profile and location changes are published so the candidate index stays current.
- Profile store: User profiles and preferences, sharded by user id. The source of truth the candidate index is built from.
- Recommendation service: Builds a deck: queries the user’s geo shard for candidates within distance and preferences (both ways), removes anyone already swiped, ranks, and caches about 100 profiles.
- Candidate index (Elasticsearch · geo-sharded): One document per active user: location, age, gender, preferences and activity, in shards that each cover a geographic area sized by population, so a deck query usually hits one shard.
- Ranking features: Batch and streaming jobs that compute signals for ranking (activity, response rates, likely mutual interest) from swipe events, and write them into the candidate index.
- Deck cache (per user, ~100 ids): Each user’s next ~100 recommended profile ids. The app takes 20 at a time; when the cache runs low, a rebuild runs in the background.
- Swipe service: Records each swipe idempotently, updates the user’s seen filter, and on a right swipe checks synchronously whether the other person already liked them.
- Seen filters (Bloom filter per user): A compact filter of every profile a user has swiped, checked on every deck build. False positives (rarely skipping someone new) are fine; false negatives are impossible.
- Likes store (Cassandra · key = (target, swiper)): Every right swipe stored by the person who was liked, so when A likes B, "has B liked A?" is one key lookup on A’s partition. Left swipes are kept separately for history and the seen filter.
- Match service: Creates a match exactly once per pair with a deterministic id, adds it to both match lists, and triggers notifications.
- Matches (key = (min_id, max_id)): One row per matched pair plus a per-user match list. The pair key is the same whichever person swiped last, which is what makes concurrent matches safe.
- Notifications (WebSocket + push): Tells both people "It’s a match!": over the open connection if the app is in use, through a push notification otherwise.
- Swipe events (Kafka): Every swipe and match, for ranking features, analytics, abuse detection and rebuilding the seen filters if needed.
User flows
- Open the app and get a deck. Cards come from a precomputed per-user deck; building it is a geo query on one shard, a seen filter, and a ranking pass.
- The app asks for the next cards; the deck cache returns 20.
- If fewer than ~30 remain, a background rebuild queries the user’s geo shard. The query filters both ways: candidates the user wants to see (age, gender, distance) and who want to see someone like the user. A cheap pre-score caps it at a few thousand candidates even in dense cities.
- Candidates the user has already swiped are removed using their seen filter. One fetch of a ~12 KB Bloom filter, then 2,000 in-memory checks. A false positive hides a new person occasionally; nobody already swiped can slip through.
- The rest are ranked, the top 100 cached, and the first cards hydrated. Ranking blends likelihood of mutual interest, activity, distance and fairness of exposure. Photos load from the CDN; the app prefetches the next few so swiping never waits.
- Swipe right and match. The write path: record the like once, check the reverse like with a single key lookup, and create the match exactly once.
- A swipes right on B.
- The swipe service stores the like under B and checks whether B already liked A. Likes are keyed by the person liked, so the write goes to B’s partition and the check "did B like A?" is a lookup of (target=A, swiper=B) on A’s partition. Two single-key operations; no scan.
- B is added to A’s seen filter, and the swipe goes on the event stream. The filter update is what keeps B out of A’s future decks. The event feeds ranking and abuse detection asynchronously.
- B had liked A, so the match service creates the match. The match id is derived from the pair (smaller id, larger id), and the insert is conditional, so the match exists once no matter how many times or from which side it is attempted.
- Both people are notified. A sees "It’s a match" in the swipe response itself; B gets it over the open connection or a push. Match lists are also fetched on app open, so a lost push only delays the news.
- Saturday 9 p.m. in a big city. The scale case: swipes hit 70 k/s, deck rebuilds spike in dense shards, and the busiest geo shards carry far more than their share.
- Swipes surge; the likes store absorbs them across many partitions. Partitioning by target spreads writes over millions of users. A very popular profile is a hot partition, but even thousands of likes a minute is a modest write rate for one partition.
- Analytics and ranking work is deferred to the event stream. Only the like write and the reverse-like check are synchronous. Everything else (features, analytics, abuse models) reads the stream and can lag a few minutes during the peak without anyone noticing.
- Deck rebuilds concentrate on the shards for the busiest cities. Shards are sized by active users, not by area, so London is split into several shards while a rural region shares one. Busy shards get more replicas for read throughput.
- Decks are refilled early, before users run out. Refilling when 30 cards remain, rather than at zero, hides rebuild latency even when the shard is busy.
- Two people swipe right on each other at the same moment. The correctness case: both swipes see "no reverse like yet" or both see "yes". The pair key makes either outcome end in exactly one match.
- A and B swipe right on each other within milliseconds. Each request writes its like and then reads the other direction. Depending on timing, each may or may not see the other’s like.
- If neither sees the other, both likes are stored and no match is created yet. Writing first and reading second guarantees at least one of them sees the other’s like if both writes complete before both reads. For the remaining case, a stream job scans recent likes for mutual pairs within seconds and creates the missing match.
- If both see each other, both call the match service. Both compute the same pair key; one conditional insert succeeds and the other finds the match already there. Exactly one match, one pair of notifications.
- If a notification is lost, the match list fixes it. Apps sync their match list on open, keyed by the last match they saw, so delivery problems delay a match but never lose it.
- Profiles and locations keep the index fresh. The background path: the candidate index follows profile and location changes, and inactive users drop out of decks.
- A user opens the app in a new city; the app reports a coarse location.
- The profile service moves the user’s document to the shard for the new area. A delete from the old shard and an insert into the new one. The user’s cached deck is invalidated because it was built for the old city.
- Ranking signals are refreshed from swipe events. Activity and response signals are partial document updates. Users inactive for a week are flagged so they stop appearing in decks: showing absent people wastes everyone’s likes.
Deep dives
Building the deck
How do you pick 100 profiles for someone from 75 million, fast, and so that both sides are likely interested?
The filter is two-sided: B must fit A’s preferences (age, gender, distance) and A must fit B’s. Ignoring the second half shows A people who will never see A, which wastes A’s swipes and B’s attention.
Doing this on every card would mean a geo query per swipe at 70 k swipes a second. But a deck of 100 lasts a while, and recommendations do not need to be fresh to the second, so the work can be batched per user and cached.
- Geo-sharded search index with two-sided filters, seen-filter removal, ranking, deck of ~100 cached per user chosen
- Query candidates on every card rejected
- Precompute decks for every user nightly rejected
The answer: Profiles live in an Elasticsearch-style index sharded by geography. A deck build queries the user’s shard with both-way filters (distance, age and gender each way, active within a week), sorted by a cheap pre-score and capped at a few thousand; removes everyone in the user’s seen filter; ranks the rest with a model predicting mutual interest, adjusted for fairness of exposure; and caches the top 100 ids per user. The app shows 20 at a time and refills when 30 remain. Changes to the user’s location or preferences invalidate the cached deck.
A new user signs up. Who sees them, and how do they get good recommendations?
New users have no swipe history, so ranking leans on profile completeness, activity and broad popularity, and they get a temporary exposure boost so others see them and data accumulates quickly. Their own deck starts from simple filters and improves as they swipe.
What if a user’s shard has run out of people to show?
Widen the distance step by step (25 km to 50 to 100), possibly into neighbouring shards, and tell the user they have seen everyone nearby. Rural users hit this often, so the expansion is a normal path.
Why rank for mutual interest instead of attractiveness?
Showing everyone the most popular profiles concentrates likes on a few people who cannot respond to them all, and most users get no matches. Ranking for the chance that both swipe right spreads attention and produces more matches overall.
How do you evaluate a ranking change?
By matches and conversations started per active user, not swipes, and by fairness of exposure (how many users get at least some likes). Run it as an A/B test by region, since the two sides of the market affect each other.
Should decks be refreshed when someone in them deletes their account?
Hydration skips missing or inactive profiles when cards are served, so a stale id in the cached deck costs nothing visible. Rebuilding every deck that contains a departed user would be far more work than filtering at serve time.
Never showing someone twice
A user has swiped 10,000 profiles over two years. How do you make sure none of them reappear?
Every deck build must exclude everyone the user ever swiped. Checking each of 2,000 candidates against a table of 10,000 swipes per user means thousands of lookups per build, or loading a large set per build at 3,000 builds a second.
The check only needs to be one-sided correct: it must never let a swiped profile through, but occasionally hiding a profile that was never swiped is harmless. That is exactly the guarantee a Bloom filter gives.
- A Bloom filter per user, updated on every swipe, rebuilt from the swipe log if lost chosen
- Exact set of swiped ids per user situational: light users, or as the source a filter is built from
- Query the swipe table for each candidate rejected
The answer: Each user has a Bloom filter sized for their swipe count (about 10 bits per swipe for ~1 % false positives), stored in a Redis cluster keyed by user id. The swipe service adds every swiped id as it records the swipe; the recommendation service fetches the filter once per deck build and tests each candidate in memory. When a user’s swipes outgrow the filter’s size, a new, larger filter is built from the swipe history. The swipe history in the event log is the source of truth, so a lost filter is rebuilt rather than trusted empty.
The Redis node holding seen filters fails. What do users see?
Without a filter, decks would show people already swiped, which is worse than a short delay. The recommendation service treats a missing filter as "rebuild first": it reconstructs it from the swipe history (one partition read) before building the deck, or serves the remaining cached deck meanwhile.
Should a pass be permanent?
Product decision. Some apps re-show passed profiles after months because preferences change. That fits the design: keep left swipes with timestamps and build the filter from recent passes plus all likes.
How do you size a filter for a user who keeps swiping?
Start small (sized for, say, 1,000 entries) and when the count passes the design capacity, build a larger filter from the swipe history and swap it in. Scalable Bloom filters do this automatically by chaining filters of growing size.
Why not check the seen filter in the index query itself?
The index would need each user's swipe list as a query filter, thousands of ids per query, which is slow and huge. Filtering a few thousand candidates in the service against a 12 KB filter is far cheaper.
Detecting a match exactly once
How do you know, at the moment of a right swipe, whether the other person already liked you, and avoid creating two matches?
A match is a mutual like. When A likes B, the system must check whether B has liked A, on the request path, because users expect "It’s a match" instantly. With likes stored by swiper, that check means searching B’s likes for A; with billions of likes it must be a single key lookup.
Races are inevitable: A and B can like each other within the same millisecond. Two requests may both create a match, or neither may see the other’s like.
- Store likes by target; write own like then read reverse like; create match with a deterministic pair key and conditional insert; sweep for missed pairs chosen
- Lock the pair while swiping rejected
- Detect matches only asynchronously from the stream situational: if the product accepted delayed match notifications
The answer: A right swipe writes (target, swiper) into a likes table partitioned by target, then looks up whether (target = swiper, swiper = target) exists. If it does, the match service inserts a match keyed by (smaller id, larger id) with IF NOT EXISTS, adds it to both users’ match lists, and notifies both. Because each side writes before reading, at least one side sees the other’s like whenever the writes complete before the reads; for the remaining sliver, a stream job joins recent likes in both directions and creates any missing match within seconds. The pair key makes every path converge on one match.
A unmatches B. What has to change?
Delete the match row and both list entries, and keep a block record so neither appears in the other’s deck again. The likes stay as history but must not recreate the match, so the match job checks for a block before inserting.
Why is a like stored by the person who was liked?
Because the question asked on every right swipe is "has the person I just liked already liked me?", which is a read of my own likes-received partition. Storing by swiper would make it a search through the other person’s likes.
How do you handle a match notification for a user with the app closed?
Send a push notification and record the match in their list. When they open the app, the match list sync shows it even if the push never arrived, so the push is a convenience, never the only record.
Could you use the swipe stream alone to detect matches?
Yes, as a fallback and for the rare race, but not as the only path: the user who swipes second expects an instant answer. The synchronous check gives that; the stream makes it complete.
Sharding by geography
Why shard the candidate index by location instead of by user id, and how do you draw the shard boundaries?
Every deck query is "people near me". With shards by user id, every query would go to every shard. With shards by location, a query goes to the one shard covering the user’s area. But population is wildly uneven: a fixed grid would give London one overloaded shard and the Sahara an empty one.
Tinder published how they solved this: split the map into cells with Google’s S2 library, and group cells into shards so each shard holds a similar load.
- S2 cells grouped into shards balanced by active users; a query covers one or a few shards chosen
- Shard by user id rejected
- Fixed grid of equal-area shards rejected
The answer: Divide the world into S2 cells at a fixed level, measure active users per cell, and group contiguous cells into shards with roughly equal load, so dense cities span several shards and sparse regions share one. A small, cached mapping from cell to shard routes each document and each query; a deck query computes the cells covering its search radius and queries the shards that own them, usually one. Shard boundaries are recomputed periodically as usage changes, with documents moved in the background. This mirrors the geosharded recommendations design Tinder engineering described.
A user is on the border between two shards. What happens?
The covering cells for their search radius belong to two shards, so the query goes to both and results are merged. It costs one extra query for users near borders, which is why shard boundaries are drawn through low-density areas where possible.
Why not use a geohash instead of S2?
Either works for dividing space. S2 cells are roughly equal in area everywhere on the sphere and have good covering algorithms for circles, while geohash cells distort near the poles and have awkward edges. The balancing idea is the same with both.
How do you rebalance shards without downtime?
Compute new boundaries, create the new shards, and dual-write documents for the affected cells to old and new shards while backfilling. Switch query routing per cell once the new shard is caught up, then delete from the old one.
Does the shard map need to be consistent everywhere instantly?
No. Routing with a slightly stale map sends a query to the old shard, which still has the data during a migration because of dual writes. Maps are versioned and refreshed every few seconds.
Taking 70,000 swipes a second
Every swipe is a write. What must happen synchronously, and what can wait?
A swipe is tiny, but there are billions a day, and users swipe fast: the app cannot wait for slow writes before showing the next card. At the same time, a right swipe must check for a match immediately, and the seen filter must be updated before the next deck build.
Most consumers of swipes (ranking features, analytics, abuse detection) do not need them within milliseconds.
- Synchronous: idempotent like write, reverse-like lookup, seen-filter add. Asynchronous: everything else via Kafka chosen
- Everything asynchronous rejected
- Everything synchronous, including features and analytics rejected
The answer: The app sends swipes immediately (and batches them while offline) with a client id for idempotency. The swipe service writes the like or pass to a wide-column store partitioned by target, adds the target to the seen filter, and for likes does the reverse lookup and match creation; then it publishes the swipe to Kafka. Feature computation, analytics and abuse detection consume the stream. A per-user rate limit at the gateway stops bots that like everyone, which would otherwise flood other people’s likes and distort ranking.
How do you detect a bot that swipes right on everyone?
Its signature is obvious in the stream: a like rate near 100 %, at machine speed, with no conversations. Rate limits slow it; an abuse model on the event stream flags it and its likes are removed from other users’ likes partitions so they do not produce fake matches.
The app is offline for an hour and swiped 50 cards. What happens on reconnect?
It uploads the queued swipes in order with their client ids. Matches discovered are returned in the batch response. If any target became unavailable meanwhile (deleted account), the swipe is simply recorded and ignored.
What consistency does the likes store need?
Read-your-writes for the swiping user and a quorum read for the reverse-like check, so a like written a moment ago by the other person is visible. Eventual consistency elsewhere is fine, because the stream sweep repairs any match the check misses.
How long do you keep swipe history?
Likes as long as the account exists, because they are needed for matching and for the seen filter. Passes can expire after a policy period if the product allows people to reappear. Deleting an account deletes its swipes and removes it from others' likes.
The theory behind it
- Geospatial indexing and proximity search: Geohash, S2 and quadtrees, nearest-neighbour and radius queries, indexing moving objects, map tiles, and the accuracy and hot-cell problems that come with each.
- Sharding and partitioning: Horizontal partitioning strategies (range, hash, directory), consistent hashing, choosing a shard key, hot shards, cross-shard queries, and resharding without downtime.
- Caching: Where to cache (browser, CDN, application, database), cache-aside vs write-through vs write-back, eviction policies, invalidation, hot keys, thundering herds and cache stampedes.