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
- Set preferences; view a stack of candidates matching preferences and nearby; swipe left/no, right/yes.
- Two mutual right-swipes → create a match → notify both. Never show a profile already swiped on.
Non-functional
- Consistency for swipes (never lost; exactly one match on mutual like).
- Low-latency stack (< 300 ms); handle 10M × 100 swipes/day write throughput.
Scale & back-of-the-envelope
- 10M DAU × 100 swipes = 1B swipes/day ≈ 10k/s avg, ~100k/s peak; ~100 B each → 36.5 TB/yr.
- A single Postgres node ≈ 10k writes/s (B-tree random seek), but swipes peak at 100k/s → the append-only LSM write of Cassandra is why swipes live there, not Postgres.
- Seen-set bloom filters fit in ~128 GB RAM.
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.