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.

Case study27 minLesson 2 of 8
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

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

The first five minutes
Interviewer

Design the core of a swipe-based dating app.

You

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?

Interviewer

No, stop at the match. Assume 20 million daily users who swipe about 100 times a day.

You

Must a match be shown instantly to the person who swiped second?

Interviewer

Yes, that's the moment the product is built around. The other person gets a push notification.

You

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.
Estimate: Peak swipe rate

How many swipes a second must the system record at the evening peak?

You know
  • 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
  1. 20 × 106 × 100 = 2 × 109 swipes a day
  2. 2 × 109 ÷ 86,400 ≈ 23,000 a second
  3. 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.

Estimate: Memory for "already seen" filters

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?

You know
  • 20 million daily users
  • 10,000 swiped profiles per user
  • About 9.6 bits per item for 1% false positives

Show the working
  1. One filter: 10,000 × 9.6 bits = 96,000 bits = 12 KB
  2. All users: 20 × 106 × 12 KB = 240 GB
  3. 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.

Your turn: Define the API

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

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

Your turn: Model the data

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

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

Your turn: High-level design

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

Stacks are precomputed into a cache. A swipe is appended to the history and, if it is a right swipe, decided by one atomic update on the pair row, whose change stream drives the push.Phone app to API gateway (HTTPS); API gateway to Stack service (GET /stack); API gateway to Swipe service (POST /swipes); Stack service to Stacks, filters, profiles (next 20); Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB (atomic update); Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app (push), asynchronous; Stack builder to Profile geo index (nearby + prefs); Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profilesPair likes DBSwipe historyProfile geoindexHTTPSGET /stackPOST /swipesnext 20atomic updatepushnearby + prefs

request and replyasynchronous message

Stacks are precomputed into a cache. A swipe is appended to the history and, if it is a right swipe, decided by one atomic update on the pair row, whose change stream drives the push.

Step through Ana getting her stack and swiping, then Ben swiping on her 3 ms later.

Stack, swipe, a simultaneous match, a lost cache nodeUse the arrows or ← → keys
  1. Stack, swipe, a simultaneous match, a lost cache node, step 1Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profilesPair likes DBSwipe historyProfile geoindex
    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.

  2. Stack, swipe, a simultaneous match, a lost cache node, step 2Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderrefill AnaStacks, filters,profilesPair likes DBSwipe historyProfile geoindex
    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.

  3. Stack, swipe, a simultaneous match, a lost cache node, step 3Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profilesPair likes DBSwipe historyProfile geoindex1,000 found
    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.

  4. Stack, swipe, a simultaneous match, a lost cache node, step 4Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profiles+200Pair likes DBSwipe historyProfile geoindex
    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.

  5. Stack, swipe, a simultaneous match, a lost cache node, step 5Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profilesPair likes DBSwipe historyProfile geoindex
    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.

  6. Stack, swipe, a simultaneous match, a lost cache node, step 6Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profilesPair likes DBAna ✓ Ben ✗Swipe historyProfile geoindex
    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.

  7. Stack, swipe, a simultaneous match, a lost cache node, step 7Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profilesPair likes DBAna ✓ Ben ✓Swipe historyProfile geoindex
    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.

  8. Stack, swipe, a simultaneous match, a lost cache node, step 8Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profilesPair likes DBSwipe historyProfile geoindex
    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.

  9. Stack, swipe, a simultaneous match, a lost cache node, step 9Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profilesPair likes DBboth read: noneSwipe historyProfile geoindex
    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.

  10. Stack, swipe, a simultaneous match, a lost cache node, step 10Phone app to API gateway; API gateway to Stack service; API gateway to Swipe service; Stack service to Stacks, filters, profiles; Swipe service to Stacks, filters, profiles; Swipe service to Pair likes DB; Swipe service to Swipe history; Pair likes DB to Match notifier, asynchronous; Match notifier to Phone app, asynchronous; Stack builder to Profile geo index; Stack builder to Swipe history; Stack builder to Stacks, filters, profiles.Phone appAPI gatewayStack serviceSwipe serviceMatch notifierStack builderStacks, filters,profiles1 of 10 downPair likes DBSwipe historyProfile geoindex

    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.

1 / 10

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.

Ways to detect a mutual right swipe
OptionGood atBad atPick it when
Write mine, read theirsNo 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 defaultBoth 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 pairLikes 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.
Ben likes Ana: set my flag, match if theirs is set
-- 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.

Ways to remember who I've seen
OptionGood atBad atPick it when
Read the swipe historyExact, 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 cacheExact 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 default12 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 diesWhat happensWhat you do
A cache nodeIts 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 primaryRight 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 nodeIts replicas take its writes.Replication factor 3; quorum writes for right swipes keep working with one node down.
The stack builderStacks 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 notifierThe 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

LevelWhat the answer does
JuniorStores swipes in one table and checks for the reverse swipe on each right swipe, without seeing the race until prompted.
MidEstimates the write rate, picks a write-optimised store, precomputes stacks, and uses a geo index with filters.
SeniorExplains 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.
The simultaneous swipeStrong answer
Interviewer

Two people swipe right on each other in the same millisecond. What happens?

You

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.

Interviewer

Why not just read the other person's swipe?

You

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.

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.

Self-review: swipe matching0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure