Design Tinder
Dating App Matching System Design
Overview
Open Tinder and you see a stack of profile cards. You swipe right to like or left to pass. If someone you liked has already liked you — or likes you later — you get a match, both of you are notified, and a chat opens. That is the entire product loop, and it looks trivial. The system design underneath is not.
Two things make this a real interview question. First, swipes happen at enormous write volume — billions per day across the user base, each one a durable write, most of which will never become a match. This is a write-heavy workload, and the naive relational design falls over. Second, mutual-match detection has to be exact and fast: when user A likes B, the system must immediately know whether B has already liked A, and if so create a match — without scanning a table of billions of rows.
It is labeled "Medium" because the individual pieces are approachable, but a strong answer has to get three things right at once: store swipes so they scale (shard by the swiper, make swipes idempotent), detect a mutual like on the write path (a cheap reciprocal lookup, never a scan), and generate the card stack from a huge candidate pool using geolocation plus preference filters while never re-showing a profile you already swiped.
In this walkthrough you'll scope the system, size the swipe volume, model the like store, design the API, draw the swipe and match paths, then go deep on mutual-match detection, swipe storage at write scale, and candidate generation.
Out of scope (say so): the full messaging/chat system after a match (we link to it, not design it), the ML ranking model that decides who is a good match (we treat ranking as a scoring function), payment/subscription tiers (Boost, Super Like billing), photo moderation, and fraud/bot detection. We keep the core: build a card stack, swipe like/pass, detect mutual matches, notify both users.
Functional requirements
- Get a card stack. A user requests a batch of candidate profiles to swipe on, filtered by their location, distance radius, age range, and gender preference, and excluding anyone they've already swiped.
- Swipe. A user swipes right (like) or left (pass) on a candidate. The decision is recorded durably and is safe to retry (idempotent).
- Mutual-match detection. When a like is recorded, the system checks whether the target has already liked the swiper. If so, a match is created.
- Notify both users on a match. Both participants receive a push notification and see the new match in their match list.
- List matches. A user can list their existing matches (the entry point to chat).
Out of scope (state it): the chat/messaging service itself, ML match ranking, Super Like / Boost / subscription billing, profile creation and photo upload pipeline, moderation, and account recovery.
Non-functional requirements
- Write-heavy and write-scale. Swipes are the dominant operation — billions/day. The swipe write path must be cheap, horizontally shardable, and never block on a scan.
- Mutual-match detection must be exact and O(1)-ish. Finding out whether the target already liked the swiper must be a single point lookup, not a scan or join over a billions-row table.
- Idempotent swipes. A client retry (flaky network, double-tap) must not create a duplicate like or, worse, a duplicate match. Re-swiping the same person in the same direction is a no-op.
- Low-latency card stack. Fetching the next batch of candidates should be fast (< 200 ms). Candidates should be pre-filtered and paginated, never assembled by scanning the whole user base at request time.
- No repeats. A profile the user has already swiped must not reappear in a future stack. This dedup check has to be cheap at swipe scale.
- Eventual consistency is fine for most reads. A match notification arriving a second late, or a candidate stack that is a few minutes stale, is acceptable. The match creation itself should be consistent — you must not silently drop or double-create a match.
- Availability over strong consistency for the stack. A slightly stale or imperfect card stack is better than an error screen.
Estimations
State assumptions first; the goal is to justify design choices, not to hit exact numbers.
Rounded assumptions
- Users: ~100M total, ~10M daily active (DAU).
- Swipes per active user/day: ~100 (a mix of likes and passes; passes dominate). So ~1B swipes/day ≈ ~12K swipes/sec average, with peaks (evenings) of ~50K+ swipes/sec.
- Like fraction: roughly ~30–50% of swipes are right-swipes; the rest are passes. Both are stored.
- Match rate: only a small fraction of likes become matches — a mutual like requires both sides to swipe right. Assume ~1–3% of likes turn into a match, so on the order of ~10M matches/day.
- Card-stack reads: a user pulls a new batch of ~20–50 cards perhaps a few times per session. Far fewer than swipes — call it ~1–2K stack fetches/sec, and each is cacheable/precomputable. Reads are cheap relative to writes.
- Swipe storage: 1B swipes/day × ~40 bytes/row (swiper, target, direction, timestamp) ≈ ~40 GB/day of raw swipe data, ~15 TB/year — must be sharded and TTL/archive-able.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| ~12K–50K swipes/sec, ~1B/day | Write-heavy: shard the swipe store by swiper; use a horizontally scalable KV/wide-column store, not one relational box. |
| Match requires a mutual like | On each like, do a single reciprocal point lookup (target → swiper) — never scan for mutual likes. |
| ~40 GB/day of swipe rows | TTL/archive old passes; likes are the durable, valuable data. Consider storing all swipes for dedup but treating likes specially. |
| No repeats, at swipe scale | Keep a cheap per-user "already seen" set (or Bloom filter) so candidate generation can exclude swiped profiles without a giant anti-join. |
| Stack fetch is rare and cacheable | Precompute/queue candidate batches per user; generate at query time only as a fallback. |
Caution
Do not store swipes in a single relational table and find matches with a self-join like SELECT ... WHERE a.swiper = b.target AND a.target = b.swiper. At a billions-row scale that query is fatal. Detect the mutual like on the write path with one point lookup keyed by (target, swiper).
Core entities / data model
Four things are stored: the user/profile, the swipe (the like/pass decision — the write-scale entity), the match (created when two likes reciprocate), and the per-user seen set (dedup, so candidates aren't repeated).
| Entity | Key fields |
|---|---|
User |
user_id, name, age, gender, location (lat/lng + geohash), photos[], pref_gender, pref_age_min/max, pref_max_distance_km, last_active_at |
Swipe (Like store) |
(swiper_id, target_id) composite key, direction (like/pass), created_at — the composite key makes re-swipes idempotent |
Match |
match_id, (user_a, user_b) (stored order-normalized, e.g. smaller id first), created_at, status (supports later unmatch/block without deleting the row) |
SeenSet |
user_id → set/Bloom filter of target_ids already swiped (dedup for candidate generation) |
The Swipe store is the heart of the write path. Its composite primary key (swiper_id, target_id) does double duty: it makes a swipe idempotent (re-inserting the same pair is a conditional no-op) and — because we shard by swiper_id — it lets us look up "did X swipe Y?" as a single-partition point read. That reciprocal read is exactly what mutual-match detection needs (deep dive 1).
The Match row stores its two participants in a normalized order (e.g. min(id), max(id)) so a match has a single canonical key regardless of who liked first. This gives us an idempotency guard: creating the same match twice (both sides' likes race) collapses to one row via a unique constraint on (user_a, user_b).
The SeenSet is a derived structure — it can be rebuilt from the Swipe store (all targets this user has swiped). We keep it separate and cheap (a Redis set or a per-user Bloom filter) because candidate generation consults it on every stack build to avoid re-showing profiles.
Where each lives. Swipe lives in a horizontally sharded wide-column / KV store (Cassandra/DynamoDB-style), partitioned by swiper_id. User/profile lives in a sharded relational or document store, with location indexed by geohash (or a purpose-built geo index / geo service). Match lives in a sharded relational store (matches are lower volume and benefit from the unique constraint). SeenSet lives in Redis.
Caution
Do not treat the SeenSet as the source of truth. The Swipe store is authoritative; the SeenSet is a fast dedup index rebuildable from it. If the SeenSet is lost, you rebuild it — a Bloom-filter false positive permanently hides one valid candidate per occurrence (the re-check always returns the same positive, so it is not transient). This is acceptable at a <1% FP rate given millions of nearby candidates, but it is not truly "harmless."
API design
Five operations carry the system: fetch a stack, swipe, get matches, plus the internal match/notification flow.
Get a card stack
GET /api/stack?limit=20
200 OK
Returns: { "candidates": [ { "user_id": "...", "name": "...", "age": 27, "distance_km": 3, "photos": [...] }, ... ], "next_cursor": "..." }
Returns a batch of candidate profiles for the caller, already filtered by location radius, age, and gender preference, and already excluding anyone the caller has swiped (deep dive 3). Served from a precomputed candidate queue when warm; generated at query time as a fallback. Cursor-based, not offset-based — the pool shifts as people go active/inactive.
Swipe
POST /api/swipe
Body: { "target_id": "u456", "direction": "like" } // or "pass"
Idempotency-Key: <swiper_id>:<target_id> // optional; the composite key already dedups
200 OK
Returns: { "matched": true, "match_id": "m789" } // matched=false for a normal like/pass
Records the swipe idempotently (conditional insert on (swiper_id, target_id)). If direction == like, the service performs the reciprocal lookup and, on a hit, creates the match atomically and returns matched: true. A pass returns matched: false and does no reciprocal check. A retried swipe returns the same result — no duplicate like, no duplicate match (deep dive 1 & 2).
List matches
GET /api/matches?cursor=<last_match_id>&limit=20
200 OK
Returns: { "matches": [ { "match_id": "...", "other_user": {...}, "created_at": "..." }, ... ], "next_cursor": "..." }
Returns the caller's matches, most recent first — the entry point to the chat service.
Match notification (internal)
When a match is created, the swipe service emits a match_created event to a notification queue; a notification worker delivers a push (APNS/FCM) to both participants. This is async — the swiping user gets matched: true synchronously in the response; the other (previously-swiped) user is reached via push.
Note on chat. Once matched, messaging is handled by a separate messaging service (a conversation store keyed by match_id, delivery via WebSocket/long-poll) — the same shape as a WhatsApp/chat-app design. We link the concept and stop there; designing it in full is out of scope.
High-level architecture
The system has two decoupled flows: a swipe/match write path (swipe → record → reciprocal check → maybe match → notify) and a candidate read path (build a filtered, deduped card stack). The write path is the high-volume, interesting one.
1. Swipe write path and mutual-match detection
Every swipe is a durable, idempotent write. A like additionally triggers a single reciprocal lookup that decides whether a match is born.
sequenceDiagram
participant C as A's client
participant SS as Swipe Service
participant LS as Like Store (sharded by swiper)
participant MS as Match Store
participant NQ as Notification Queue
participant B as B's client
C->>SS: POST /swipe (target=B, like)
SS->>LS: conditional PUT (A→B, like) [idempotent]
SS->>LS: point lookup: does (B→A, like) exist?
alt reciprocal like exists
LS-->>SS: yes (B already liked A)
SS->>MS: create match(A,B) [unique key, idempotent]
SS->>NQ: emit match_created(A,B)
SS-->>C: 200 { matched: true }
NQ-->>B: async push notification (match with A)
else no reciprocal like
LS-->>SS: no
SS-->>C: 200 { matched: false }
end
- The swipe is written idempotently to the Like store, sharded by
swiper_id(A's shard). - On a like, the service does one point lookup for the reciprocal like
(B → A). Because that key lives on B's shard, it's a single-partition read — not a scan. - If B already liked A, create the match (idempotent via the normalized
(min,max)unique key) and emit amatch_createdevent. - The notification worker pushes to both users. A pass skips steps 2–4 entirely.
2. Candidate read path — building the card stack
The stack is a filtered, ranked, deduped slice of the user base near the caller.
flowchart LR
Client["Client"] -->|"GET /stack"| CandSvc["Candidate Service"]
CandSvc -->|"1. nearby users\n(geohash prefix / geo query)"| GeoIdx[("Geo Index\n(geohash)")]
CandSvc -->|"2. filter age/gender/prefs"| ProfileDB[("Profile Store")]
CandSvc -->|"3. exclude already-swiped"| Seen[("SeenSet\n(Redis / Bloom)")]
CandSvc -->|"4. rank (recency,\nactivity, score)"| Rank["Ranker"]
CandSvc -->|"paginated stack"| Client
- Query the geo index for users within the caller's distance radius (geohash prefix / neighboring cells).
- Filter by age range, gender preference, and other constraints.
- Remove anyone in the caller's SeenSet (Bloom-filter check — cheap, no giant anti-join).
- Rank the survivors (activity recency, a match-likelihood score) and return a paginated batch. Warm users are served from a precomputed queue; cold users fall back to query-time generation (deep dive 3).
3. Combined architecture
flowchart LR
Client["Client"] --> GW["API Gateway"]
GW -->|"GET /stack"| CandSvc["Candidate Service"]
CandSvc --> GeoIdx[("Geo Index")]
CandSvc --> ProfileDB[("Profile Store")]
CandSvc --> Seen[("SeenSet Redis")]
GW -->|"POST /swipe"| SwipeSvc["Swipe Service"]
SwipeSvc -->|"idempotent write\n+ reciprocal lookup"| LikeStore[("Like Store\nsharded by swiper")]
SwipeSvc -->|"update dedup"| Seen
SwipeSvc -->|"create match"| MatchStore[("Match Store")]
SwipeSvc -->|"match_created"| NQ[["Notification Queue"]]
NQ --> NW["Notification Workers\n(APNS/FCM)"]
GW -->|"GET /matches"| MatchSvc["Match Service"]
MatchSvc --> MatchStore
MatchStore -.->|"chat entry point"| Chat["Messaging Service\n(separate design)"]
The write path (swipe + match) and the read path (stack) are fully decoupled. A swipe write never blocks on candidate generation; a stack fetch never blocks on the notification pipeline. Each swipe also updates the SeenSet so the next stack excludes it.
Deep dives
1. Mutual-match detection — the like store and the reciprocal check
This is the conceptual core of Tinder. A match exists between A and B if and only if A liked B and B liked A. The whole trick is to detect that on the write path with a single point lookup, never by scanning.
The like store schema. Store each like as a row keyed by the swiper:
Table: likes
Partition key: swiper_id
Sort/clustering key: target_id
Value: direction (like|pass), created_at
Primary key = (swiper_id, target_id) -> one row per (swiper, target) pair
Sharded by swiper_id
Because the partition key is swiper_id, the query "does a row (B, A) with direction = like exist?" is GET likes WHERE swiper_id = B AND target_id = A (then check direction == like) — a single-partition point read on B's shard. That is O(1)-ish, not a scan. The direction check matters: if B passed on A, the row exists but must not trigger a match.
The exact write-path algorithm when A swipes right on B:
1. Conditional PUT likes[(A, B)] = {like, now} # idempotent; re-swipe = no-op
2. reciprocal = GET likes[(B, A)] # single point lookup on B's shard
3. if reciprocal exists and reciprocal.direction == like:
create_match(A, B) # see idempotency below
emit match_created(A, B)
return matched=true
else:
return matched=false
Step 2 is the payoff: one point read decides the match. No table scan, no self-join.
Making match creation idempotent and race-safe. Two dangers: (a) A and B swipe right on each other at nearly the same instant, so both requests reach step 3 and both try to create the match; (b) A retries the swipe and re-runs step 3.
Naive: check-then-create the match without a unique key (can double-create)
If step 3 does if not exists(match) then insert(match) without a database-level uniqueness guarantee, two concurrent requests (A→B and B→A firing simultaneously) can both read "no match yet" and both insert — creating two match rows for the same pair.
- Con: duplicate matches; duplicate notifications; a corrupted match list.
- Verdict: avoid. Concurrency here is not hypothetical — simultaneous mutual right-swipes are exactly when a match is born.
Normalized match key + unique constraint (recommended)
Store the match under a canonical key (user_a, user_b) = (min(A,B), max(A,B)) with a unique constraint on that pair. Now:
- Both racing requests compute the same key and issue an idempotent upsert; the database collapses them to one row. The second insert is a conflict/no-op.
- Only the request that actually created the row emits the
match_createdevent (use the "row inserted" signal, e.g. anINSERT ... ON CONFLICT DO NOTHINGthat reports whether it inserted), so exactly one notification fan-out happens. - A retry recomputes the same key and is a no-op.
flowchart LR
A2B["A likes B\n(B→A row with direction=like exists)"] -->|"key = (min,max)"| K["match key (A,B)"]
B2A["B likes A\n(A→B row with direction=like exists)"] -->|"key = (min,max)"| K
K -->|"INSERT ON CONFLICT\nDO NOTHING"| One["Exactly one match row"]
One -->|"only the inserter"| Notify["emit match_created once"]
- Verdict: the standard fix. The normalized key turns "did these two match?" into a single deterministic lookup and makes creation idempotent under both races and retries.
Tip
The reciprocal check and the match key are two different idempotency mechanisms working together: the like store's (swiper, target) key dedups swipes; the match store's normalized (min, max) key dedups matches. You need both.
Do we store passes? A left-swipe (pass) never creates a match, so it doesn't need the reciprocal check. But we do record it — because candidate generation must not re-show a passed profile. One design stores passes in the same like store (with direction=pass); another stores only likes in the like store and pushes every swipe (like or pass) into the SeenSet for dedup. Storing passes separately or as TTL'd rows keeps the reciprocal-lookup hot set (the likes) smaller.
2. Swipe storage at write scale — sharding, idempotency, hot users, dedup
Swipes are the firehose: ~1B/day, tens of thousands/sec at peak, each a durable write. This is a classic write-heavy partitioning problem.
Shard by the swiping user. Partition the like store by swiper_id. Consequences:
- One user's swipes all land on one shard → their write stream is localized, and "what did A swipe?" is a single-partition scan (used to rebuild the SeenSet).
- The reciprocal lookup
(B → A)hits B's shard — still a single-partition point read (deep dive 1). - Load spreads evenly because swiping activity is roughly uniform across users (unlike Instagram's follower graph, there's no 400M-follower celebrity of swipes — a person can only swipe so fast). Note: popular profiles that attract many incoming likes don't create a hot swiper shard — those likes are written to each swiper's own partition and scatter across shards (see "Hot targets" below).
Use a horizontally scalable wide-column / KV store (Cassandra, DynamoDB) that natively partitions by key and handles high write throughput with tunable consistency. A single relational primary cannot absorb this write rate.
Idempotency. The composite primary key (swiper_id, target_id) guarantees at most one row per pair. A retried or double-tapped swipe is a conditional write (IF NOT EXISTS, or an upsert that preserves the first decision) — it never creates a second like and never re-triggers a match. This is why the API can be safely retried on a flaky mobile connection.
Warning
Don't let a re-swipe flip a decision and spuriously create a match. If A already passed B, a retried request must not silently become a like. Treat the first write as authoritative (or require an explicit "undo" as a separate, rate-limited operation) so idempotency holds.
Hot targets, not hot swipers. The swiper side is uniform, but popular profiles receive a disproportionate number of incoming likes (many people swipe right on the same attractive/celebrity profile). Note this does not hurt the like store: incoming likes on B are written to each swiper's partition ((A→B), (C→B), …), so they scatter across shards — there is no single hot partition for "likes received by B." The place popularity matters is candidate generation (B gets surfaced to many stacks) and any denormalized "likes received" counter, which should be buffered/aggregated rather than incremented per-like on one row.
The dedup / seen set. Candidate generation must exclude everyone the user already swiped. Options:
Anti-join the whole like store on every stack build (avoid)
For each stack request, SELECT candidates WHERE target NOT IN (SELECT target FROM likes WHERE swiper = me). At hundreds or thousands of swipes per user and billions of rows, this anti-join is expensive and gets slower as the user swipes more.
- Verdict: avoid. Dedup must be a cheap membership test, not a growing anti-join.
Per-user seen set — Redis set or Bloom filter (recommended)
Maintain a compact per-user "already seen" structure, updated on every swipe:
- Redis set of
target_ids — exact, O(1) membership, but memory grows with swipe count (fine for most users; cap/TTL the oldest for extreme swipers). - Bloom filter per user — fixed memory regardless of swipe count; a false positive permanently hides a specific never-seen profile for that user (re-checking returns the same positive every time, so it is not transient). This is acceptable at Tinder's scale — size the filter for a low false-positive rate (<1%) so the expected miss rate stays negligible, and optionally keep an exact Redis set for recent or high-value swipes while using the Bloom filter only for the long tail. There are no false negatives, so a swiped profile is never re-shown.
Candidate generation checks the SeenSet during filtering (step 3 of the read path). The set is derived from the like store, so it's rebuildable if lost.
- Verdict: the Bloom filter is the classic Tinder-scale answer — flat memory, no re-shows, and a harmless failure mode.
Archival. Passes are low-value once recorded in the SeenSet; likes are the durable, valuable data (they drive future matches). TTL or cold-archive old pass rows to keep the hot like store lean, while retaining likes.
3. Candidate generation — geo + preference filtering, ranking, freshness
The card stack is a filtered, ranked slice of the nearby user base that excludes already-swiped profiles. The dominant filter is location — Tinder is inherently local.
Geospatial filtering (cross-reference, don't reproduce). Index each user's location by geohash (or use a geo service / quadtree). To find candidates within the caller's radius, query the caller's geohash cell plus its neighbors and keep users within the true distance. A geohash prefix of length 5 covers roughly a 4.9 km × 4.9 km cell — a good default for a 5–10 km dating radius; drop to length 4 (~39 km cells) for users with a wider range preference. Always query the 8 neighboring cells to avoid boundary misses. The full geospatial index design (geohash vs quadtree vs S2, cell sizing, boundary handling) is its own topic — cross-reference a dedicated geo/proximity guide rather than re-deriving it here.
Preference filtering. From the geo candidate set, filter by age range, gender preference, max distance, and any hard constraints (e.g. "recently active"). These are cheap attribute filters on the already-small nearby set.
Dedup. Remove SeenSet members (deep dive 2) so nothing repeats.
Ranking. Order survivors by a score — recency of activity (surface people who are online now), a match-likelihood signal, profile completeness. We treat ranking as a pluggable scoring function; the ML model behind it is out of scope.
Precomputed queue vs query-time generation.
Query-time generation — build the stack fresh on every request (simple, but bursty)
On each GET /stack, run geo query → filter → dedup → rank, live.
- Pro: always fresh; no storage; naturally reflects who just came online nearby.
- Con: every stack fetch pays the full geo + filter + rank cost; latency spikes under load; repeated work for active swipers who burn through a batch quickly.
- Verdict: fine as a fallback and for cold/low-traffic users, but expensive as the only path for heavy users.
Precomputed candidate queue per active user (recommended for active users)
For active users, precompute a queue of the next N candidates (a background job or an async top-up when the queue runs low) and serve stack fetches straight from it. Refill from the geo+filter pipeline in the background.
- Pro: stack fetch is a fast queue read; the expensive geo/rank work is amortized and off the request path.
- Con: the queue can go stale — a queued candidate may go offline, change location, or get swiped by the user through another path. Mitigate with a short queue TTL, a freshness re-check at serve time (drop candidates now out of range or already in the SeenSet), and modest queue sizes so staleness windows stay small.
- Verdict: the standard approach for active users; combine with query-time generation as the cold-start / fallback path.
Freshness and pagination. The pool is not static — people go online/offline and move. Use cursor-based pagination and short-lived batches rather than a fixed offset into a giant sorted list (offsets break as the pool shifts). Always re-check the SeenSet at serve time so a profile swiped seconds ago in another session never slips through a stale queue.
Tip
The dedup check belongs in both the generation step (so queued candidates exclude past swipes) and the serve step (so a just-swiped profile from a concurrent session is dropped). Belt and suspenders — the SeenSet is cheap.
Tradeoffs & bottlenecks
- Precomputed candidate queue vs query-time generation. Precomputing makes stack fetches fast and amortizes geo/rank cost, but the queue goes stale (offline, moved, already swiped). Query-time is always fresh but pays full cost per fetch. Production: precompute for active users, query-time as fallback + serve-time freshness re-check.
- Store every swipe vs store only likes. Storing all swipes (likes + passes) gives a complete dedup source and audit trail but grows fast (~1B rows/day). Storing only likes shrinks the reciprocal-lookup hot set but forces passes into the SeenSet/Bloom filter for dedup. Common answer: likes durable in the like store; passes TTL'd/archived or represented only in the SeenSet.
- Reciprocal point lookup vs materialized match index. The write-path reciprocal lookup keeps writes simple and match detection exact. Alternatively you could maintain a separate "pending likes received" index per user — more storage and another thing to keep consistent, for little gain, since the sharded point read is already O(1)-ish.
- Exact Redis set vs Bloom filter for dedup. Exact sets never hide a valid candidate but grow with swipe count; Bloom filters have flat memory and no false negatives (never re-show) but a false positive permanently hides a specific profile for that user — it is not transient. Mitigate by sizing for a low FP rate (<1%); at Tinder scale, Bloom's flat memory usually wins because the expected miss rate stays negligible.
- Idempotency granularity. The
(swiper, target)like key and the normalized(min,max)match key both must be enforced at the storage layer (conditional writes / unique constraints), not just in application code — otherwise concurrent mutual swipes double-create. - Sharding key choice. Sharding the like store by
swiper_idkeeps a user's writes and the reciprocal read single-partition and spreads load uniformly. Sharding bytarget_idinstead would concentrate all of a popular profile's incoming likes on one partition — a hot shard. Swiper-sharding avoids that.
Extensions if asked
If the interviewer wants to go beyond the core loop:
- Undo / rewind. Let a user take back their last swipe (a paid feature). Model as an explicit, rate-limited delete/override of the last
(swiper, target)row plus a SeenSet removal — kept separate from the idempotent normal-swipe path so retries don't accidentally rewind. - Super Like. A like that also notifies the target immediately (before they swipe) and boosts your card in their stack. A
direction=superlikevariant plus a ranking boost; still flows through the same reciprocal-match logic. - Boost. Temporarily raise a user's ranking in others' candidate queues for 30 minutes — a ranking-layer multiplier, not a change to the swipe/match path.
- Messaging after match. A separate messaging service keyed by
match_id(conversation store + WebSocket delivery), essentially the WhatsApp/chat-app design. Link it; don't design it here. - ML match ranking. Replace the simple scoring function with a learned model (embeddings for users, engagement signals) to order the stack. This is a full recommendation system — name it, don't build it.
- "Likes you" screen. Surface the set of people who have already liked the caller (a paid feature). This is a per-user "incoming likes" query — a reverse index on
target_id, materialized separately because the like store is sharded by swiper.
What interviewers look for & common mistakes
What interviewers usually reward:
- Detecting the mutual match on the write path with a single reciprocal point lookup — and explicitly rejecting a scan or self-join over the swipe table. This is the make-or-break insight.
- Sharding the like store by the swiping user and explaining why: localized writes, single-partition reciprocal reads, uniform load (no swipe-side hot key).
- Idempotency in two places — the
(swiper, target)like key and the normalized(min, max)match key — and handling the simultaneous-mutual-swipe race with a unique constraint so exactly one match and one notification result. - A cheap dedup / seen set (Redis set or Bloom filter) so already-swiped profiles never reappear, instead of a growing anti-join.
- Geo-first candidate generation that cross-references a real geospatial index (geohash) rather than scanning the whole user base, plus precomputed queues with a freshness re-check.
- Naming the write-scale reality — billions of swipes/day is write-heavy; the store must scale horizontally and archive low-value passes.
- Async match notification to both users, with the swiper getting a synchronous
matched: trueand the other user reached via push.
Before you finish, do a quick mistake check:
- Did you detect the mutual like with a point lookup on the reciprocal
(target → swiper)key — never a scan or self-join? - Did you shard the like store by swiper_id and justify it (uniform load, single-partition reciprocal read)?
- Did you make swipes idempotent via the composite key, and matches idempotent via a normalized unique key (handling the concurrent mutual-swipe race)?
- Did you dedup already-swiped profiles cheaply (Redis set / Bloom filter), not with an anti-join?
- Did you generate candidates geo-first (geohash / geo service, cross-referenced) with age/gender/preference filters, and precompute queues with a serve-time freshness check?
- Did you acknowledge the write-scale of swipes and archive low-value passes?
- Did you notify both users on a match asynchronously, and point to a separate messaging service for chat rather than designing it here?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →