Design Swipe Matching
Design a swipe-to-match dating app: recording swipes at 70,000 a second, detecting a mutual match exactly once, and building candidate stacks.
On this page
You open a dating app and see one face at a time. Swipe right if you are interested, left if not. When two people have both swiped right on each other, the app says "It's a match" and opens a chat. A swipe-based dating app such as Tinder is the kind of product this question means.
Each piece looks trivial: a swipe is an insert, a match is a lookup, the stack is a query. At tens of thousands of swipes a second, the inserts need a write-optimised store, the lookup has a race when two people swipe together, and the stack must never repeat someone. The steps follow A Framework for Any Design. Try each "Your turn" box before reading on.
Clarify the problem
Design the core of a swipe-based dating app.
I'll take that as: build a profile with preferences, get a stack of nearby people who fit, swipe left or right, and match when both swiped right. Is chat in scope?
No, stop at the match. Assume 20 million daily users who swipe about 100 times a day.
Must a match be shown instantly to the person who swiped second?
Yes, that's the moment the product is built around. The other person gets a push notification.
Then my priorities: never miss a mutual match and never announce one twice, show the match on the second swipe in under 200 ms, and never show a profile someone already swiped on. A stack that is a few minutes stale is fine.
- Functional: create a profile with photos and preferences (age range, genders, maximum distance); get a stack of candidates who fit my preferences and whose preferences fit me; swipe left or right; match when both swipe right; list my matches.
- Non-functional: a swipe returns in under 200 ms; every mutual right swipe produces exactly one match; a profile is never shown twice to the same person; stacks may be minutes stale; right swipes are durable, a lost left swipe is tolerable.
- Out of scope: chat (see Design a Chat App), payments, photo moderation, the ranking model itself.
Estimate the scale
- Swipes: 20 million × 100 = 2 billion a day ÷ 86,400 ≈ 23,000 a second. With an evening peak of 3x, about 70,000 a second.
- Right swipes: if 30% are right, about 21,000 a second at peak. These are the ones that can create a match.
- Swipe history: about 50 bytes stored per swipe, so 2 × 109 × 50 B = 100 GB a day, about 36.5 TB a year.
- Profiles: 100 million accounts × 2 KB = 200 GB, plus photos in blob storage behind a CDN.
- Stacks: 200 candidate ids of 8 bytes per daily user is 20 × 106 × 1.6 KB = 32 GB of cache. The daily users' profile cards add 20 × 106 × 2 KB = 40 GB.
How many swipes a second must the system record at the evening peak?
- 20 million daily users
- 100 swipes per user per day
- Peak is 3 times the average
- A day is 86,400 seconds
Show the working
- 20 × 106 × 100 = 2 × 109 swipes a day
- 2 × 109 ÷ 86,400 ≈ 23,000 a second
- 23,000 × 3 ≈ 70,000 a second at peak
Estimate 70 thousand/s (anything from 25 thousand/s to 200 thousand/s is a good answer)
Every swipe is a write. That alone rules out a single relational primary for the history.
Each daily user has swiped on 10,000 profiles so far. A Bloom filter with a 1% false-positive rate needs about 9.6 bits per item. How much memory do the filters of all daily users take?
- 20 million daily users
- 10,000 swiped profiles per user
- About 9.6 bits per item for 1% false positives
Show the working
- One filter: 10,000 × 9.6 bits = 96,000 bits = 12 KB
- All users: 20 × 106 × 12 KB = 240 GB
- An exact set of 8-byte ids: 10,000 × 8 B = 80 KB each, at least 1.6 TB in total
Estimate 240 GB (anything from 80 GB to 800 GB is a good answer)
The filter is about 7 times smaller than the exact set. The price is that 1 in 100 unseen profiles is wrongly hidden.
Write the endpoints for getting the stack and swiping. How does the second swiper learn about the match immediately, and what happens if the phone retries a swipe?
Compare with a model answer
GET /stack returns the next profiles; POST /swipes records a swipe and answers whether it made a match, so the second swiper sees it in the same response. The swipe is keyed by (swiper, target), so a retry changes nothing.
Define the API
PUT /me/profile name, photos, bio, age range, genders wanted, max distance
PUT /me/location { "lat": 51.51, "lng": -0.12 } sent when the app opens
GET /stack?count=20
200 { "profiles": [ { "user_id": "u_91", "name": "Ben", "age": 31,
"distance_km": 4, "photos": [...] }, ... ] }
POST /swipes
body: { "target_id": "u_91", "direction": "right" }
200 { "matched": true, "match_id": "u_12:u_91" }
200 { "matched": false }
404 Not Found target deleted their account
GET /matches?cursor=...The decision worth arguing is what the stack does not include: whether each candidate already liked you. Sending it would let the app show "It's a match" with no round trip, but anyone inspecting the app's traffic could list everyone who liked them. Keep it on the server and answer in the swipe response; under 200 ms is fast enough for the animation.
The swipe is idempotent by (swiper, target): swiping on the same person twice, or a retry after a timeout, returns the same result. See idempotency and retries.
Sketch the records for profiles, swipes and likes. Which key lets you answer "do they both like each other?" in one atomic step?
Compare with a model answer
Swipe history keyed by swiper, for "who have I seen". Likes keyed by the pair of users, smaller id first, with one flag per direction, so both people's likes for each other live in one row that a single atomic update can check.
Model the data
profiles -- relational, 200 GB
user_id PK, name, birth_date, gender, bio, photo_urls,
age_min, age_max, genders_wanted, max_distance_km,
lat, lng, last_active_at
swipes -- wide-column, partition key swiper_id
swiper_id, target_id, direction, created_at
PRIMARY KEY ((swiper_id), target_id) -- 2 billion rows a day
pair_likes -- sharded relational, key = the pair
low_id, high_id -- the two user ids, smaller first
low_likes_high BOOLEAN
high_likes_low BOOLEAN
matched_at TIMESTAMP NULL
PRIMARY KEY (low_id, high_id)The swipe history is append-only, write-heavy and read by one key, the swiper, which fits a wide-column store such as Cassandra: writes land in memory and a log, so 70,000 a second spread over a cluster is routine. The pair row needs a different guarantee, an atomic read-modify-write on one key, so it lives in a store that offers that: sharded relational rows, or a key-value store with conditional updates. It grows by up to 600 million rows a day (30% of 2 billion swipes), about 9 TB a year at 40 bytes a row, so shard it by a hash of the pair. Profiles are ordinary relational data, the source for two copies fed by its change stream (not drawn below): profile cards in the cache, and a search index with a geo field for stack building.
Sketch the boxes for serving a stack, refilling it in the background, and recording a swipe that may create a match.
Compare with a model answer
A stack service that only reads precomputed stacks from a cache; a stack builder worker that queries the geo index and drops already-seen profiles; and a swipe service that appends to the history and updates the pair row, which decides the match.
High-level design
Stacks are built ahead of time, so opening the app is a cache read. Swipes go to the history for "seen" and to the pair row for "matched".
request and replyasynchronous message
Step through Ana getting her stack and swiping, then Ben swiping on her 3 ms later.
- stack left
- 45
1Ana opens the app. The stack service pops the next 20 ids from her precomputed stack and fetches their profile cards from the same cache: two cache reads, no query.
- stack left
- 45
2Only 45 are left, under the refill mark of 50, so the stack service queues a refill job for the builder. Ana does not wait for it.
- candidates
- 1,000
3The builder asks the geo index for active people within her 15 km whose age and gender fit her preferences and whose preferences fit her. 1,000 come back.
- candidates
- 1,000
- already seen
- 360
- stack left
- 245
4It drops everyone in Ana's seen filter, 360 people she already swiped on, ranks the other 640 and appends the best 200 to her stack.
- Ana likes Ben
- recorded
5Ana swipes right on Ben. The swipe service appends (Ana, Ben, right) to her history partition and adds Ben to her seen filter.
- Ana → Ben
- true
- Ben → Ana
- false
- matched
- false
6It upserts the pair row (Ana, Ben) and sets Ana's flag in one atomic statement that returns the row. Ben's flag is still false: no match.
- Ana → Ben
- true
- Ben → Ana
- true
- matched
- now
7Ben swipes right on Ana 3 ms later, on another server. His update hits the same row. Had Ana's update still been running, his would wait for its row lock; either way it sees her flag already set.
- matches created
- 1
8The same statement set
matched_at, so Ben's response says "It's a match". The notifier reads the row's change stream and pushes to Ana, recording the pair key so a redelivered event sends no second push.- matches created
- 0
9Had each like lived only under its swiper, each server would check the other's like on a lagging replica. Both checks could miss: no match, ever.
10A cache node dies, taking 1 in 10 stacks and seen filters with it. For those users the builder first rebuilds the filter from their swipe history, then builds a stack on demand.
Deep dives
Interviewers push on the swipe rate, detecting the match exactly once, building the stack, and never repeating a profile.
Recording 70,000 swipes a second
Each swipe is a tiny append. The only read is "all swipes by this user", to rebuild a seen filter. Partition the history by swiper_id and cluster by target_id inside the partition, so a repeated swipe overwrites the same cell instead of adding a row. In a log-structured store (see indexing), each write is an append to memory and a commit log, which is why such stores take this rate on modest hardware.
Left swipes are 70% of the volume and only feed the seen filter, so one replica's acknowledgement is enough, and they may expire after a year if the product wants people to reappear. Right swipes are written with a quorum. Partitions stay small: 100 swipes a day is 36,500 a year, under 2 MB.
Detecting a match exactly once
Ana and Ben swipe right on each other 3 ms apart. The obvious method writes "Ana likes Ben" under Ana's key, then reads "does Ben like Ana?" under Ben's key. Each request writes then reads. If the reads go to replicas that lag, both can read before the other's write arrives, and neither announces a match. If both reads see the other write, both announce one.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Write mine, read theirs | No extra table. With quorum reads and writes (R + W > N, no sloppy quorum), at least one of the two requests sees the other's like. | Both may see it, so two matches are announced unless match creation is idempotent. Replica reads break it completely. | You already have a strongly consistent store keyed by swiper and accept dedup. |
| One pair row, atomic update Usual default | Both directions live in one row. Updates to one row are serialized, so exactly one request sees both flags set, and it records the match in the same statement. | A second write per right swipe, to a store that supports atomic single-row updates. | The second swiper must see the match instantly. The usual answer. |
| Stream partitioned by pair | Likes go to a stream partitioned by the pair key; one consumer per partition sees both likes in order, so no locks. | Asynchronous: the second swiper learns of the match a moment later, by push. | Instant feedback is not required, or the pair store is overloaded. |
-- Ana is user 12, Ben is user 91: low_id = 12, high_id = 91
INSERT INTO pair_likes (low_id, high_id, low_likes_high, high_likes_low)
VALUES (12, 91, false, true)
ON CONFLICT (low_id, high_id) DO UPDATE
SET high_likes_low = true,
matched_at = CASE WHEN pair_likes.low_likes_high
AND pair_likes.matched_at IS NULL
THEN now() ELSE pair_likes.matched_at END
RETURNING matched_at; -- not null: tell Ben "It's a match"Why it is exact: the database runs the two updates on the row one after the other. The first sees only its own flag; the second sees both and sets matched_at. The match is decided and stored in one atomic write, so it cannot be lost between "detected" and "saved". A retried request finds matched_at already set and returns the same answer. Notifying Ana reads the row's change stream and uses the pair key as the match id, so a redelivered event produces no second match.
Building the stack
A good candidate is nearby, fits my filters, has me inside theirs, is unseen and recently active. Running that query on every app open is wasteful when the answer barely changes in an hour.
So precompute. The builder runs one query on the search index: a geo filter around my location (the cells and neighbour cells from Design Ride Sharing), plus age, gender, the other person's age range and genders, and active in the last 30 days. It then drops seen profiles, checks each candidate's own distance limit, ranks, and stores 200 ids in the cache. The stack service serves 20 at a time and asks for a refill below 50, so a heavy swiper never waits.
The cost is staleness: a profile in my stack may have been deleted since. The stack service checks the 20 it sends against deletions and my seen filter. Rebuild a stack when the user moves more than a few kilometres.
Never showing a profile twice
The builder must remove everyone I have swiped on from 1,000 candidates. The exact answer is in my swipe history, but that is a large read on every refill.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Read the swipe history | Exact, and nothing extra to store. | A refill checks 1,000 candidates against the partition: a batched read of up to 1,000 keys on every refill. | Swipe histories are short, or refills are rare. |
| Exact set in the cache | Exact and fast: one in-memory lookup per candidate. | At least 80 KB per user with 10,000 swipes, at least 1.6 TB for 20 million users. | Memory is cheap compared to the cost of a wrongly hidden profile. |
| Bloom filter in the cache Usual default | 12 KB per user at 1% false positives, 240 GB in total. No false negatives, so a seen profile is never shown. | About 1 in 100 unseen profiles is hidden for good, and items cannot be removed, so expiring old left swipes means a rebuild. | The rule is "never show twice" and losing 1% of candidates is acceptable. My pick. |
A Bloom filter answers "definitely not seen" or "probably seen". Its error points the safe way: it can wrongly say "seen", hiding someone, but never wrongly say "not seen". In a small town the cost is real: 1% of 1,000 candidates is 10 people never shown. Say so, and offer the exact set to users with a small pool. When a user passes the item count the filter was sized for, rebuild a larger one from the history, because an overfull filter's false-positive rate climbs fast.
Bottlenecks and failures
At 10x, 700,000 swipes a second, the history cluster grows by adding nodes, since partitions are per user. The pair store needs more shards for 210,000 right swipes a second; the pair key hashes evenly. A popular profile receiving thousands of right swipes a minute touches thousands of different pair rows, so there is no hot row. The geo index sees only refill load; dense cities need more replicas.
| When this dies | What happens | What you do |
|---|---|---|
| A cache node | Its users lose their stacks and seen filters. | Build stacks on demand; rebuild filters from the history before serving, never serve without one. |
| A pair-store shard primary | Right swipes for its pairs fail until a replica takes over. | Synchronous replica so no acknowledged like is lost; the app retries the idempotent swipe. |
| A swipe-history node | Its replicas take its writes. | Replication factor 3; quorum writes for right swipes keep working with one node down. |
| The stack builder | Stacks drain without refills. | A refill is requested with 50 still left, so users keep swiping while it restarts; an empty stack is built on demand. |
| The match notifier | The first swiper is not pushed; the match is still stored. | The stream keeps its position, so pushes resume, and the match list already shows it. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Stores swipes in one table and checks for the reverse swipe on each right swipe, without seeing the race until prompted. |
| Mid | Estimates the write rate, picks a write-optimised store, precomputes stacks, and uses a geo index with filters. |
| Senior | Explains why write-then-read can miss or duplicate a match, decides it with one atomic write on a pair key, keeps "who liked me" off the client, and argues the Bloom filter's 1% loss against the memory it saves. |
Two people swipe right on each other in the same millisecond. What happens?
Both likes go to one row keyed by the pair, smaller id first. The database serializes updates on that row, so one request sees only its own flag and the other sees both. The second sets matched_at in the same statement and tells its user. Exactly one match, and it is stored before anyone hears about it.
Why not just read the other person's swipe?
With replica reads both could miss it; with strong reads both could see it and I'd announce twice. The pair row gives me exactly one, for the cost of a second write per right swipe.
Follow-up questions
How would you add a "see who liked you" feature?
Query pair rows where the other flag is set and mine is not. That needs an index by user, so keep a per-user "liked by" list written from the pair rows' change stream. Serve it only to users entitled to it, from the server.
A user travels to another city. What changes?
The location update moves their profile document in the geo index and discards their stack, so the next refill uses the new place. The seen filter stays.
How do you stop a few very popular profiles from appearing in every stack?
That is ranking: the builder counts impressions per profile and down-ranks those far above their share.
A user unmatches. Can they appear again?
No. The swipe stays in the history and the seen filter, and the pair row keeps both flags with an unmatched_at time so a retry cannot recreate the match.
How would you shard the geo index?
By region, with the maximum distance capped at, say, 100 km so a stack query stays in one region. Big cities get more replicas; a profile near a border goes into both regions' shards.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".