Skip to main content
PProfileMatch

MediumProblem 8 of 36 in the system design series

Design Tinder: Geo Matching, Billions of Swipes and Instant Matches

Prefer watching? This article is the written companion to the video above, from the Shubham Aher YouTube channel.

A swipe-based dating app shows people one profile card at a time, drawn from users who live nearby and fit their preferences. A right swipe means interest, a left swipe means pass, and when two people both swipe right on each other the app declares a match and tells both of them. The interface is tiny, but the backend behind it has to juggle location search, a firehose of writes and a subtle concurrency problem.

Interviewers pick a Tinder-like service because it mixes three distinct skills in one problem. You need a geospatial query that also respects two-way preferences, a storage layer that can swallow billions of small records every day, and a correct way to detect a mutual like even when both people tap at the same instant. Each part has an obvious answer that turns out to be wrong.

This is an interview practice design for a Tinder-like service, built from first principles. It is not a description of how Tinder is actually built.

Functional Requirements

Users create a profile and configure what they are looking for: an age range, how far away a candidate may be, and which genders interest them. Those settings define the candidate pool, so they are first-class data rather than an afterthought on the profile page.

From there the product reduces to three actions. The app serves a stack of nearby profiles that fit the preferences, the user swipes each card right or left, and a mutual right swipe produces a match with a notification to both people. Messaging after a match, photo moderation and paid features are left out because each would add its own subsystem without changing the core design.

Set preferences

Age range, maximum distance, and who you're interested in.

See nearby profiles

A stack of candidates that match your preferences.

by distance

Swipe

Right to like, left to pass.

like · pass

Get matched

Mutual like → both people are notified instantly.

mutual like

  • Out of scope today: chat after a match, photo moderation, payments

Non-Functional Requirements

Swiping is a rhythm, so the next card must be ready in under 300 ms or the experience feels broken. That requirement is why card stacks are prepared ahead of time instead of being computed per swipe.

Correctness matters for matches in a way it does not for most counters. If two people like each other, the system must create the match every time and never create it twice; a missed match is a lost connection, and a duplicate produces two notifications. Meanwhile the swipe path is overwhelmingly write-heavy, since every left and right swipe is persisted. Finally, someone who already swiped on a profile should not see it again, which needs a cheap way to remember what each person has seen.

Fast card stack

The next profiles appear instantly.

< 300 ms

Correct matches

A mutual like always becomes a match, exactly once.

no missed matches

Write-heavy swipes

Billions of swipes a day, and every one is stored.

writes ≫ matches

No repeats

Never show a profile you already swiped on.

seen filter

Scale Estimation

Assume about 20 million daily active users, each swiping roughly 100 times a day. That gives around 2 billion swipes daily, which averages to about 23K per second and climbs to roughly 70K per second during evening peaks.

If one swipe record is about 100 bytes (two user IDs, the decision and a timestamp), that is close to 200 GB of fresh data a day and on the order of 73 TB a year. Matches are a tiny fraction of that volume. The estimate tells us where to spend effort: a storage engine tuned for appends, a key layout where checking whether A swiped on B is a single lookup, and no read-modify-write cycle on the hot path.

20million

Daily active users

≈ 100 swipes each / day

2billion

Swipes per day

≈ 23K / sec · ≈ 70K at peak

200GB / day

New swipe data

≈ 100 bytes per swipe

73TB

Swipe data per year

200 GB × 365 days

Swipe writes dominate → write-optimised store, one-lookup checks

Core Entities & API

The data model has four entities: Profile, Preferences, Swipe and Match. Separating preferences from the profile keeps the search filters explicit, and keeping matches apart from swipes lets the small, frequently read match list live in its own table.

Three endpoints are enough. PUT /profile saves the bio, photo references, preferences and the latest location. GET /feed takes the caller's coordinates and returns a batch of candidate profiles. POST /swipes carries the target user's ID and a decision of like or pass, and the response says whether that swipe completed a match. Returning the match result synchronously lets the app show the match animation right away, instead of waiting for a push notification to arrive.

Core entities

ProfilePreferencesSwipeMatch

API

  • PUT /profile { bio, photos[], prefs, location }→ 200 OK
  • GET /feed?lat&lng→ { profiles[] }
  • POST /swipes { target_id, decision: like | pass }→ { matched: true | false }

High-Level Design

All traffic passes through an API gateway for authentication and routing, then splits into two paths. On the read path, a feed service asks a geo-aware search index such as Elasticsearch for profiles that fit the caller's location and filters. Profiles are owned by an ordinary database and replicated into that index, so search load never touches the system of record. The resulting candidates are cached as a ready-made stack in Redis.

On the write path, a swipe service records each decision in Cassandra, chosen because it is built for high-volume appends. For right swipes it also runs an atomic check in Redis to learn whether the other person has already liked back. When that check finds a mutual like, a notification service sends a push to both phones through Apple's and Google's push gateways.

nearby + prefscache stackevery swipeatomic checkit's a matchpush to bothClient appswipe UIAPI gatewayauth · routingFeed servicebuild card stacksSearch indexElasticsearch · geoCard stacksRedis · ~100 cardsSwipe servicelike · passSwipe storeCassandraMatch checkRedis · atomicNotificationsAPNs · FCM
High-level design. Dashed arrows are taken only on a cache miss or as a fallback. Scroll sideways on small screens.

Deep Dive 1 — Nearby Candidates

Building a candidate stack starts with fresh location. Each time the app opens it reports coordinates, and the backend stores them as a geo point on the profile so searches center on where the person is today, not where they signed up.

The search itself combines a distance filter with preferences evaluated in both directions: the candidate must fit my criteria, and I must fit theirs. Adding a recently-active filter avoids wasting cards on dormant accounts. A search index can apply the geo radius and all these filters in a single request.

Next, anyone already swiped on must be removed. Querying billions of swipe rows per request would be far too slow, so each user has a Bloom filter of the IDs they have swiped. A Bloom filter can produce false positives, which means occasionally hiding someone you have not seen, but it never produces false negatives, so a repeat card is impossible. The survivors are ranked, for example by activity and distance, and about 100 of them are cached in Redis. When the stack runs low, a background job refills it.

  1. 1Update locationApp sends location on open → geo point on profile
  2. 2Geo + filter queryDistance, two-way preferences, recently active
  3. 3Drop already-seenPer-user Bloom filter of swiped IDs
  4. 4Rank the resultsRecent activity and distance first
  5. 5Cache a stack~100 cards in Redis; refill in the background
  • Bloom filter: no false negatives → a swiped profile never comes back

Deep Dive 2 — Match Detection

The naive approach writes my like and then reads whether you liked me. If we both swipe within a few milliseconds, each read can run before the other write is visible, both checks return nothing, and the match silently disappears. That violates the strongest requirement in the design.

A distributed lock on the pair fixes the race but adds lock acquisition and release round trips to every like, which is expensive at around 70K swipes per second. The chosen approach is an atomic check on a single key per pair. Sort the two user IDs so the key is the same no matter who swipes first, for example likes:{min}:{max}. A short Lua script in Redis records the caller's like and reads the other side in one indivisible operation.

Because both swipes target one key on one shard, Redis executes them one after the other, and whoever arrives second is guaranteed to see the first like and create the match. Redis runs with persistence and replication enabled, Cassandra still receives every swipe as the durable record, and the match insert is idempotent on the pair, so a retry can never produce a duplicate.

Write, then read back

  • + Simple
  • − Both swipe at once → both miss
  • − Lost matches

Lock per pair

  • + Correct
  • − Extra lock round trips
  • − Slow at 70K swipes / sec

Atomic pair-key checkChosen

  • + One atomic step
  • + Both swipes hit one key
  • − Redis must be durable
  • key = likes:{min(a,b)}:{max(a,b)} → both swipes land on one shard
  • Match insert is idempotent on the pair → never created twice

Deep Dive 3 — Storing Swipes

Swipes are stored in Cassandra with the swiper's user ID as the partition key and the target's ID as the clustering key. Writing a swipe appends to one partition, and the question "did Asha already swipe on Rahul?" is a direct lookup on a known row. The write path never reads first, and log-structured storage turns each insert into a cheap sequential append, which is why the cluster can absorb peaks of tens of thousands of writes per second.

Matches get their own table keyed by user and sorted by time, so listing someone's matches newest first is one query against a small dataset. Every swipe also adds the target to the swiper's Bloom filter, and if that filter is ever lost it can be rebuilt by scanning that user's partition. Old pass decisions can carry a TTL of a few months, which reclaims storage and lets a once-rejected profile resurface later.

  • Cassandra, partitioned by swiper

    Partition = swiper ID, clustered by target ID: fast writes, one-lookup checks.

  • Append-only writes

    No read before write; log-structured storage absorbs 70K writes per second.

  • Matches in their own table

    Small and read often: a user's matches, newest first, in one query.

  • Rebuildable Bloom filters

    Updated on each swipe; rebuilt from the user's Cassandra partition if lost.

  • Expire old passes

    Saves space, and gives people a second chance to appear.

Trade-offs

Prebuilt card stacks make swiping instant, but a stack may be a few minutes stale, so a candidate who just changed preferences can still appear briefly. The Bloom filter keeps memory tiny and lookups fast, at the cost of occasionally skipping a profile the user never actually saw.

The Redis pair-key check gives correct matches under concurrency, but it only works if Redis is persistent and replicated, so it is treated as critical infrastructure. Syncing profiles into the search index asynchronously lets search scale independently, with profile edits taking a few seconds to become searchable. In short: a geo index finds candidates, swipes are append-only, and every pair of users shares one atomic key.

  • Precomputed card stacks

    Instant swiping — but a stack can be minutes out of date.

  • Bloom filter for seen profiles

    Tiny and fast — but it may skip a few unseen profiles.

  • Redis atomic match check

    Correct under races — but Redis must be persistent and replicated.

  • Eventually consistent profiles

    Search scales well — profile edits reach the index in seconds.

Geo index for candidates. Append-only swipes. One atomic key per pair.

Key takeaways

  • Swipe writes dominate at roughly 23K per second on average, so pick an append-optimized store like Cassandra.
  • Use a geo-enabled search index with two-way preference filters to build candidate stacks.
  • A per-user Bloom filter guarantees no repeat cards, at the cost of occasionally skipping an unseen one.
  • Detect matches with one sorted pair key and an atomic Redis Lua script to remove the race.
  • Make match creation idempotent and keep Cassandra as the durable record of every swipe.

Interview questions

How does a dating app detect a match when both users swipe at the same time?

Give each pair of users one shared key by sorting their IDs, then use an atomic Redis Lua script that records the new like and checks for the other person's like in a single step. Since both swipes hit the same key, Redis processes them sequentially, so the second one always sees the first and creates the match. An idempotent match insert prevents duplicates on retry.

How do you avoid showing a profile the user already swiped on?

Keep a per-user Bloom filter containing every profile ID that user has swiped on, and check candidates against it while building the card stack. Bloom filters never give false negatives, so a seen profile cannot reappear. The rare false positive only hides an unseen profile, which is harmless. The filter can be rebuilt from the user's swipe partition if lost.

What database should store billions of swipes?

A write-optimized wide-column store such as Cassandra fits well. Partition by the swiper's user ID and cluster by target ID so each swipe is a cheap append and checking a specific swipe is a single-row lookup. Its log-structured storage handles tens of thousands of writes per second, and adding nodes scales capacity as the dataset grows by terabytes each year.

How do you find nearby users that match preferences in both directions?

Store each profile's latest location as a geo point in a search index like Elasticsearch, alongside age, gender and activity fields. One query can apply a distance radius, filter on the caller's preferences, and also require that the caller fits each candidate's preferences. Results are then ranked and cached as a stack so swiping does not query the index on every card.

Why cache a card stack instead of querying on every swipe?

Geo search with several filters is too slow to run per card if the next profile must appear in under 300 ms. Precomputing around 100 candidates and storing them in Redis lets the app pop cards instantly, while a background job refills the stack when it runs low. The trade-off is that a cached stack may lag reality by several minutes.

Keep practising