Design Tinder: Geo Feeds, Swipes and Mutual Matches

Design Tinder: a geo-indexed candidate feed, a swipe firehose sharded by swiper, derived matches with deterministic IDs, and a Bloom-filter seen set.

Advanced 9 min read · Updated

Design the core of a location-based dating app: a user sees a feed of nearby candidate profiles, swipes like or pass on each, and when two people like each other they match and can start talking. In scope: the candidate feed, recording swipes and detecting mutual matches. Out of scope: post-match messaging (a separate service downstream of a match; see the chat design practice round), photo storage (large blobs behind a CDN), ranking quality, payments and identity verification.

Clarifying questions

  • What counts as a match? Both users like each other (double opt-in); one like alone is not a match.
  • Do we build chat? No; this design stops at "two users are now matched".
  • How fresh must the feed be? Seconds to minutes of staleness is fine; a new nearby user needn't appear instantly.
  • What's the scale unit? Swipes per day, not profile views; that number sizes the system.
  • Ranking or payments? Out of scope; assume a ranker already scores each candidate.

What makes this problem distinctive

It looks like nearby search (find profiles near me, filter by preference), but that's the smaller half. Every swipe is a write, one per card shown, so the feed is read far less often than its contents are acted on: the swipe path, not the search path, must survive the load. Two invariants sit under that write path: a match is a derived consequence of two independent likes (detected without scanning anything), and a profile a user has already swiped must never resurface (an exclusion check cheap enough to run on every request).

Key idea: the swipe write firehose shapes this design, and every swipe must respect two invariants: a match is derived, and a swiped profile never comes back.

Key concepts

  • Geospatial indexing: divide the map into cells (geohash strings or H3 hexagons) and tag each profile with its cell; "who's nearby" becomes "look up my cell and its neighbors" instead of scanning every user, the same nearby-search problem ride-hailing solves for drivers. See geospatial indexing.
  • Likes and derived matches: a swipe is a one-way decision (A likes B). A match exists only when both directions are likes, so detecting it means checking whether the reverse edge exists, not scanning for pairs. If the match record were lost but both swipes remained, it could be rebuilt.
  • The seen filter is a Bloom filter: keeping every swiped ID per user is exact but grows without bound. A Bloom filter (a bit array plus a few hash functions) answers "definitely not seen" or "probably seen". It never gives false negatives, so a swiped profile is never re-shown; a false positive merely hides an unseen candidate, which is harmless.
  • Shard keys and access patterns: when two queries read the same data along different axes, one shard key makes one cheap and the other a scatter across shards. Pick the axis of the more frequent pattern. See sharding.

1. Requirements

Unlike typical 100:1 read-heavy sites, Tinder is write-heavy: users act on almost everything they read.

  • Candidate feed: given location and preferences, return nearby profiles the user hasn't swiped.
  • Swipe: record a like or pass (the highest-volume write).
  • Mutual match: when two users like each other, create a match and notify both.
  • List matches: a user can see their current matches.
  • Swipe write throughput: billions of writes a day without falling over.
  • Match correctness: a mutual like produces exactly one match and notifies both: never zero, never two.
  • Feed latency: low hundreds of milliseconds.
  • No repeats: never show an already-swiped profile.
  • Availability and durability: swipes and matches survive failures; the feed may degrade before the swipe path does.

The property never to compromise: match correctness under a race (two near-simultaneous likes still give exactly one match). The constraint driving everything else: swipe write throughput.

2. Back-of-the-envelope

Swipes:  50M DAU × 100 swipes/day          = 5 billion swipes/day
         5×10⁹ ÷ 86,400 s                    ≈ 58,000 writes/s average
         × 3 evening peak                    ≈ 174,000 writes/s
Feed:    50M DAU × 20 feed pulls/day          = 1 billion reads/day ≈ 11,600 reads/s  (≈ 1/5 of writes)
Storage: swipe row ≈ 32 bytes (swiper, swipee, direction, ts)
         5×10⁹ × 32 B                         ≈ 160 GB of raw swipes per day, before indexes
Seen filter at 1% false positives: bits/entry ≈ −log₂(0.01) / ln 2 ≈ 9.6 bits  (vs 64 bits for an exact ID)

Matches need two likes, so they're a trickle compared with swipes; the matches list and notifications never approach swipe volume. Size for the writes.

3. API

Every endpoint takes the acting user from the authenticated session, never from the body.

EndpointRequestResponse
GET /v1/candidates?limit=20(session user; location and preferences server-side){ "candidates": [{ "user_id": "u_88", "name": "Sam", "age": 29, "distance_km": 3.2, "photos": ["https://cdn…"] }, …] }
POST /v1/swipes{ "swipee_id": "u_88", "direction": "like" }{ "matched": true, "match_id": "m_7f3…" }, or matched: false (a later match arrives by push)
GET /v1/matches(session user){ "matches": [{ "match_id": "m_7f3…", "user": {…}, "created_at": "…" }] }

Should the match check block the swipe? Swipes return immediately; if the reverse like already exists, the match is returned inline; otherwise a match discovered later arrives as a push notification.

4. Data model

EntityFieldsKey / notesLives in
Useruser_id, name, birthdate, gender, preferences, location, geo_cellPK user_id; geo_cell derived from coordinatesDurable, replicated store
Geo indexcell_id → set of user_idsInverts geo_cell so search is a few cell lookupsDurable, replicated
Swipeswiper_id, swipee_id, direction, created_atPK (swiper_id, swipee_id): one row per directed pair; a re-swipe overwritesDurable, sharded by swiper_id
Matchmatch_id, user_a, user_b, created_atmatch_id = hash(min(a, b), max(a, b)): identical from either sideSmall durable table
Seen filterBloom filter bitsOne per user at seen:{user_id}; updated per swipe, read per feedCache (Redis): rebuildable from swipe history

One user forces a geo index; one swipe forces a swiper-keyed table; one mutual pair forces a deterministic match ID; one "never re-show" rule forces a rebuildable seen filter.

5. High-level design, one fix at a time

Naive: one database for users and swipes. The feed is a distance query over all users, a swipe is an insert, a match check scans for the reverse row. Five things break: a full scan to find candidates; billions of swipe writes overwhelming one store; scanning for mutual likes; re-showing swiped profiles; and assembling a filtered, ranked feed live on every request.

  1. Geo index + candidate service: a feed request touches a handful of cells, not every row. Fixes search; swipes still overload the store.
  2. Swipe service sharded by swiper_id: each user's swipes land on one shard, so writes spread across shards and scale independently; it also keeps a user's exclusion data local.
  3. Reverse-key read for matches: when A likes B, write to shard(A), then point-read shard(B) for (swiper=B, swipee=A). A hit means B already liked A: insert the match by its deterministic ID with insert-if-absent. No scans.
  4. Per-user seen filter: a Bloom filter in Redis at seen:{swiper_id}, updated on each swipe and tested by the candidate service. 10,000 profiles as 64-bit IDs in a hash set cost about 300 KB per user with overhead; a Bloom filter at 1% false positives is 9.6 bits × 10,000 = 96,000 bits ≈ 12 KB. If a cache node dies, rebuild from the user's swipe history.
  5. Precomputed candidate queue: a background job pre-filters by geo cell and preferences into a per-user queue; a feed request pops from it, applies the fast-changing seen check, ranks and returns, with no live geo query on the hot path.
swipefeedaddtestMobile appAPI gatewaySwipe serviceCandidate serviceSwipe shardsby swiper_idMatchesinsert-if-absentSeen filtersRedis Bloom per userCandidate queuesper user, precomputedGeo index + profilesPush notifications
Composed design: swipes are sharded by swiper with a reverse-key match check and a Bloom seen filter; the feed is served from a precomputed per-user candidate queue built from the geo index.
Alice's appSwipe serviceShard(Alice)Shard(Bob)Matches + pushPOST /swipes {Bob, like}write (Alice → Bob, like)get (Bob → Alice)?like ✓insert match_id if absent; notify Alice & Bob{ matched: true }
A swipe that completes a match: write, add to the seen filter, reverse point read finds B's like, insert the match if absent, notify both.
import hashlib
import math

class BloomFilter:
    """Seen filter: 'definitely not seen' or 'probably seen'. No false negatives."""

    def __init__(self, capacity: int, fp_rate: float = 0.01):
        self.m = math.ceil(-capacity * math.log(fp_rate) / math.log(2) ** 2)   # bits
        self.k = max(1, round(self.m / capacity * math.log(2)))                 # hash functions
        self.bits = bytearray((self.m + 7) // 8)

    def _positions(self, item: str):
        h = hashlib.sha256(item.encode()).digest()
        a, b = int.from_bytes(h[:8], "big"), int.from_bytes(h[8:16], "big")
        return [(a + i * b) % self.m for i in range(self.k)]                    # double hashing

    def add(self, item: str):
        for p in self._positions(item):
            self.bits[p // 8] |= 1 << (p % 8)

    def __contains__(self, item: str) -> bool:
        return all(self.bits[p // 8] >> (p % 8) & 1 for p in self._positions(item))

swipes = {}          # (swiper, swipee) -> "like" | "pass"   (sharded by swiper in production)
matches = {}         # match_id -> (user_a, user_b)
seen = {}            # user -> BloomFilter

def match_id(a: str, b: str) -> str:
    lo, hi = min(a, b), max(a, b)                 # same id whichever side detects it
    return hashlib.sha1(f"{lo}:{hi}".encode()).hexdigest()[:16]

def swipe(swiper: str, swipee: str, direction: str) -> bool:
    swipes[(swiper, swipee)] = direction                         # 1. durable write on shard(swiper)
    seen.setdefault(swiper, BloomFilter(10_000)).add(swipee)     # 2. never show again
    if direction == "like" and swipes.get((swipee, swiper)) == "like":   # 3. reverse point read
        matches.setdefault(match_id(swiper, swipee), (swiper, swipee))   # insert-if-absent
        return True
    return False

print(swipe("alice", "bob", "like"))    # False: bob hasn't liked alice yet
print(swipe("bob", "alice", "like"))    # True: mutual like -> match
print(swipe("bob", "alice", "like"))    # True again, but still exactly one match row
print(len(matches), "bob" in seen["alice"], "carol" in seen["alice"])   # 1 True False
bf = BloomFilter(10_000)
print(bf.m, bf.k, round(bf.m / 10_000, 1))   # 95851 7 9.6 -> ~12 KB per user at 1% FP

6. Deep dives

Mutual-match detection under a race

In the common case (no reverse like yet) the check is one cheap point read, safe to run inline. The race: Alice and Bob like each other in the same millisecond, each write lands before the other's reverse read, and both checks report a hit. The deterministic match_id = hash(min(A, B), max(A, B)) collapses it: both sides compute the same ID, and creating the match is insert-if-absent, so the second detection is a no-op, giving exactly one row and one pair of notifications, with no locks or deadlocks. If the swipee's shard is briefly unavailable, the like is still durably written and the check retries asynchronously: a match can be delayed, never lost. See idempotency.

-- Matches table: the primary key makes concurrent detections idempotent
INSERT INTO matches (match_id, user_a, user_b, created_at)
VALUES (:match_id, :lo, :hi, now())
ON CONFLICT (match_id) DO NOTHING;      -- second detector is a no-op; only the inserter notifies

The swipe firehose and hot profiles

Sharding by swiper_id spreads writes evenly: a popular user's admirers write to their own shards. But each admirer's like triggers a reverse-lookup read on the popular user's shard ("has she liked me back?"), concentrating reads there. Reuse the Bloom-filter trick: keep a small filter of each user's outgoing likes, cached or replicated off the owning shard. A popular user has liked only a tiny fraction of admirers, so the filter answers "definitely not" for almost all of them without touching the shard. See hot partitions.

Candidate generation: order the filters cheap to expensive

  1. Geo: query the user's cell plus neighbors (boundary-aware), narrowing millions of users to a local set.
  2. Preferences: age, distance, gender, ideally mutual (keep only candidates whose own preferences admit this user).
  3. Seen filter: test each survivor against the Bloom filter; no false negatives means a swiped profile never comes back.
  4. Rank by recency, activity and the ranker's compatibility score, then store in the per-user candidate queue so a swipe session is served from memory and refilled in the background.

Degrade gracefully: if ranking is down, fall back to distance-sorted candidates; a brief seen-filter outage may resurface a few profiles (annoying, not a correctness failure). Monitor candidate-queue hit rate, empty-feed rate and geo-query latency. A weak answer queries everyone nearby unfiltered; a good one applies geo, preference and seen filters then ranks; a great one orders them cheap-to-expensive, states the filter's guarantee, and precomputes with fallbacks.

7. Variants

  • 10× scale: more swipe shards, more batching per shard, and the match check fully behind an async queue so a slow reverse read never adds swipe latency. Shard the geo index finer in dense metros, where one cell can hold more users than a shard should (the same hot-cell problem ride-hailing has).
  • Global distribution: dating is geo-local, so partition by region: per-region profiles, geo index, swipe shards and matches. Travel mode ("show me people where I'm visiting") is the rare cross-region case. See geo-replication.
  • Recommendation quality: ranking is its own ML system that learns from the swipe stream and scores candidates at generation time; treat it as a dependency. See recommendation systems.

The transferable pattern

Separate a write-heavy stream of independent events (swipes) from a read path over derived state (matches, feed). Detect a relationship between two entities with a reverse-key point read plus a deterministic ID and insert-if-absent, not a scan. Enforce "never show again" with a probabilistic set whose error runs in the safe direction. The same shape fits anywhere two independent actors must discover a mutual condition without a coordinator: mutual follows, friend requests, two-sided marketplaces.

  • Two paths: a swipe write firehose and a candidate-feed read; the firehose sizes the system.
  • Nearby candidates are a geospatial-indexing problem.
  • A match is a mutual like detected on write with a reverse-key read, never scanned for.
  • A Bloom filter guarantees a swiped profile is never shown again.
  • Matches are rare; swipes are not.

Where it’s used

Dating apps

Tinder, Bumble and Hinge: geo-filtered feeds, swipe streams and mutual matching.

Social graphs

Mutual follows and friend requests use the same reverse-edge check.

Marketplaces

Two-sided matching (riders and drivers, candidates and recruiters) with derived matches.

Recommendation feeds

Bloom-filter "already seen" sets keep feeds from repeating items.

In an interview

  • Show that swipes, not feed reads, size the system: ~58K writes/s average, ~174K at peak.
  • Shard swipes by swiper_id, detect matches with a reverse point read, and make match creation idempotent with a deterministic ID.
  • Use a per-user Bloom filter for the seen set and explain why its error direction is safe.
  • Precompute candidate queues with filters ordered cheap to expensive, and degrade gracefully.