“Design Tinder” sounds like Instagram with a nicer gesture. Show a photo, swipe left or right, if two people both swipe right they can chat. The interviewer lets that toy version sit for about a minute, then drops the number that breaks it: 2 billion swipes a day. That is roughly 23,000 writes per second on average and 70,000+ at peak, every single one of them a tiny row that says “user A has an opinion about user B,” and every one of them potentially the second half of a mutual match that has to fire a notification within a second.

Two problems are hiding in that number, and they pull in opposite directions. The first is write volume: 2B swipes/day is a firehose of tiny writes that must not cost a database transaction each. The second is match detection: when B swipes right on A, you must instantly know whether A already swiped right on B, and if so, create a match and notify both - a read-your-neighbour’s-write problem at 23k/sec. Wrapped around both is the thing users actually experience: the deck - that stack of profiles served to each person, ranked by location, preferences, and desirability, refilled before they run out. Get the deck wrong and there is nothing to swipe on. Let me build it properly.

Functional Requirements (FR)

In scope:

  • Profile and preferences. A user has photos, a bio, an age, a gender, a location, and discovery preferences (interested-in gender, age range, max distance). They can edit these.
  • The recommendation deck. When a user opens the app, the system serves a stack of candidate profiles - people nearby who fit their preferences and whose preferences the user fits - ordered so the most promising are on top. The deck refills as they swipe.
  • Swipe. A user swipes right (like), left (pass), or super-likes a profile. Every swipe is recorded so the same profile is not shown again.
  • Match on mutual right-swipe. If A right-swiped B and B right-swipes A, a match is created and both users are notified in real time. A match is the precondition for chat.
  • Chat. Matched users can exchange text messages. Unmatched users cannot message each other at all.
  • Unmatch / block / report. Either party can unmatch, which closes the conversation. Blocking removes the other from all future decks.

Explicitly out of scope (say it so you own the scope):

  • The ranking model internals. How the ML model scores desirability and compatibility (the “Elo-like” attractiveness signal, embedding similarity) is a separate ML system. I design the serving and feature-collection path and treat “give me a ranked candidate list” as a scored retrieval problem, not the model training.
  • Payments and subscriptions. Tinder Gold, Boosts, and Super Like packs are a billing system. I will note where they hook in (e.g. Boost reprioritises you in others’ decks) but not design the ledger.
  • Photo moderation, verification, KYC. Assume photos are already moderated and users are verified; treat it as an async pipeline that flags profiles.
  • Rich media chat, voice, video. Chat is text (plus a media pointer) for this design; a full media pipeline is its own system.

The one decision that drives everything: this is a write-heavy system whose reads are a ranked geo-and-preference retrieval, and whose correctness hinges on one small strongly-consistent operation - creating a match exactly once. Everything else (the deck, swipe recording, presence) can be eventually consistent and approximate. The match write cannot.

Non-Functional Requirements (NFR)

  • Scale: 75M monthly active users, ~30M daily active users, 2B swipes/day (~23k/sec average, ~70k/sec peak). Tens of millions of matches/day, hundreds of millions of chat messages/day.
  • Latency: opening the app and seeing the first card must feel instant - deck served in under ~200ms (from a pre-computed cache, not a live query). A swipe must register in well under 100ms (fire-and-forget write). A mutual match must notify both users within ~1s of the second right-swipe.
  • Availability: 99.9%+ on the swipe and deck path. If the deck cannot refill, the product is dead. Swipe writes may degrade to buffered/async but must never be lost silently - a lost right-swipe is a lost match.
  • Consistency: match creation is strongly consistent and idempotent - a mutual like must create exactly one match, never zero, never two, regardless of who swiped last or of concurrent swipes. The deck, swipe history dedup, and desirability scores are eventually consistent and best-effort. Chat is ordered per conversation but eventually consistent across replicas.
  • Durability: swipes (especially right-swipes), matches, and messages must be durable - they are the substance of the product. A deck that is regenerated is cheap; a lost match is not.
  • Privacy: left-swipes and who-liked-whom must never leak (revealing “X passed on you” is a product and trust disaster). Access to the likes table is tightly scoped.

Back-of-the-Envelope Estimation (BoE)

Real numbers with the arithmetic, because they dictate the architecture.

Swipes (the write firehose):

2,000,000,000 swipes/day
= 2e9 / 86,400
≈ 23,000 swipes/sec average
Peak (evenings, ~3x) ≈ 70,000 swipes/sec

That 23k-70k writes/sec is the number that kills a naive design. Each swipe is tiny (~30 bytes of meaning) but there are billions of them, most are left-swipes that carry little value, and each right-swipe triggers a match-check read.

Match checks and matches:

Right-swipe rate ≈ 45% of swipes  ->  ~0.9B right-swipes/day  (~10k/sec)
Every right-swipe does one "did they already like me?" lookup.
Mutual-match probability per right-swipe ≈ ~3%
Matches/day ≈ 0.9B * 0.03 ≈ 27M matches/day  (~300/sec)

So the match-detection read runs at the right-swipe rate (~10k/sec), but the strongly-consistent match write is rare (~300/sec). That asymmetry is the whole game: cheap approximate reads on every right-swipe, expensive strong writes only on the ~3% that hit.

Deck reads:

30M DAU, each opens the app ~5 sessions/day, deck of ~25 cards per refill,
   ~2 refills/session
Deck fetches ≈ 30M * 5 * 2 = 300M deck fetches/day ≈ 3,500/sec average
But a deck is served from a PRE-COMPUTED cache, so a "fetch" is a cache read,
   not a live geo+ranking query. The expensive ranking runs OFF the read path.

Storage:

Swipes are the bulk. We keep them (for dedup - never show the same profile twice - and for the likes-you feature):

Per swipe row:
  swiper_id   8 B
  swipee_id   8 B
  direction   1 B
  ts          8 B
  ≈ 25 B, round to ~50 B with keys/index overhead
2B swipes/day * 50 B = 100 GB/day ≈ 36 TB/year of swipe data.

That is large and grows forever, so swipes go in a horizontally-sharded NoSQL store, not one Postgres box. Left-swipes can be aged out or downsampled (you rarely need old left-swipes beyond dedup, which a Bloom filter can help with); right-swipes are precious and kept.

Matches: ~27M/day * ~200 B ≈ 5.4 GB/day ≈ 2 TB/year.  Modest.
Messages: assume ~400M messages/day * ~300 B ≈ 120 GB/day ≈ 44 TB/year.  Sharded chat store.
Profiles: 75M users * ~5 KB (metadata + photo pointers) ≈ 375 GB.  Small; cache hot in RAM.

Bandwidth:

Deck serving is photo-heavy but photos come from a CDN, not our app servers.
App-server ingress = swipe writes: 70k/sec * ~200 B (payload+framing) ≈ 14 MB/sec. Trivial.
The cost is not bytes on the wire; it is WRITE OPS and the match-check reads.

Cache:

Pre-computed decks: keep ~50 candidates per active user cached.
30M DAU * 50 candidate IDs * ~16 B ≈ 24 GB of deck cache. Fits in a Redis cluster.
"Who liked me" pending-likes per user: also cached for instant match checks.

The takeaways: a 70k writes/sec swipe firehose of tiny rows that must not each cost a transaction, 36 TB/year of swipe history that forces a sharded NoSQL store, a match-detection read on every right-swipe but a strongly-consistent match write on only ~3% of them, and a deck that must be pre-computed off the read path so opening the app is a cache hit. The write path and the match-exactly-once guarantee, not storage, are the design drivers.

High-Level Design (HLD)

The architecture separates three planes: the deck plane (an offline/near-line pipeline that pre-computes each user’s ranked candidate stack from geo + preferences + ranking), the swipe plane (a high-write path that records swipes and detects mutual matches), and the chat plane (messaging over matches). The gesture the user makes is cheap; the intelligence is pushed off the hot path into the deck pipeline.

   ┌──────────────┐     open app / swipe / chat     ┌──────────────┐
   │  Client App   │ ------------------------------> │  API Gateway  │
   │ (deck, swipe, │ <------ match! notification --- │  (LB, auth)   │
   │   chat)       │                                 └──────┬───────┘
   └──────────────┘                                         │
              ┌──────────────────────┬─────────────────────┼───────────────┐
              │                      │                      │               │
      ┌───────▼────────┐    ┌────────▼────────┐    ┌────────▼───────┐  ┌────▼─────┐
      │  Deck Service   │    │  Swipe Service   │    │ Match Service  │  │  Chat    │
      │ (serve cached   │    │ (record swipe,   │    │ (create match, │  │ Service  │
      │  candidate deck)│    │  fire match-check│    │  idempotent,   │  │ (msgs    │
      └───────┬────────┘    │  event)          │    │  notify both)  │  │  over    │
              │             └────────┬─────────┘    └───┬───────┬────┘  │ matches) │
     ┌────────▼─────────┐            │ swipe event        │       │      └────┬─────┘
     │  Deck Cache       │   ┌────────▼─────────┐   ┌──────▼──┐ ┌──▼──────┐   │
     │  (Redis: user ->  │   │  Swipe Store      │   │ Likes    │ │ Match   │   │
     │   [candidateIDs]) │   │  (sharded NoSQL,  │   │ Index    │ │ Store   │   │
     └────────▲─────────┘   │   by swiper_id)   │   │(who liked│ │(sharded)│   │
              │             └────────┬─────────┘   │  whom)   │ └─────────┘   │
     ┌────────┴──────────┐          │ CDC stream    └──────────┘         ┌─────▼──────┐
     │  Deck Pipeline     │  ┌───────▼────────┐                          │ Message    │
     │ (geo shortlist ->  │  │  Kafka          │  ---> Notification/     │ Store      │
     │  filter -> rank -> │  │ (swipes, matches│       Push Service      │ (sharded   │
     │  write deck cache) │  └────────────────┘                          │ by match)  │
     └────────▲──────────┘
              │
     ┌────────┴──────────┐   ┌──────────────┐   ┌───────────────┐
     │ Geo Index (S2/     │   │ Profile Store │   │ Ranking Model  │
     │ geohash of users)  │   │ (users, prefs)│   │ (scores/embeds)│
     └───────────────────┘   └──────────────┘   └───────────────┘

Deck-building flow (off the hot path):

  1. The Deck Pipeline runs per active user (triggered when a deck runs low, or on a schedule). For user U it queries the Geo Index for users within U’s max-distance radius, filters to U’s preferences (gender, age) and to users whose preferences U satisfies (bidirectional filter), removes anyone U already swiped, then asks the Ranking layer to score and order the survivors.
  2. It writes the top ~50 candidate IDs to the Deck Cache (deck:{U} -> [candidate ids...]).
  3. When U opens the app, the Deck Service just pops IDs off deck:{U}, hydrates the profiles from the (cached) Profile Store, and returns cards. A read, not a computation.

Swipe + match flow (the hot path):

  1. U swipes on V. The client calls the Swipe Service, which writes the swipe to the Swipe Store (sharded by swiper_id) - a fire-and-forget tiny write - and, if it is a right-swipe, checks the Likes Index: “did V already like U?”
  2. If no prior like from V: record U’s like (so when V eventually sees U, the check will hit) and return. No match yet.
  3. If V already liked U: this is a mutual match. Hand off to the Match Service, which idempotently creates one match (dedup key on the unordered pair {U,V}), writes it to the Match Store, and pushes a real-time “It’s a Match!” to both U and V via the Notification/Push service.
  4. All swipes and matches also flow onto Kafka for the deck pipeline (feed the ranking/features), analytics, and the “who liked me” index.

Chat flow: once a match exists, either user opens the conversation. The Chat Service verifies the match, then messages go through it to the Message Store (sharded by match/conversation id) and are pushed to the other party over their live connection. No match, no channel - chat is gated on the match record.

The key insight: the swipe is a cheap append; the deck is pre-computed; the only strong operation is creating the match, and it is rare. You keep the expensive ranking and geo work entirely off the read path, and you pay for strong consistency exactly once per mutual like.

Component Deep Dive

The hard parts, naive-first then evolved: (1) recording 2B swipes/day, (2) detecting mutual matches without races or double-matches, (3) building the recommendation deck from location + preferences, (4) chat gated on matches.

1. Recording 2 billion swipes a day

Naive approach: one row insert per swipe in a relational DB. INSERT INTO swipes (swiper, swipee, dir, ts) on every gesture, with a unique index on (swiper, swipee).

Where it breaks:

  • Write throughput. 23k-70k inserts/sec against a single relational primary is hopeless once the table is billions of rows deep - each insert is a row write, a unique-index maintenance, a WAL append, and replication. The unique index in particular becomes a B-tree hot spot.
  • Unbounded growth. 36 TB/year in one table means the index no longer fits in RAM, and every insert starts paying random-IO to maintain a giant secondary index. Performance degrades as the table grows, which is the worst kind of scaling.
  • Read-check contention. Every right-swipe also reads (“did they like me?”), so reads and writes fight on the same hot table.

First evolution: shard the relational store by swiper_id. Now writes spread across N shards and each user’s swipe history is co-located. Much better for the write and for “have I swiped this person” dedup. But relational per-row inserts are still heavier than we need for 70k/sec of tiny appends, cross-shard operational cost is high, and the match-check (which needs V’s view: “did V like U?”) lives on a different shard than U’s write. We have split the two halves of a match across shards.

The answer: a horizontally-sharded wide-column / KV store for swipes, plus a dedicated Likes Index keyed for the match-check direction, and batched/async persistence.

  • Swipe Store = sharded NoSQL (Cassandra/DynamoDB-style), partition key = swiper_id. A swipe is an append keyed by the swiper; “have I already swiped V” is a point read within U’s partition. This store is built for high write throughput and linear horizontal scaling, exactly the shape of a swipe firehose. Left-swipes can carry a TTL or be compacted; right-swipes are kept.
  • Dedup with a per-user Bloom filter in front. Before serving a card we must never show someone U already swiped. Checking the swipe store on every deck build is fine, but at serve time a per-user Bloom filter of “already-swiped IDs” gives an O(1) “definitely not seen / maybe seen” check with a tiny footprint, catching the common case cheaply and only falling back to the store on a maybe.
  • The Likes Index is the clever part. To answer “did V like U?” fast, maintain a second structure keyed so the recipient can be looked up: likes_received:{V} -> set of users who right-swiped V (or a KV entry like:{V}:{U}). When U right-swipes V, we (a) append U’s swipe to U’s partition, and (b) write like:{V}:{U} into the Likes Index and check whether like:{U}:{V} already exists. The Likes Index is the join table that lets the match-check be a single point read regardless of sharding.
  • Buffer and batch. The client can even fire-and-forget; the Swipe Service acks immediately and writes through a short in-memory buffer / Kafka so a burst never blocks the user. Durability comes from Kafka + the store, not from a synchronous DB commit on the gesture.
swipe(U, V, dir):
    ack_client_immediately()                     # sub-100ms UX
    append to Swipe Store partition(swiper=U)     # dedup history
    bloom[U].add(V)                               # fast "already swiped" filter
    emit swipe event -> Kafka                      # deck pipeline / analytics
    if dir == RIGHT:
        write like:{V}:{U}                         # so V's future swipe finds it
        if exists(like:{U}:{V}):                   # V already liked U?
            match_service.create_match(U, V)       # -> strong, idempotent (see #2)

We traded a synchronous unique-index insert for an append plus a KV check, spread across stores built for that write rate. The unique-match invariant no longer lives in a fragile global index; it lives in the Match Service (next).

2. Mutual-match detection without races or double-matches

Naive approach: on a right-swipe, read the other person’s like; if present, insert a match. SELECT like WHERE from=V AND to=U; if found: INSERT match(U,V).

Where it breaks:

  • The simultaneous-swipe race. U and V swipe right on each other at almost the same instant. Both requests run the check; neither sees the other’s like yet (it is mid-write); both conclude “no match.” Result: a mutual like that never becomes a match - the worst possible bug, an invisible failure. Or, with different timing, both see the other’s like and both insert - two match rows for one pair, so both users get “It’s a Match!” twice and the conversation is ambiguous.
  • Ordering of the pair. (U,V) and (V,U) are the same match. If the match key is directional you get duplicates; if the read and write race you get zero.
  • Notification duplication. Even with one match row, firing the push twice (once per side) double-notifies.

The fix: an idempotent match write keyed on the unordered pair, with a compare-and-set that both swipes funnel into.

  • Canonical pair key. A match is identified by min(U,V) : max(U,V) - order-independent, so both swipes compute the same key. The match record’s primary key is this pair. Inserting a match becomes an idempotent “create if absent” on that key; a second attempt is a no-op, not a duplicate.
create_match(A, B):
    key = pair_key(A, B)          # min:max, order-independent
    # atomic: create only if not present
    created = match_store.put_if_absent(key, {users:[A,B], ts, status:active})
    if created:                    # exactly one caller wins
        notify(A, "match with B"); notify(B, "match with A")
        open_conversation(key)
    # else: already exists -> do nothing (idempotent)
  • Closing the simultaneous-swipe gap. The put-if-absent handles the double-insert side. The zero-match side (both check before either like is visible) is handled by making the like-write and match-check a single atomic step per swipe against a consistent store: when U right-swipes, we write U’s like and then check for V’s like in one strongly-consistent read-after-write on the Likes Index. Because U’s like is durably written before U’s check completes, and V’s like was written before V’s check, at least one of the two swipes (whichever commits its like second) will observe the other’s like and trigger create_match. The put-if-absent then guarantees the other racing trigger, if any, is a no-op. Net: exactly one match, even under simultaneous swipes.
  • Where strong consistency lives. Only the Likes Index for the pair and the match record need strong consistency (a conditional write / single-partition transaction, e.g. DynamoDB conditional put or a Cassandra LWT, or a small Redis SET NX on the pair key as the match lock). Everything else - deck, swipe history, scores - stays eventually consistent. Match writes are ~300/sec, so the cost of strong consistency here is negligible.
  • Idempotent notifications. The “It’s a Match!” push carries the match key; the client dedupes on it, and the notifier only fires on the created == true branch, so even retries do not double-notify.

The elegant part: you never need a global unique index or a distributed transaction across shards. The unordered pair key collapses both directions to one row, and put_if_absent on it makes “create the match exactly once” a single-key atomic operation.

3. Building the recommendation deck (location + preferences)

Naive approach: compute the deck live on app open. When U opens the app, query all users, filter by distance and preferences, rank, return the top 25.

Where it breaks:

  • O(N) live geo + filter on the read path. Scanning tens of millions of users, computing distance to each, and filtering per request at 3,500 deck-fetches/sec is absurd, and it puts the most expensive computation directly in the latency-critical open-app path.
  • Bidirectional filtering is a join. U must fit V’s preferences and V must fit U’s - you cannot pre-filter one side. Done live, it is a huge cross-filter every open.
  • Ranking needs model scores. A good deck is ranked (compatibility, desirability, recency, distance), and running a model over all candidates per request live is impossible at this rate.

First evolution: a geo index so distance is a bounded lookup. Bucket users by geohash / S2 cell (like the Uber location index), so “users within R km of U” is “U’s cell plus neighbouring cells,” not a full scan. This kills the O(N) distance problem. But we still filter and rank live per request, and the bidirectional preference join and model scoring are still on the hot path.

The answer: pre-compute a ranked deck per active user off the read path, refreshed as it drains, served as a cache pop.

  • Geo shortlist. Users are indexed in a geospatial index (S2 cells / geohash), updated when a user’s location changes (which is infrequent - people do not move like Uber cars, so this index is far less write-heavy than Uber’s). For U, retrieve users in U’s cell and the ring of neighbour cells out to U’s max distance. This yields a bounded candidate pool (a few thousand at most, capped).
  • Bidirectional preference filter. From that pool keep only V where V.gender in U.interested_in, V.age in U.age_range, dist(U,V) <= U.max_dist and the symmetric conditions for U from V’s side. Both users’ preference fields are small and cached, so this is a cheap in-memory filter over the shortlist, not a DB join.
  • Remove already-swiped. Subtract U’s swipe history (via the Bloom filter + Swipe Store), so U never re-sees a passed or liked profile.
  • Rank. Score the survivors with the Ranking layer: an ML model producing a compatibility/desirability score (the classic “who is likely to right-swipe whom” plus an attractiveness/Elo-style signal), blended with recency (active recently), distance, and product boosts (a user who bought a Boost is temporarily reranked up in others’ decks). Take the top ~50.
  • Write the deck to cache. deck:{U} -> [ranked candidate ids]. Serving is now a cache pop + profile hydration.
  • Refill trigger. When U’s deck drops below a threshold (say 5 left), enqueue a refresh job. Also refresh on a preference change, a big location change, or a TTL (decks go stale - candidates get consumed by others, go offline, or change location) so an active user’s deck is always warm.
build_deck(U):
    pool  = geo_index.nearby(U.loc, U.max_dist)          # bounded by cells
    cand  = [V for V in pool
             if fits(V, U.prefs) and fits(U, V.prefs)     # bidirectional
             and not swiped(U, V)]                         # bloom + store
    scored = ranking.score(U, cand)                        # model + recency + boost
    deck_cache.set("deck:"+U, top_k(scored, 50))
  • Handling churn and fairness. A candidate V may be shown to many people’s decks at once; that is fine (many can like V). But we also want to avoid starving newcomers and avoid always showing the same “top” profiles. The ranker injects exploration (occasionally surface new/under-shown profiles) and caps how often a single profile dominates decks, so the marketplace stays liquid.

The deck plane is the read-heavy intelligence of the product, and the trick is that none of it runs on app open. Open is a cache read; the geo query, bidirectional filter, and model scoring all run asynchronously in the refill job.

4. Chat, gated on the match

Naive approach: a generic messaging table anyone can write to, filtered by “are they matched?” at read time. INSERT message(from, to, text) then when rendering, check the match exists.

Where it breaks:

  • Authorization at the wrong layer. If anyone can insert a message row addressed to anyone, a bug or a malicious client can message a non-match. The gate must be at write time, structurally - no match, no channel.
  • No conversation locality. Messages keyed by (from,to) scatter a conversation across the store; loading a thread is a cross-key scan.
  • Fan-out is point-to-point but ordering matters. Within a conversation, messages must be ordered; across the store they need not be.

The fix: chat keyed on the match id, written only if the match exists, ordered per conversation.

  • The match id is the conversation id. When a match is created (#2), a conversation is opened with the same pair key. Every message belongs to conversation:{pair_key}. The Chat Service, on every send, verifies the match record exists and is active (not unmatched/blocked) - a single point read - then appends.
  • Message store sharded by conversation. Message Store partitioned by match_id, clustered by a per-conversation monotonic sequence (or timestamp + tiebreak) so a thread is one contiguous partition, read in order, appended cheaply. This co-locates a conversation and makes “load last 50 messages” a single-partition range read.
  • Delivery. The recipient, if online, gets the message pushed over their live connection (WebSocket) via the same Notification/Push path the match alert used; if offline, a push notification and the message waits in the store for next open. This is point-to-point (one match = two participants), so it scales with connection servers, not broadcast fan-out.
  • Unmatch/block closes the channel. Unmatching flips the match status to closed; the Chat Service refuses further writes and the conversation disappears from both inboxes. Blocking additionally adds the pair to a block-list consulted by the deck pipeline so they never resurface.
send_message(sender, match_id, text):
    m = match_store.get(match_id)
    assert m and m.status == active and sender in m.users   # gate at write
    seq = next_seq(match_id)                                 # per-conv order
    message_store.append(match_id, {sender, text, seq, ts})
    push_to(other_user(m, sender), message)                 # if online

The point: the match record is the authorization boundary. Chat is not a general messaging system with a filter; it is a channel that only exists because a match exists, keyed by the match, and it dies when the match does.

API Design & Data Schema

Client-facing REST/gRPC for deck, swipe, profile; a persistent connection for real-time match and chat push.

REST / gRPC (client-facing)

GET  /api/v1/deck?limit=25
  -> { cards: [ { user_id, name, age, distance_km, photos:[cdn_urls],
                  bio, common_interests:[...] }, ... ] }     # served from deck cache

POST /api/v1/swipe
  body: { swipee_id: "u_882", direction: "right" | "left" | "super" }
  -> 200 { recorded: true, matched: false }
     // or on a mutual like:
  -> 200 { recorded: true, matched: true, match_id: "u_12:u_882" }

GET  /api/v1/matches
  -> { matches: [ { match_id, user:{id,name,photo}, last_message,
                    unread: 2, matched_at }, ... ] }

GET  /api/v1/matches/{match_id}/messages?before=<seq>&limit=50
  -> { messages: [ { sender_id, text, seq, ts }, ... ] }

POST /api/v1/matches/{match_id}/messages
  body: { text: "hey", client_msg_id: "c_9" }        # client_msg_id = idempotency
  -> 200 { seq: 1043, ts: ... }

POST /api/v1/matches/{match_id}/unmatch   -> { status: "closed" }
POST /api/v1/users/{id}/block             -> { blocked: true }

PUT  /api/v1/profile/preferences
  body: { interested_in:["female"], age_min:24, age_max:32, max_distance_km:30 }
  -> { updated: true }        # triggers a deck refresh for this user

Real-time channel (server -> client push)

// persistent WebSocket, server-initiated frames
{ "type": "MATCH",   "match_id": "u_12:u_882", "user": {...} }        // It's a Match!
{ "type": "MESSAGE", "match_id": "u_12:u_882", "sender_id": "u_882",
  "text": "hey", "seq": 1043, "ts": ... }
{ "type": "TYPING",  "match_id": "u_12:u_882" }                        // ephemeral

Data stores - the right tool per plane

1. Swipe Store - sharded wide-column / KV (Cassandra / DynamoDB), partition by swiper_id. Built for the write firehose and for point-read dedup within a user’s partition.

Table: swipes                       (partition = swiper_id)
  swiper_id   BIGINT   PARTITION KEY
  swipee_id   BIGINT   CLUSTERING KEY
  direction   TINYINT  (0 left, 1 right, 2 super)
  ts          TIMESTAMP
  Primary key: (swiper_id, swipee_id)     -- one opinion per pair, upsert-safe
  Left-swipes: TTL / compacted; right-swipes retained.

Table: likes_index                  (the match-check join, strongly consistent)
  swipee_id   BIGINT   PARTITION KEY      -- who RECEIVED the like
  swiper_id   BIGINT   CLUSTERING KEY      -- who SENT it
  ts          TIMESTAMP
  Primary key: (swipee_id, swiper_id)     -- "did V (swipee) get a like from U?"
  Also powers "who liked me" (a paid feature): scan likes_index[V].

2. Match Store - relational or strongly-consistent KV, key = ordered pair. Matches are few (~27M/day), relational-shaped (state machine: active/closed/blocked), and require the exactly-once/idempotent guarantee. A store with conditional writes (DynamoDB conditional put, Cassandra LWT, or sharded Postgres) is ideal.

Table: matches
  match_id     STRING   PRIMARY KEY     -- "min_id:max_id", order-independent
  user_a       BIGINT                   -- = min(u,v)
  user_b       BIGINT                   -- = max(u,v)
  status       ENUM(active, closed, blocked)
  matched_at   TIMESTAMP
  last_msg_at  TIMESTAMP
  Indexes: (user_a, last_msg_at), (user_b, last_msg_at)   -- each user's match list
  Write: put_if_absent(match_id, ...)   -- idempotent create, exactly once

3. Message Store - sharded NoSQL, partition by match_id, clustered by seq. Conversation-local, append-heavy, ordered per thread. Sharding by conversation keeps a thread contiguous.

Table: messages                     (partition = match_id)
  match_id   STRING   PARTITION KEY
  seq        BIGINT   CLUSTERING KEY  (per-conversation monotonic)
  sender_id  BIGINT
  text       TEXT     (or media_ref pointer)
  ts         TIMESTAMP
  Primary key: (match_id, seq)

4. Profile Store - relational, heavily cached. Slow-changing user data (name, age, gender, bio, photo pointers, preferences, location). 75M rows, ~375 GB; hot profiles cached in Redis, photos on a CDN. Location and preference fields are the ones the deck pipeline reads.

Table: users
  user_id      BIGINT PRIMARY KEY
  name, age, gender, bio
  interested_in, age_min, age_max, max_distance_km     -- preferences
  geo_cell     STRING INDEX          -- S2/geohash cell for the geo index
  lat, lng     DOUBLE
  last_active  TIMESTAMP             -- recency signal for ranking
  photo_refs   ARRAY<STRING>         -- CDN keys

5. Geo Index - S2/geohash buckets (in-memory or Redis GEO), keyed by cell. cell -> set<user_id>, updated on location change. Read by the deck pipeline for the nearby shortlist. Far lighter write load than Uber’s because users move rarely.

6. Deck Cache - Redis. deck:{user_id} -> [ranked candidate ids], refilled by the pipeline, popped by the Deck Service. ~24 GB.

Why this mix: swipes and messages are enormous-write, append-only, and query by a single partition key - NoSQL wide-column is exactly right (linear write scaling, no giant global index). Matches are few, transactional, and need exactly-once - a strongly-consistent conditional-write store is exactly right. Profiles are slow-changing reference data with rich queries - relational + cache. Matching the store to the plane is the whole schema decision. Putting 2B swipes/day in Postgres, or matches in an eventually-consistent bucket, would each be exactly wrong.

Bottlenecks & Scaling

Where it breaks first, in order, and the fix for each:

1. The swipe write firehose (breaks first). 70k tiny writes/sec against a giant table with a unique index will collapse. Fix: sharded wide-column store partitioned by swiper_id, appends not transactional inserts, buffered through Kafka so a burst never blocks the client. Left-swipes TTL’d/compacted to bound growth; right-swipes retained. The write scales linearly by adding shards.

2. The match-check read on every right-swipe. ~10k/sec “did they like me?” lookups, and the two halves of a pair live on different swiper shards. Fix: the Likes Index keyed by swipee_id turns the check into a single point read on the recipient’s partition regardless of who swiped. The match-check does not scan; it is one keyed get.

3. The simultaneous-swipe race (the correctness bottleneck). Both like at once -> zero matches or two. Fix: order-independent pair key + put_if_absent makes match creation idempotent and exactly-once; writing the like before checking (read-after-write on the Likes Index) guarantees at least one side observes the other. Rare (~300/sec) so the strong-consistency cost is negligible.

4. Live deck computation on app open. A geo scan + bidirectional filter + model scoring per open is impossible at 3,500 fetches/sec. Fix: pre-compute decks off the read path into a Redis cache; app-open is a cache pop. The expensive work runs in async refill jobs triggered by deck-low, preference change, or TTL.

5. Geo hot spots - dense cities. A downtown S2 cell holds far more users than a rural one, so a cell lookup can return tens of thousands. Fix: adaptive cell precision (finer cells in dense areas), cap the shortlist size, and let the ranker downsample within a hot cell. Because most people rarely change location, the geo index write load is light; the read shortlist is what needs capping.

6. Popular-profile skew. A small number of highly-desirable profiles appear in millions of decks and receive a flood of right-swipes, hot-spotting their likes_index partition and their profile reads. Fix: cache hot profiles aggressively, and rate-limit / cap how often one profile saturates decks (ranker exploration + fairness caps keep the marketplace liquid and spread load). “Who liked me” reads for a popular user are served from cache with pagination.

7. Chat fan-out and thread loading. Hundreds of millions of messages/day, threads read on every open. Fix: shard messages by match_id, cluster by seq so a thread is one contiguous partition read; deliver online recipients via their existing WebSocket (point-to-point, no broadcast). Inbox lists come from the match store’s per-user index.

8. Notification fan-out. Match and message pushes to online and offline users. Fix: a Notification/Push service consuming a Kafka topic - online users via WebSocket gateways, offline via APNs/FCM. Idempotent on match_id/seq so retries do not double-notify. This is async and off the swipe path.

9. Storage growth. 36 TB/year of swipes forever. Fix: age out left-swipes (their only long-term value is dedup, which a Bloom filter approximates), retain right-swipes (they drive matches and the likes-you feature), and archive old matches/messages of long-inactive users to cold storage.

10. Single points of failure. Fix: Deck, Swipe, Match, and Chat services are stateless behind the LB and scale horizontally. Kafka, the NoSQL stores, Redis, and the match store are all replicated. The deck cache is rebuildable from the pipeline, the swipe buffer is durable in Kafka, and the one strongly-consistent operation (match create) runs on a replicated conditional-write store with failover. No single box whose loss stops swiping.

11. Multi-region / global. Fix: users are geographically local (you match with people near you), so deploy the stack per region and route by geography - a user and their candidate pool live in the same region, and the deck/geo/swipe loop never crosses a region boundary. The rare cross-region match (traveler) is handled by routing on the pair’s home region. Analytics and the ranking model train on a globally-replicated copy.

Wrap-Up

The trade-offs that define this design:

  • Cheap append swipes over transactional inserts. We record 2B swipes/day as fire-and-forget appends in a sharded wide-column store, buffered through Kafka, rather than paying a unique-index transaction per gesture - trading a heavy synchronous write for a light append plus a keyed match-check, which is the only shape that survives 70k writes/sec.
  • Pre-computed decks over live ranking. The geo shortlist, bidirectional preference filter, and model scoring all run asynchronously into a Redis deck cache, so opening the app is a cache pop, not the most expensive computation in the system on the latency-critical path.
  • Eventual consistency everywhere except the match. The deck, swipe history, presence, and scores are best-effort; only the Likes Index for a pair and the match record get strong consistency. We collapse both swipe directions to one order-independent pair key and put_if_absent on it, making “create the match exactly once” a single-key atomic operation with no global unique index or distributed transaction, even under simultaneous swipes.
  • The match record as the authorization boundary for chat. Chat is not a general messaging system with a filter; it is a channel keyed by the match, writable only if the match exists and is active, and it closes when the pair unmatches or blocks.
  • Store matched to plane. Swipes and messages go in append-optimized NoSQL sharded by user/conversation; matches go in a strongly-consistent conditional-write store; profiles go in relational-plus-cache. Each plane’s opposite needs (huge-write-ephemeral vs few-write-transactional vs slow-changing-relational) drive the store choice.

One-line summary: a dating system where 2B swipes/day are cheap sharded appends with a swipee-keyed Likes Index for O(1) match checks, mutual likes create a match exactly once via an order-independent pair key and idempotent conditional write, the recommendation deck is pre-computed off the read path from a geospatial shortlist plus bidirectional preference filtering plus model ranking into a Redis cache, and chat is a match-gated, conversation-sharded channel pushed in real time.