Build a location-based dating app: users set up a profile, see a stack of nearby people who match their preferences, swipe right or left, and get matched the moment two people like each other.
Permanent filtering is the rule that you never see a profile you have already swiped on, in either direction. It sounds like a small UI detail, but it quietly shapes the data model: the system has to remember every swipe you have ever made, forever, and consult that history every single time it builds your stack. A heavy user can easily have tens of thousands of swipes, and the check has to happen for every candidate profile on every stack build without slowing the feed down. That is why swipe history gets its own write-optimized store and its own fast lookup structure (a cached set, or a bloom filter for heavy swipers) rather than being an afterthought.
Consistency is not one setting for the whole app - you choose it per operation, based on what breaks when the data is briefly wrong. The discovery feed can be stale without anyone noticing. If the stack was built thirty seconds ago and someone in it has since moved away or changed their bio, the worst case is you see a slightly outdated card. Nothing is broken, so we happily serve the feed from caches and read replicas and take the speed. Swipes are the opposite. A right swipe is the core promise of the product: if two people like each other, they match. If a swipe write is lost, a match that should exist never happens and neither person ever knows - a silent, unrecoverable failure of the one thing the app is for. If match creation runs twice, users see duplicate matches. So the swipe-and-match path pays for strong consistency: writes that are durably acknowledged, reads that are guaranteed to see them (quorum reads and writes, explained in the low-level design), and a database constraint as the final referee. This mixed model is worth saying out loud in an interview: one system, two consistency levels, each chosen by asking "what actually breaks if this data is briefly wrong?"
The numbers split the system into three workloads that want three different stores, and saying this split out loud is most of the database discussion. Swipes are a write firehose: 70K/sec at peak, every row tiny, append-only, and never updated (your swipe on a person is one fact). There is no relational constraint to enforce on a swipe - writing the same swipe twice is harmless because it is keyed by (actor, target) and just overwrites itself. That profile - huge volume, tiny independent rows, no transactions - is exactly what a wide-column NoSQL store like Cassandra is built for: writes spread across partitions and throughput scales by adding machines. This mirrors the argument in Design a Newsfeed, where fan-out writes drove the same choice. Profiles and discovery are the opposite: a modest dataset (~200GB) that is read constantly and queried in a genuinely hard way - "everyone within 25km of me who fits my preferences and whose preferences fit me." That needs a real query engine and a geospatial index, so profiles live in PostgreSQL with PostGIS, scaled with read replicas and a Redis cache in front, exactly the read-heavy recipe from Design Dropbox's metadata tier. Matches are rare (millions/day, not billions) but carry the system's one hard invariant: exactly one match per pair of users. Rare writes plus a hard correctness rule is the textbook case for a relational database with a unique constraint - the same "let the database be the referee" move as the booking exclusion constraint in Design Calendly. The lesson underneath: you do not pick one database for the app. You pick per table, by write volume, query shape, and what invariants must hold.
DISCOVERY - building the stack
[Client] --> [API Gateway] --> [Discovery Service] --> [Stack Cache (Redis)]
| (miss)
+--> [Profiles DB (PostgreSQL + PostGIS)]
| geo query: within max_distance
| + mutual preference filters
+--> [Swiped Set (Redis / bloom filter,
| backed by Swipes DB)]
v exclude already-swiped
cache a stack of ~100, return 25 cards
SWIPE + MATCH - the write path
[Client] --> [API Gateway] --> [Swipe Service]
|
+--append--> [Swipes DB (Cassandra)]
| row: (actor, target, direction)
|
+--reverse lookup: did target already
| right-swipe me? (point read)
| | yes - mutual like
v v
[Match Service] --insert once--> [Matches DB
| (PostgreSQL, UNIQUE pair)]
v
both users see "It's a Match"
(push notification hook - out of scope)The discovery query is "find everyone within 25km of this point." Without help, a database answers that by computing the distance from you to every one of 100M users and keeping the close ones - a full scan per stack build, which is hopeless. A geospatial index (PostGIS with a GIST index, in our case) organizes rows by where they are, the way an atlas groups places by map page. Users are stored under the regions of the map they sit in, so "within 25km of me" starts at your region, checks it and its neighbors, and never even looks at the millions of users on other continents. The scan becomes a tree descent - roughly O(log n) instead of O(n). The same idea appears under different names elsewhere: geohashes (encode lat/lng into a short string so nearby places share a prefix) and Redis GEO commands (GEOADD/GEOSEARCH) are alternative implementations. In an interview, naming one concrete mechanism and explaining why a plain index on latitude and longitude columns is not enough (a range on each axis still leaves a huge rectangle to filter) is what shows real understanding.
It looks inconsistent at first - swipes and matches are both "two users interacting" - but the two tables could not be more different, and the split is the whole lesson. Swipes: 600M/day, append-only, never updated, no constraint to enforce. If a swipe row is written twice, nothing happens (same key, same value). Each swipe is an independent fact that touches exactly one partition. Cassandra absorbs this shape almost for free, and no relational feature would be earning its cost. Matches: a few million/day - thousands of times rarer - but with a rule that must never be violated: one pair, one match. Enforcing "never two" in application code across concurrent servers is exactly the kind of careful-code that eventually fails; a UNIQUE constraint in PostgreSQL makes the database itself reject the duplicate, atomically, no matter how the race unfolds. When a rule must hold, put it where it cannot be bypassed. The general principle: choose the store per table, not per app. High-volume tolerant data goes to the store that scales writes; low-volume precious data goes to the store that enforces truth. Design Calendly makes the same move with its booking exclusion constraint, and Design a Newsfeed makes the mirror-image argument for why its feed entries need no constraints at all.
Database Schemas
- Profiles (PostgreSQL + PostGIS)
user_id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL,
age INT NOT NULL,
gender gender_enum NOT NULL,
bio TEXT,
interests TEXT[],
photo_urls TEXT[], -- pointers; upload pipeline out of scope
location GEOGRAPHY(POINT) NOT NULL,
max_distance_km INT DEFAULT 25,
pref_gender gender_enum[],
pref_age_min INT DEFAULT 18,
pref_age_max INT DEFAULT 99,
last_active TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_location ON profiles USING GIST (location)
-- "everyone within X km" as a tree descent, not a 100M-row scan
INDEX idx_prefs ON (gender, age)
-- narrows candidates before the mutual-preference check
- Swipes (Cassandra) -- the firehose: 600M rows/day, append-only
PRIMARY KEY ((actor_id), target_id)
Fields: direction ('left' | 'right'), ts
-- one row per (actor, target): swiping twice overwrites itself (idempotent)
-- the partition (actor_id) = full swipe history -> permanent filtering
-- reverse check "did B like A?" = point read of row (B, A)
-- QUORUM writes + QUORUM reads: a just-written like is always visible,
-- which is what makes the simultaneous-swipe race safe
- Matches (PostgreSQL) -- small, rare-write, precious
match_id UUID PRIMARY KEY,
user_a UUID NOT NULL, -- always the smaller user_id of the pair
user_b UUID NOT NULL, -- always the larger user_id
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (user_a, user_b)
-- the referee: exactly one match per pair, enforced by the database,
-- no matter how the concurrent-swipe race interleaves
INDEX idx_matches_a ON (user_a, created_at DESC)
INDEX idx_matches_b ON (user_b, created_at DESC)
Redis keys
- Stack cache (prebuilt discovery stacks)
Key: "stack:{user_id}" -> [candidate cards ...]
TTL: ~10 minutes; refreshed in the background for active users
- Swiped set (permanent filtering)
Key: "swiped:{user_id}" -> set of target_ids
-- heavy swipers switch to a bloom filter: fixed tens-of-KB memory,
-- "definitely not swiped" / "probably swiped"; errors only hide
-- an unswiped profile, never resurface a swiped one
- Hot profile cards
Key: "profile:{user_id}" -> rendered card JSON
TTL: ~5 minutes; absorbs reads for widely-shown profiles{
"name": "Jordan",
"age": 28,
"gender": "male",
"bio": "Software engineer who loves hiking",
"interests": ["hiking", "coffee", "live music"],
"location": { "latitude": 37.7749, "longitude": -122.4194 },
"max_distance_km": 25,
"preferences": {
"gender": ["female"],
"age_min": 24,
"age_max": 34
}
}{
"user_id": "u_8f3e2d1c",
"created_at": "2026-07-05T18:04:11Z"
}{
"profiles": [
{
"user_id": "u_456",
"name": "Sam",
"age": 27,
"bio": "Artist and traveler",
"photo_urls": ["https://cdn.example.com/p/u_456/1.jpg"],
"distance_km": 5
}
],
"count": 25,
"stack_refreshes_at": "2026-07-05T18:14:00Z"
}
// distance_km is rounded on purpose - exact coordinates and precise
// distances are never exposed (they can be used to triangulate someone's
// location). Every profile here has already passed the mutual-preference
// check and the already-swiped exclusion.{
"target_user_id": "u_456",
"direction": "right"
}{
"matched": true,
"match_id": "m_7a2b8f4e"
}
// Idempotent: retrying this request overwrites the same (actor, target)
// row and returns the same result. If both users swipe right at the same
// moment, both may attempt match creation - the UNIQUE constraint makes
// the second attempt a no-op returning the same match_id, so both clients
// can show "It's a Match" for the same single match.{
"matches": [
{
"match_id": "m_7a2b8f4e",
"user": {
"user_id": "u_456",
"name": "Sam",
"photo_urls": ["https://cdn.example.com/p/u_456/1.jpg"]
},
"created_at": "2026-07-05T18:05:02Z"
}
],
"next_cursor": "2026-07-05T18:05:02Z#m_7a2b8f4e",
"has_more": false
}// 429 Too Many Requests - swipes are rate limited per user
{
"error": "rate_limited",
"message": "Swipe limit reached. Try again later.",
"retry_after_seconds": 3600
}
// 404 Not Found - profile does not exist or is not visible to the caller
{
"error": "not_found",
"message": "Profile not found"
}
// Deleted or out-of-range profiles return the same 404 as nonexistent
// ones, so the API cannot be used to probe who is on the app.Quorum: Cassandra keeps each row on several replicas (say 3). A quorum write waits until a majority (2 of 3) confirm; a quorum read asks a majority and takes the newest answer. Any two majorities must overlap in at least one replica, so a quorum read is guaranteed to see the latest quorum write. That guarantee is what makes our match detection safe: when you swipe, your read of the other person's swipe cannot miss a write that already completed. Unique constraint: a rule the database itself enforces - here, "only one row may ever exist for this pair of users." Application code can check before inserting, but two servers can pass that check at the same moment; the constraint is checked atomically inside the database, so the race has a guaranteed winner and a guaranteed, harmless loser. Same philosophy as the booking exclusion constraint in Design Calendly: put the invariant where it cannot be bypassed. Bloom filter: a set that trades a little accuracy for a lot of memory. It can answer "definitely not in the set" or "probably in the set" using a fixed few tens of kilobytes, no matter how many items go in. For swipe history the trade is perfect because the error only goes one way: it may occasionally hide a profile you never swiped on (mildly wasteful, invisible to the user), but it can never show you one you already did.