System Design Notes All designs

Location & Matching

Tinder — Matching & Swipes

Users set preferences, view a stack of nearby candidates, swipe left/right, and when two swipe right it's a match. The hard parts: low-latency stack loading (< 300 ms), absorbing a 100k swipes/sec write firehose, and race-free mutual-match detection.

Requirements

Functional

Non-functional

Scale & back-of-the-envelope

API design

POST /profiles              { minAge, maxAge, gender, radius }  -> Profile
GET  /stacks?lat&long       -> Profile[]      # location passed at request time
POST /swipes/{userId}       { decision: yes | no }  -> Match?   # match only on mutual like

High-level design

Reads (getStack) serve a precomputed stack from Redis (O(1), < 300 ms); writes (swipe) append to Cassandra + update Redis match keys, and on a mutual like create a Match (via a Saga) and push.

flowchart LR
    C["Client App"] -->|"swipe / getStack"| GW["API Gateway"]
    GW -->|"swipe yes/no"| SW["Swipe Service"]
    GW -->|"setProfile / getStack"| PS["Profile Service"]
    SW -->|"append swipe"| CASS["Swipe DB Cassandra"]
    SW -->|"match check + seen set"| RD["Redis"]
    SW -.->|"create match Saga"| PG["Profile DB Postgres"]
    SW -->|"APN / FCM push"| C
    PS -->|"read prefetched stack"| SC["Stack Cache Redis"]
    CRON["Cron Prefetcher"] -->|"query nearby"| ES["Elasticsearch Geo"]
    CRON -->|"warm stacks"| SC
    PG -->|"CDC"| ES
      

Deep dive · geosharded recommendation & prefetch

Running a geo + preference + seen-filter + rank query synchronously per request can't hit 300 ms. Elasticsearch holds a denormalized profile copy (kept fresh from Postgres via CDC), geosharded by geohash/region; a Cron Prefetcher runs each user's query off the request path and warms the Stack Cache.

flowchart TD
    subgraph WP["Write / Sync"]
        U["Profile or location update"] --> PSV["Profile Service"] --> PGDB["Postgres source of truth"]
        PGDB -->|"CDC stream"| ESI["Elasticsearch geo-sharded"]
    end
    subgraph PF["Async Prefetch"]
        CR["Cron Prefetcher"] -->|"geo + age + gender"| ESI
        ESI -->|"candidates"| CR
        CR -->|"drop already-seen via Bloom"| SK["Stack Cache Redis"]
    end
    subgraph RP["Read under 300ms"]
        CL["getStack lat long"] --> PSV2["Profile Service"] -->|"O(1) read"| SK
    end
      

Why prefetch? Only way to hit < 300 ms consistently. Cost: staleness (user moved) → mitigate with short TTLs, low-watermark refresh, and a live Elasticsearch fallback on miss or far-from-region requests.

Deep dive · swipe ingestion (Cassandra)

Cassandra writes are append-only into an LSM tree — commit log → memtable → ack → flush to immutable SSTable — which keeps up where Postgres's WAL-plus-seek cannot.

flowchart LR
    SWP["Swipe write A,B,yes"] --> CLog["Commit Log (append-only disk)"]
    CLog --> MT["Memtable (RAM)"]
    MT --> ACK["Ack to client"]
    MT -->|"periodic flush"| SST["SSTable (immutable disk)"]
    SST --> CMP["Background compaction"]
      

Partition by the swiper so "have I swiped on X?" is a single-partition read. Mitigate hot partitions (very active/popular users) with time-bucketed keys + gateway rate limiting; use QUORUM writes for the swipe-consistency NFR.

Deep dive · race-free mutual-match detection

If A and B swipe right on each other near-simultaneously on different servers, a naive "write my like, read the other's" can miss the match or create duplicates. Fix: an atomic check-and-set on a single canonically-ordered key match:{min(a,b)}:{max(a,b)} with a 2-bit field; the swipe that flips the second bit is the unique writer that sees "both set" and creates the match — exactly-once and idempotent.

sequenceDiagram
    participant A as User A
    participant SW as Swipe Service
    participant CS as Cassandra
    participant RD as Redis
    participant PG as Postgres
    participant N as Push Service
    A->>SW: POST /swipes/B decision yes
    SW->>CS: append swipe A,B,yes
    SW->>RD: atomic set bit A likes B
    RD-->>SW: reciprocal B likes A present
    alt both bits set
        SW->>PG: create Match A,B via Saga
        SW->>N: push It is a match
    else not yet mutual
        SW-->>A: 200 OK no match
    end
      

A Saga with compensations spans Redis → Postgres → push so we never notify a match that wasn't persisted. A Postgres UNIQUE(LEAST(a,b), GREATEST(a,b)) is the durable backstop.

Deep dive · filtering already-seen profiles

A Bloom filter per user is the front-line "no repeats" filter: it answers "definitely not seen" / "probably seen" with no false negatives, so it never re-shows a seen profile (a false positive merely skips a not-actually-seen one — harmless in a huge pool). Sizing 1B entries at 1% ≈ 1.2 GB of bits → a 128 GB node holds all seen-sets in RAM. The filter runs inside the Cron Prefetcher before writing the Stack Cache.

Bottleneck summary

Swipe throughput → Cassandra LSM + partition on swiper. Geo hotspots → adaptive geosharding. Stack staleness → TTL + low-watermark refresh + live ES fallback. Match correctness → single-shard atomic op on a canonical key + Postgres unique backstop + Saga. Redis durability → AOF + rebuild from the Cassandra log.