Design a Dating App (Tinder)
A location-based dating app where users swipe right or left on candidate profiles, a match is created when two users like each other, and matched users can chat. The core challenges are generating a fast geo-filtered candidate feed, detecting mutual likes cheaply, and keeping swipe latency low at high write volume.
1Requirements
Functional
- • Users create a profile with photos, bio, age, gender, and preferences (age range, distance radius, gender).
- • Users see a stack of candidate profiles filtered by location and preferences, ordered by a recommendation score.
- • Users swipe right (like) or left (pass) on candidates; each candidate is shown at most once.
- • When two users like each other, a match is created and both are notified immediately.
- • Matched users can exchange messages in a chat thread.
- • Users can unmatch, block, and report other users.
Non-functional
- • Swipe writes must be acknowledged in under 100 ms p99; the feed must never stall waiting on writes.
- • Match detection must be exactly-once: no duplicate match rows, no missed mutual likes.
- • Candidate feed generation under 300 ms p99 including geo filtering and dedup against prior swipes.
- • Scale to 50 million daily active users generating around 2 billion swipes per day.
- • Location data is sensitive: coordinates are never exposed to clients, only rounded distances.
- • High availability for swiping (eventual consistency acceptable for the feed, not for matches).
2Back-of-envelope estimation
| Swipe write QPS | ~23K avg, ~70K peak | 2B swipes/day / 86,400 s ≈ 23K/s; 3x peak factor for evening hours. |
| Swipe storage per day | ~64 GB/day | 2B swipes x 32 bytes (two 8-byte ids, direction, timestamp) ≈ 64 GB before indexes. |
| Matches per day | ~10M | Roughly 1% of right swipes (assume 50% of swipes are right, ~1% mutual) ≈ 10M matches/day. |
| Feed reads | ~6K QPS | 50M DAU x 10 feed refills / day ≈ 500M feed builds ≈ 6K/s average. |
| Geo index size | ~4 GB in memory | 50M active users x ~80 bytes (id, geohash, age, gender, prefs) fits in one Redis cluster. |
3API design
GET /v1/feed?limit=25Returns a batch of candidate profiles for the current user, pre-filtered by geo radius, preferences, and prior swipes.
POST /v1/swipes {targetUserId, direction}Records a like or pass. Response includes matched: true when the swipe completes a mutual like.
GET /v1/matches?cursor=...Lists the current user's matches with the other user's profile summary and last message preview.
POST /v1/matches/{matchId}/messages {text}Sends a chat message within a match; delivered over a WebSocket or push notification to the peer.
DELETE /v1/matches/{matchId}Unmatches; hides the conversation for both sides and prevents further messages.
4High-level design
Clients talk to an API gateway that fronts three main services: a Feed service, a Swipe service, and a Match/Chat service. User profiles live in a Postgres cluster sharded by user id, with a read-through cache for hot profiles.
The Feed service answers the question: which nearby, preference-compatible users has this person not yet swiped on. It queries a geo index (Redis with geohash-bucketed sets, or PostGIS for the MVP) to get candidates within the radius, filters by age and gender preferences, then removes already-swiped ids using a per-user Bloom filter plus an exact check on the swipes table for the survivors. Surviving candidates are ranked by a score (recency of activity, profile completeness, ELO-style desirability) and returned in batches of 25.
The Swipe service is write-optimized. Each swipe is appended to a swipes table sharded by swiper id and, when the direction is a like, the service performs mutual-like detection: check whether the target has already liked the swiper. Doing this check and the match insert in one transaction on a single shard keyed by the unordered user pair guarantees exactly-once match creation even when both users like each other simultaneously.
When a match is created, an event is published to a message queue. Consumers create the chat thread, send push notifications to both users, and update each user's match list cache. Chat itself is a standard messaging subsystem: messages persisted to a table partitioned by match id, fanned out over WebSockets when both users are online.
Swiped-on ids per user grow unboundedly, so the dedup layer is tiered: a Bloom filter in Redis (fast, tiny, false positives acceptable because a false positive only hides one candidate) backed by the authoritative swipes table. Feeds are also precomputed asynchronously for active users so the read path is mostly a cache pop.
5Data model
users
user_id, name, birth_date, gender, bio, photos_json, geohash, lat, lng, pref_min_age, pref_max_age, pref_genders, pref_radius_km, last_active_atSharded by user_id. lat/lng never leave the backend; clients get rounded distance only.
swipes
swiper_id, target_id, direction, created_atPrimary key (swiper_id, target_id) makes replays idempotent. Sharded by swiper_id.
matches
match_id, user_a_id, user_b_id, created_at, unmatched_atuser_a_id < user_b_id enforced so the pair has one canonical row; unique index on (user_a_id, user_b_id).
messages
message_id, match_id, sender_id, body, created_at, read_atPartitioned by match_id; ordered by (match_id, created_at).
6Deep dives
Mutual-like detection without race conditions
The classic bug: user A and user B like each other within the same millisecond on different app servers. Each server checks for the reverse like, sees nothing (the other insert has not committed), and neither creates a match, or both do and you get duplicates.
The fix is to serialize per pair. Normalize the pair to (min_id, max_id) and route both swipes through the same database shard or the same transaction scope. Inside one transaction: insert the swipe, then query for the reverse like, then insert the match protected by a unique constraint on the normalized pair. If two transactions race, one blocks on the row lock or fails the unique constraint and treats the conflict as match already exists, which is the correct outcome.
An alternative at higher scale is a Redis-based approach: SADD the like into a set keyed by the pair and check cardinality atomically in a Lua script. Redis executes scripts single-threaded per key, so the second like always observes the first. The match event then flows to the database asynchronously.
Geo filtering and the candidate pipeline
Naive distance queries (haversine over every user) do not scale. Instead, encode each user's location as a geohash and bucket users into cells. A radius query becomes: compute the set of geohash cells covering the circle, union the user sets of those cells, then do an exact distance check on the survivors. Redis GEOADD/GEOSEARCH implements exactly this and handles 50M points comfortably in memory.
After geo filtering, the pipeline applies preference filters (age, gender, both directions: you must match their preferences too), removes prior swipes, and ranks. Filtering both directions is easy to forget and produces bad feeds: showing someone a candidate who would never see them back wastes a like.
Dense cities and sparse rural areas need different cell sizes. Use a coarse geohash precision for rural users (bigger cells, more candidates) and finer precision for cities, or expand the search ring outward until you have enough candidates. Cap candidate set size per query to bound latency.
Recommendation scoring and the ELO question
The MVP ranking is a simple weighted score: recency of activity (active users first, so likes get answered), distance (closer first), and profile completeness. This alone produces a usable product.
Tinder historically used an ELO-style desirability score: being liked by highly-liked users raises your score, and you are shown people in a similar band. This improves match rates because likes are reciprocated more often within bands. Implement it as a periodically recomputed score in a batch job, not on the hot path.
Whatever the model, keep scoring out of the synchronous feed path. Precompute ranked candidate lists into a per-user Redis list during off-peak or on a trigger (user opens app), so the feed endpoint is a cheap LRANGE plus a freshness check. Stale-but-fast beats fresh-but-slow for a swipe feed.
The already-swiped problem
A power user can accumulate hundreds of thousands of swipes. Excluding all of them from every feed query with a NOT IN over the swipes table becomes the slowest part of feed generation.
Tier the dedup. First line: a per-user Bloom filter in Redis sized for ~1M entries at 1% false positive rate (about 1.2 MB per heavy user, far less for typical users if sized dynamically). A Bloom filter false positive merely hides one candidate the user has not actually seen, which is harmless. No false negatives means you never re-show a swiped profile.
Second line: for candidates that pass the Bloom filter, no database check is needed at all, because the filter has no false negatives. The exact swipes table is only consulted when rebuilding a lost filter. Rebuilds stream the user's swipe history from the swipes shard, which is an offline, per-user operation.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Next.js + Postgres with PostGIS (Supabase free tier) + Redis (Upstash free tier) + web push for match notifications.
- 01Scaffold a Next.js app with Supabase auth; create users, swipes, matches, messages tables and enable the PostGIS extension.
- 02Build the profile screen: photo upload to Supabase storage, bio, preferences; store location as a PostGIS geography point captured from the browser geolocation API.
- 03Implement GET /api/feed: one SQL query with ST_DWithin for radius, preference filters both directions, and NOT EXISTS against swipes (fine at MVP scale).
- 04Implement POST /api/swipes as a single Postgres transaction that inserts the swipe, checks the reverse like, and inserts the match row on a unique pair constraint.
- 05Build the swipe UI: a card stack with drag gestures (framer-motion), calling the swipe endpoint optimistically.
- 06Build the matches list and a simple chat using Supabase Realtime channels per match id.
- 07Add the unique index on matches(least_id, greatest_id) and write a two-browser test: like from both sides, assert exactly one match row and both clients notified.
Mutual-like detection in one SQL transaction
sqlBEGIN;
INSERT INTO swipes (swiper_id, target_id, direction)
VALUES (:me, :them, 'like')
ON CONFLICT (swiper_id, target_id) DO NOTHING;
-- Create the match only if the reverse like exists.
-- The unique index on (user_a_id, user_b_id) makes a racing
-- duplicate insert a no-op instead of a second match.
INSERT INTO matches (user_a_id, user_b_id)
SELECT LEAST(:me, :them), GREATEST(:me, :them)
WHERE EXISTS (
SELECT 1 FROM swipes
WHERE swiper_id = :them AND target_id = :me
AND direction = 'like'
)
ON CONFLICT (user_a_id, user_b_id) DO NOTHING
RETURNING match_id;
COMMIT;
-- If RETURNING yields a row, respond matched: true.Geo-filtered candidate feed query
sqlSELECT u.user_id, u.name, u.bio, u.photos_json,
ROUND(ST_Distance(u.location, me.location) / 1000) AS km_away
FROM users u, users me
WHERE me.user_id = :me
AND u.user_id <> :me
AND ST_DWithin(u.location, me.location, me.pref_radius_km * 1000)
-- my preferences about them
AND date_part('year', age(u.birth_date)) BETWEEN me.pref_min_age AND me.pref_max_age
AND u.gender = ANY (me.pref_genders)
-- their preferences about me (both directions!)
AND date_part('year', age(me.birth_date)) BETWEEN u.pref_min_age AND u.pref_max_age
AND me.gender = ANY (u.pref_genders)
AND NOT EXISTS (
SELECT 1 FROM swipes s
WHERE s.swiper_id = :me AND s.target_id = u.user_id
)
ORDER BY u.last_active_at DESC
LIMIT 25;Atomic pair-keyed like via Redis Lua (scale-up path)
typescriptimport { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL as string);
// KEYS[1] = likes set for the normalized pair
// ARGV[1] = liker id. Returns 1 when both sides have liked.
const LUA =
"redis.call('SADD', KEYS[1], ARGV[1]) " +
"if redis.call('SCARD', KEYS[1]) >= 2 then return 1 end " +
"return 0";
export async function recordLike(me: string, them: string): Promise<boolean> {
const [a, b] = [me, them].sort();
const key = "pairlikes:" + a + ":" + b;
const matched = (await redis.eval(LUA, 1, key, me)) === 1;
if (matched) {
// enqueue durable match creation; Redis decided the race
await redis.lpush("match_events", JSON.stringify({ a, b, at: Date.now() }));
}
return matched;
}Bottlenecks & failure modes
- ⚠Swipe write throughput: 70K peak QPS of tiny writes; solved by sharding the swipes table by swiper id and batching acknowledgments, never by synchronous cross-shard writes.
- ⚠Simultaneous mutual likes racing across servers; requires pair-keyed serialization (single-shard transaction or atomic Redis script) or you get missed or duplicate matches.
- ⚠Feed dedup against huge swipe histories; NOT IN queries collapse under power users, hence Bloom filters.
- ⚠Hot geo cells: a dense city cell can contain millions of users; mitigate with finer geohash precision and candidate caps per query.
- ⚠New or returning users in sparse areas get empty feeds; needs ring expansion and relaxed filters as a fallback.
Key takeaways
- ▸Serialize match detection per user pair (normalized min/max id) so mutual likes are detected exactly once regardless of timing.
- ▸Geohash bucketing turns radius queries into set unions; exact distance is only computed on a small survivor set.
- ▸Bloom filters are the right tool for have I shown this profile before: false positives are harmless, false negatives are impossible.
- ▸Precompute ranked candidate feeds asynchronously; the read path should be a cache pop, not a live geo query plus ranking.
- ▸Filter preferences in both directions or your feed shows candidates who will never like back.