Design Tinder: System Design & Architecture

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.

Scope

In Scope

  • Profile creation and management: age, gender, bio, interests, location, and a max distance the user is willing to see profiles from
  • Profile operations: edit profile and preferences, delete account
  • Discovery feed: a stack of nearby profiles that fit the user's preferences, within their max distance
  • Swipe mechanics: right (like) or left (pass), with permanent filtering - a profile you have swiped on is never shown to you again
  • Match formation: two mutual right swipes create exactly one match, and both people are told

Out of Scope

  • The matchmaking/ranking algorithm (what order the stack is in): a recommendation system in its own right; we build the stack, not the science of sorting it
  • Chat and messaging after a match (delivery, receipts, presence - that whole problem is Design WhatsApp)
  • Photo upload and processing: photos appear as URLs here; the direct-upload pipeline itself is covered in Design Dropbox
  • Notifications (push/email) - we leave the hook where a match would trigger one
  • Revenue features (boosts, super likes, subscriptions)
  • Safety and moderation features: blocking, reporting, fake-profile detection internals

💡 What does "permanent filtering" mean, and why is it a real design problem?

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.

Functional Requirements

  • A user can create a profile with age, gender, bio, interests, location, and a max discovery distance
  • A user can edit their profile and preferences, or delete their account
  • A user sees a stack of nearby profiles that match their preferences (and whose preferences match them back)
  • A user can swipe right (like) or swipe left (pass) on each profile in the stack
  • A user never sees a profile they have already swiped on, in either direction
  • When two users have both swiped right on each other, a match is created and both users see it
  • A user can view their list of matches

Non-functional Requirements

  • Strong consistency for swipes and matches: a mutual right swipe must produce exactly one match - never zero, never two
  • Discovery latency: p99 < 200ms to load the next stack of profiles
  • Scale: 100M monthly active users, ~20M daily active, ~600M swipes/day
  • Availability: 99.9%+; the discovery feed may be slightly stale (eventual consistency is fine there), but an acknowledged swipe is never lost
  • Location privacy: never expose exact coordinates - only an approximate distance ("5 km away")

💡 Why strong consistency for swipes but eventual consistency for the feed?

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?"

Back-of-the-Envelope Estimates

  • Assume 100M monthly active users, ~20M daily active users, peak ~2M concurrent
  • A write is anything that creates or changes data: a swipe, a profile edit, a match. A read fetches without changing: loading the stack, viewing a profile, listing matches
  • Swipes: ~600M/day ÷ 86,400 seconds ≈ 7K swipes/sec average; a 10x peak factor gives ~70K/sec. Each swipe is one tiny write plus one point-read (checking whether the other person already liked you)
  • Discovery reads: ~10 profile cards viewed per swipe → ~6B card views/day, but cards are delivered in stacks of ~25 per request → ~240M stack requests/day ≈ 2.8K/sec average, ~28K/sec peak
  • Matches: only a small fraction of right swipes are mutual → a few million matches/day, thousands of times rarer than swipes. Rare + must-be-exact is a very different storage problem than frequent + tolerant
  • Storage: a swipe row is ~50 bytes (actor, target, direction, timestamp) → 600M/day x 50B ≈ 30GB/day, ~11TB/year, growing forever because of permanent filtering - swipe history is by far the biggest table. Profiles are ~2KB x 100M = ~200GB total, tiny by comparison
  • The shape: the swipe path is a firehose of tiny writes; the discovery path is heavy reads over a small, cacheable dataset. Two different workloads living in one app

💡 What the estimates mean: one app, three storage problems

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.

High-level Architecture

  • API Gateway: Authenticates users and rate-limits swipes and stack requests - both to protect the backend and to blunt scraping and bot swiping
  • Profile Service: Creates and updates profiles and preferences, including location updates when the app opens. Backed by PostgreSQL + PostGIS
  • Discovery Service: Builds the stack - runs the geospatial + preference query, filters out everyone the user has already swiped on, and caches prebuilt stacks in Redis so the feed feels instant
  • Swipe Service: Records each swipe as an append to the swipe store, then does the reverse lookup ("did they already like me?") to detect a mutual like
  • Match Service: Turns a detected mutual like into exactly one match row, using a database unique constraint as the final referee, and hands the event to notifications (out of scope)
  • Swipes DB (Cassandra, keyed by actor + target): The firehose - full swipe history per user, used for both match detection and permanent filtering
  • Profiles DB (PostgreSQL + PostGIS): Profiles, preferences, and locations, with a geospatial index for "everyone near me" queries; read replicas + cache absorb the read load
  • Matches DB (PostgreSQL): The small, precious table - one row per matched pair, unique-constrained
  • Cache (Redis): Prebuilt stacks, hot profile cards, and per-user swiped-sets (or bloom filters) for fast permanent-filtering checks

Data/User Flow Diagram

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)

💡 What is a geospatial index, and why do we need one?

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.

Data Model & Storage

  • Profiles (PostgreSQL + PostGIS): user_id, name, age, gender, bio, interests[], location (geographic point), max_distance_km, preference filters, last_active. SQL because this is relational reference data with modest writes, and PostGIS gives us the geospatial query the whole discovery path depends on. Read replicas plus a Redis cache absorb the read load.
  • Swipes (Cassandra, one row per (actor_id, target_id)): direction and timestamp. Keyed by actor and target so the same swipe written twice just overwrites itself (idempotent), the partition per actor is the user's full swipe history (permanent filtering), and the reverse check "did B like A?" is a single point-read of row (B, A). Written and read at QUORUM so a just-written like is guaranteed visible - the match race depends on it.
  • Matches (PostgreSQL): one row per matched pair, stored as (smaller user_id, larger user_id) with a UNIQUE constraint on the pair. SQL because this table is small and rare-write but carries the hard invariant: exactly one match per pair, enforced by the database itself, not by application code being careful.
  • Stack Cache (Redis): prebuilt stacks of ~100 candidate cards per active user with a short TTL, so most stack requests never touch PostGIS. Cache only - losing it costs a rebuild, never data.
  • Swiped Set (Redis): per-user set of already-swiped target ids for fast permanent filtering during stack builds; heavy swipers get a bloom filter instead (tiny memory, errs in the safe direction - see the low-level design).

💡 Why swipes live in Cassandra but matches live in PostgreSQL

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.

API Design

POST /v1/profiles
Create a profile with metadata, location, and preferences.
PUT /v1/profiles/:id
Edit profile fields and discovery preferences.
DELETE /v1/profiles/:id
Delete the account (profile removed from discovery immediately).
GET /v1/feed?limit=25
The next stack of nearby, preference-matched, never-before-swiped profiles.
POST /v1/swipes
Record a swipe (left/right) on a target profile; returns whether it created a match. Idempotent per (actor, target).
GET /v1/matches?cursor=...
List the user's matches, newest first, paginated.

Low-level Design

Discovery Service - building the stack (the read path, step by step):

  • 1) The client asks for the next 25 cards.
  • 2) The service checks the Redis stack cache ("stack:{user_id}"); active users usually hit it and get cards back in a few milliseconds.
  • 3) On a miss, it runs the PostGIS query: everyone within the user's max_distance whose profile fits the user's filters AND whose own preferences accept the user back (the filter must run both directions - showing someone a person who would never see them wastes everyone's time).
  • 4) Candidates are checked against the swiped set and anyone already swiped on is dropped - permanent filtering.
  • 5) The service caches a stack of ~100 survivors and returns the first 25; background refresh keeps active users' stacks warm so the geo query almost never runs in the request path.

Swipe Service - recording a swipe and creating the match (the genuinely tricky part):

  • 1) A swipes right on B: write the row (actor=A, target=B, direction=right) to Cassandra. The row is keyed by (actor, target), so a retried request overwrites itself - swipes are idempotent by construction.
  • 2) Read the reverse row (actor=B, target=A). If it is a right swipe, this is a mutual like.
  • 3) Hand the pair to the Match Service, which INSERTs into PostgreSQL with the pair stored in a canonical order (smaller id first) under a UNIQUE constraint.
  • 4) The race: A and B swipe right on each other at the same moment. Because each side writes its own swipe BEFORE reading the other's, and both operations run at quorum, it is impossible for both reads to miss - at least one side (often both) sees the mutual like.
  • 5) If both sides see it, both try to create the match. The first INSERT wins; the second hits the unique constraint and is treated as a harmless no-op that returns the existing match. Exactly one match, never zero, never two.
  • The pattern to name: a fast best-effort check up front, with a database constraint as the final referee - the same layered defense as the booking flow in Design Calendly.

Discovery Service - permanent filtering at scale:

  • The source of truth is the user's Cassandra partition: every (target, direction) they have ever swiped.
  • For stack builds we keep a Redis set "swiped:{user_id}" loaded from that partition, so exclusion checks are in-memory.
  • Heavy swipers are the catch: a user with 100K swipes makes the set expensive, so past a threshold we switch to a bloom filter - a fixed-size structure (tens of KB) that answers "definitely not swiped" or "probably swiped".
  • The errors only go in the safe direction: a false positive hides one never-swiped profile (they simply appear later, or never - harmless), but a swiped profile can never leak back into the stack.
  • This is a good example of choosing a probabilistic structure because its failure mode is acceptable, and saying that reasoning out loud.

Profile Service - location updates and privacy:

  • The app reports location when opened (and periodically while active); the service updates the profile's point column, which the GIST index picks up automatically.
  • Discovery filters with ST_DWithin(candidate.location, me.location, max_distance) - an index-served proximity test, not a distance calculation per row.
  • Privacy is enforced at the edge of the system: exact coordinates are stored but never returned by any API. Cards carry only a rounded distance ("5 km away"), because precise distances from a few vantage points can be used to triangulate someone's home.

Match Service - after the match:

  • The match row is the anchor for everything downstream: the matches list both users see, and the hook where a chat thread and a push notification would be created (both out of scope - chat is Design WhatsApp).
  • Listing matches is a simple indexed read per user, newest first, cached like any other read-heavy data.
  • Unmatching or blocking (safety scope) would tombstone the row rather than delete it, so the pair can never re-match - the unique constraint keeps doing its job.

Database Types and Schemas

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

API Request/Response Payloads

POST /v1/profiles
Request
{
  "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
  }
}
Response
{
  "user_id": "u_8f3e2d1c",
  "created_at": "2026-07-05T18:04:11Z"
}
GET /v1/feed?limit=25
Response
{
  "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.
POST /v1/swipes
Request
{
  "target_user_id": "u_456",
  "direction": "right"
}
Response
{
  "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.
GET /v1/matches?cursor=...
Response
{
  "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
}
Error responses (examples)
Response
// 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.

💡 Three terms from the flows: quorum, unique constraint, bloom filter

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.

Concluding Discussion

Potential Bottlenecks

  • Issue: Dense cities blow up the discovery query - millions of candidates within 25km of downtown, most of which get filtered away.
    Mitigation: prebuild stacks in the background so the geo query is off the request path, cap the candidate pool per build, prioritize recently-active users, and cache hot profile cards.
  • Issue: The swipe firehose peaks around 70K writes/sec.
    Mitigation: tiny append-only rows partitioned by actor spread the load evenly across the Cassandra cluster; there is no read-modify-write anywhere on the hot path, so throughput scales by adding nodes.
  • Issue: A very popular profile lands in a huge share of nearby stacks, making its card a hot read and flooding it with likes.
    Mitigation: cache the rendered card (one popular card served from Redis costs almost nothing), and cap how often a single profile is dealt into stacks per hour - which is also better product behavior.

Single Points of Failure

  • The Matches DB primary: if it is down, match creation stalls. Run a hot standby with automatic failover - and note the graceful degradation: swipes are still being recorded in Cassandra, so no mutual like is lost; the match materializes when the next swipe or a reconciliation pass re-checks the pair.
  • Redis (stacks + swiped sets): cache-only, so nothing is lost - but a cold cache sends every stack build to PostGIS at once. Cluster it, and coalesce concurrent rebuilds for the same user so the database sees one query, not fifty.
  • The Profiles DB: on every discovery path. Run read replicas behind the Discovery Service and promote on primary failure; profile edits pause briefly, discovery keeps serving from replicas and caches.

Security Improvements

  • Location privacy is the big one: store exact coordinates, expose only rounded distances, and rate-limit feed requests - repeated queries from spoofed locations can otherwise triangulate a person's home.
  • Rate-limit swipes and profile views per user at the gateway to blunt scraping (mass-harvesting profiles) and bot swiping (like-everyone spam).
  • Serve photos through signed, expiring CDN URLs so images of people who deleted their accounts stop resolving.
  • Enforce visibility server-side: a profile outside your distance/preferences (or deleted) is a 404 identical to a nonexistent one, so the API cannot be used to probe who is on the app.

Critical Knowledge

If the interviewer asks "how do you guarantee exactly one match when both people swipe right at the same moment?":

  • Layer the defenses. Swipes are idempotent by key (actor, target), so retries are safe. Each side writes its own swipe before reading the other's, at quorum - so it is impossible for both reads to miss the mutual like; at least one side (often both) detects it.
  • Both sides may then try to create the match, and that is fine: the match INSERT uses a canonical pair order plus a UNIQUE constraint, so one insert wins and the other is a harmless no-op returning the existing match.
  • Exactly one match, never zero, never two - guaranteed by the database, not by careful application code.
  • Name the pattern: fast best-effort detection up front, database constraint as the final referee. It is the same layered shape as the Redis hold plus exclusion constraint in Design Calendly.

If the interviewer asks "how does finding people nearby work with 100M users?":

  • Naively, "within 25km" means computing the distance to every user - a full scan per stack build. A geospatial index (PostGIS/GIST here) organizes users by where they are, so the query descends to your region and its neighbors and ignores the rest of the planet: roughly O(log n) instead of O(n).
  • Know one alternative by name: geohashes encode lat/lng so nearby points share a string prefix (Redis GEO uses this idea), letting a plain key-value store answer proximity queries.
  • Also say why the naive fix fails: separate B-tree indexes on latitude and longitude each cut one axis, leaving a huge strip of candidates to filter - proximity needs a 2D-aware index.
  • And the operational detail: locations change constantly, so the index must handle a high update rate, which is why location updates are batched and rate-limited from the client.

If the interviewer asks "how do you make sure someone never sees a profile they already swiped on?":

  • The truth lives in the swipe store: the user's partition is their entire swipe history. During stack builds we check candidates against a Redis set of already-swiped ids, loaded from that partition.
  • For heavy swipers the set gets big, so we switch to a bloom filter: fixed tiny memory, answers "definitely not swiped" or "probably swiped".
  • The key argument is the direction of error: a bloom filter false positive hides one unswiped profile (harmless, invisible), but it can never resurface a swiped one - the failure mode lands on the safe side, which is exactly when probabilistic structures are the right call.
  • Also note the cost this feature imposes: swipe history can never be deleted, which is why the swipes table is the largest and fastest-growing thing in the system.

If the interviewer asks "why three different data stores - is that not overkill?":

  • Match the store to the table, not to the app. Swipes: 600M/day of tiny, independent, never-updated rows with zero constraints - Cassandra eats that and scales linearly; a relational database would pay for guarantees nobody uses.
  • Profiles: modest size, read-heavy, and needing a genuinely hard query (geo + mutual preferences) - PostgreSQL with PostGIS, replicas, and a cache is the read-heavy recipe.
  • Matches: rare but carrying the one hard invariant (one match per pair) - a UNIQUE constraint in PostgreSQL enforces it atomically.
  • Then flip it around to show judgment: if scale were 100x smaller, one PostgreSQL instance would do all three jobs and the extra operational burden of three stores would be the wrong call. The reasoning, not the store list, is the answer.

If the interviewer asks "why is the feed eventually consistent when swipes are strongly consistent?":

  • Because consistency is chosen per operation by asking what breaks when data is briefly wrong. A stale stack shows a slightly outdated card - nothing breaks, so the feed happily reads from caches and replicas and gets speed for free.
  • A lost right swipe silently destroys a match that should have existed - the core promise of the product - and neither user can even tell. So the swipe path pays for quorum writes and reads and a transactional match insert.
  • The quorum math in one line: with 3 replicas, writes and reads each touch 2, and any two majorities overlap, so reads always see the newest write (R + W > N).
  • Strong everywhere would be slow and wasteful; eventual everywhere would be broken where it matters. Mixed, with reasons, is the senior answer.