The whiteboard for this problem is in
excalidraw/5. Designing Tinder.excalidraw. This file is the full write up of
that board.
1. Problem Statement
Design the feed for Tinder and the ability to swipe left or right. For a good experience, a user must never be shown a profile they have already swiped on.
Two things drive the design:
- Generating a feed of nearby, relevant profiles.
- Recording swipes, and turning two matching right swipes into a match.
2. Requirements
2.1 Functional Requirements
| Requirement | Detail |
|---|---|
| Feed generation | Show profiles that fit the user's preferences |
| Proximity | Nearer profiles rank higher, filtered by a radius |
| Common interest | Shared interests rank higher |
| Preference filters | Age range, gender, distance radius |
| Swipe | Left means not interested, right means interested |
| No repeats | A swiped profile never appears again |
| Match | Two mutual right swipes create a match |
| Match notification | Both users learn about a match quickly |
2.2 Non Functional Requirements
- Feed must feel instant. The next card is already on the device before the user reaches it. Any wait kills the swiping rhythm.
- Very high write volume. Swipes are the highest volume event in the system, far more than matches or messages.
- Horizontally scalable location writes. Every device emits its position periodically, which is a constant stream of writes.
- Eventual consistency is fine for the feed. A profile appearing a minute late is not a problem. A profile appearing twice is.
- Feed generation is asynchronous. It is too slow to run inside the request that needs the next card.
2.3 Out of Scope
Chat and messaging after a match, payments and subscription tiers, photo verification, safety reporting.
3. Feed Criteria and Preference
Two signals decide who goes in the feed:
- Proximity. Nearer is better.
- Common interest. Shared interests are better.
Both need data we do not have yet, so we build the collection paths first.
4. Capturing Proximity
The user's app continuously emits latitude and longitude to the backend, and we store it.
We need a store that:
- Supports geospatial queries, which is what feed generation needs.
- Scales horizontally through sharding, because the incoming write load is large.
The stored data is simply:
key -> user_id
value -> lat, longSizing it:
user_id 32 bits = 4 B
lat, long 64 bits = 8 B
total per user = 12 B
12 B x 50M users = 600 MBSix hundred megabytes is small. The amount of data is not the concern, the query rate is. Fifty million devices writing every 30 seconds is roughly 1.7 million writes per second, and a geo query on every feed refresh on top of that. So we use Redis for in memory speed with geospatial commands, and we shard.
Sharding is by location, not by user id. A geo query asks "who is near this point", so keeping nearby users on the same shard means one shard answers the query. Sharding by user id would fan every geo query out to every shard.
5. Capturing Common Interest
Two ways to get interests:
- Ask the user to fill in profile information.
- Capture it from social login, where the user authorizes us to read profile, interests and connections.
- Auth DB is relational, because credentials, tokens and account links have a fixed structure and need transactions.
- Enrichers are workers that call the Facebook, Google and Twitter APIs. They run behind a broker because third party API calls are slow, rate limited and fail often, none of which should block a login.
- Profile DB is a document store. There is no standard structure across social platforms, so the enriched profile is naturally schemaless.
6. Feed Database
Now that we have interests and location, we can generate feeds.
6.1 When to Generate
The feed is generated when the user is about to exhaust the current one. Two ways to detect that:
| Trigger | Behaviour | Verdict |
|---|---|---|
| Frontend calls an API when the remaining card count gets low | The faster the user swipes, the sooner the call fires. Better experience | Chosen |
| Backend keeps a counter and checks on every swipe | Extra work on the hottest write path, and the refill starts later | Slower experience |
The frontend knows how many cards are left in its buffer, so let it ask.
6.2 Sizing the Problem
The feed table can explode. In the worst case it is users times users. For each user the database holds feed items, and fetching a feed item means getting profile information the user has not seen before.
6.3 Two Storage Approaches
Approach 1: store a reference
<user_id, candidate_user_id, created_at>
partitioned by user_idSmall rows. On read we still have to fetch profile information for each candidate, which is an extra call to the profile service.
Approach 2: store a copy of the profile
<user_id, candidate_profile, created_at>
partitioned by user_idNo profile call at read time. The cost is a lot of storage, since one profile is duplicated into every feed that contains it, and a real risk of serving a stale profile after the user updates their photos or bio.
What not to do in either case: never store a user's feed as a list inside a single document, for example one MongoDB document per user holding an array.
- The document bloats as the feed grows.
- Serialization and deserialization of the whole array gets expensive.
- Iterating and removing single entries is awkward.
We go with Approach 1 and DynamoDB:
hashKey: user_id
rangeKey: created_atThe hash key gives us even partitioning per user, and the range key gives us "next N unseen items in order" as a single efficient query. The extra profile fetch is solved with a profile cache, and unlike stale copies, a cache can be invalidated.
7. Feed Generator
The generator must be:
- Asynchronous, because it is time consuming.
- Triggered by the frontend, when the user's buffer is running low.
What one generator run does:
- Read the user's preferences: radius, age range, gender.
- Query the location shard for candidate user ids inside the radius.
- Drop anyone the user has already swiped on, using the seen filter.
- Score the survivors on distance, shared interests, activity and desirability.
- Write the top N as feed rows with the current timestamp.
Because it runs behind a queue, a slow geo query or a profile lookup spike never shows up as a slow request for the user. They are still swiping through cards that were written a minute ago.
8. Storing Swipes and Avoiding Repeats
The rule is: when A registers a swipe on B, A must never see B again. So while generating the feed we have to check past swipes and only add candidates that are genuinely new.
We do not create a separate database for swipes. The feed database already
has one row per (user, candidate) pair, which is exactly what a swipe is about.
We add an is_interested column to the feed item and update it in place.
Note: you do not have to create a new database for everything. Reuse the row that already represents the relationship.
For the "have they already seen this" check during generation, reading DynamoDB per candidate would be far too slow. So we keep a Bloom filter in Redis, with periodic persistence, sharded by user id. The filter is consulted before any item is added to a feed.
A Bloom filter can report a candidate as seen when they were not, which means we occasionally skip a profile that was actually fresh. In a pool of millions that is invisible. It never reports unseen for someone who was seen, which is the direction that would actually hurt.
8.1 Match Creation
When A swipes right on B:
- Mark
is_interested = trueon A's feed item for B. - Look up
(B, A)in the feed DB. - If that entry does not exist, or
is_interestedis false, do nothing. - Otherwise create a match in the match DB.
The match row is <user_a, user_b, match_id>. The match_id is what messaging
is built on, because every message belongs to a match rather than to a pair of
users.
9. API Design
9.1 Location
| Method | Path | Purpose |
|---|---|---|
| POST | /api/location | Report the current position |
{ "lat": 12.9716, "lng": 77.5946, "accuracyMeters": 20 }Returns 204 No Content. This is called every 30 seconds per device, so the
response carries nothing at all.
9.2 Feed
| Method | Path | Purpose |
|---|---|---|
| GET | /api/feed?limit=20 | Next batch of unseen cards |
| POST | /api/feed/refill | Ask for more, called when the buffer is low |
| GET | /api/preferences | Read filters |
| PUT | /api/preferences | Update radius, age range, gender |
GET /api/feed response:
{
"items": [
{
"candidateUserId": "u_88213",
"name": "Ana",
"age": 27,
"distanceKm": 3.4,
"bio": "Runner, filter coffee, bad at chess",
"photos": ["https://cdn.tinder.dev/u_88213/1.webp"],
"sharedInterests": ["running", "coffee"],
"feedItemCreatedAt": "2026-08-19T10:31:00Z"
}
],
"remaining": 42
}POST /api/feed/refill returns 202 Accepted. It only enqueues a generation
job, it does not wait for one.
9.3 Swipe and Match
| Method | Path | Purpose |
|---|---|---|
| POST | /api/swipes | Record one swipe |
| GET | /api/matches | List matches, newest first |
| DELETE | /api/matches/:matchId | Unmatch |
POST /api/swipes request:
{ "candidateUserId": "u_88213", "direction": "RIGHT", "clientEventId": "e_5512" }Response, no match yet:
{ "recorded": true, "matched": false }Response when it is mutual:
{
"recorded": true,
"matched": true,
"matchId": "m_99120",
"matchedUser": { "userId": "u_88213", "name": "Ana" }
}The clientEventId makes the call idempotent. Phones retry on flaky networks,
and a double counted swipe would corrupt both the seen filter and the match
check.
9.4 Real Time
| Direction | Event | Payload |
|---|---|---|
| Server to client | match:new | { matchId, user, matchedAt } |
| Server to client | feed:ready | { added } after a generator run |
Matches are pushed over a WebSocket so both users hear about it immediately, including the one who swiped right first and was waiting.
10. Data Model
Five stores, each chosen for one access pattern.
10.1 Auth and Preferences, Relational
users
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
phone | text | Unique |
email | text | Unique, nullable |
date_of_birth | date | Age is derived, never stored |
gender | enum | |
created_at | timestamptz | |
last_active_at | timestamptz | Feeds ranking |
is_active | boolean |
social_accounts
| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
user_id | uuid | Foreign key to users.id |
provider | enum | FACEBOOK, GOOGLE, TWITTER |
provider_user_id | text | Unique with provider |
access_token | text | Encrypted at rest |
scopes | text | What the user authorized |
last_enriched_at | timestamptz | When the enricher last ran |
user_preferences
| Column | Type | Notes |
|---|---|---|
user_id | uuid | Primary key, foreign key to users.id |
min_age | integer | |
max_age | integer | |
interested_in | enum | |
radius_km | integer | |
show_me | boolean | Pause being shown to others |
10.2 Profile, Document Store
profiles (MongoDB or DynamoDB)
| Field | Type | Notes |
|---|---|---|
_id | uuid | Same value as users.id |
displayName | string | |
bio | string | |
photos | array | CDN urls with order and moderation status |
interests | array | Normalized interest tags |
job, school | string | Often from social enrichment |
enrichedSources | object | Raw payload per provider, shape varies |
eloScore | number | Desirability signal used in ranking |
updatedAt | date |
Schemaless is the right call here, because no two social providers return the same shape and the profile keeps gaining fields.
10.3 Location, Redis
Not a table. Per geo shard:
GEOADD loc:{cell} <lng> <lat> <user_id>
GEOSEARCH loc:{cell} FROMLONLAT <lng> <lat> BYRADIUS 10 km ASC COUNT 500Keyed by geo cell, so a radius query touches one or a few adjacent shards. Every entry has a TTL, because a position from three days ago is not a position.
10.4 Feed and Swipes, DynamoDB
feed_items
| Attribute | Type | Notes |
|---|---|---|
user_id | string | Hash key, the viewer |
created_at | number | Range key, orders the deck |
candidate_user_id | string | Who is shown |
score | number | Ranking score at generation time |
distance_km | number | Snapshot at generation time |
is_interested | boolean | Null until swiped, true is right, false is left |
swiped_at | number | Null until swiped |
One table serves three jobs: the deck to show, the swipe record, and the history that prevents repeats. That is the point from the board about not creating a new database for everything.
A secondary index on (candidate_user_id, user_id) is what makes the reverse
lookup for match detection a single fast query instead of a scan.
10.5 Matches, Document Store
matches (MongoDB)
| Field | Type | Notes |
|---|---|---|
_id | uuid | This is the match_id messaging uses |
userA | uuid | Lower user id of the pair, for a stable unique key |
userB | uuid | Higher user id of the pair |
matchedAt | date | |
status | enum | ACTIVE, UNMATCHED, BLOCKED |
lastMessageAt | date | Sorts the match list |
Storing the pair in a fixed order with a unique index on (userA, userB) is
what stops a race where both users swipe right at the same moment from creating
two match rows.
10.6 Relationships
In words:
- One user has exactly one preferences row and one profile document.
- One user links many social accounts, one account belongs to one user.
- One user has many feed items as the viewer, and appears as the candidate in many other users' feed items. This double relationship is why the reverse index exists.
- One user participates in many matches, and one match involves exactly two users.
- A match exists only when two feed items,
(A, B)and(B, A), both haveis_interested = true.
11. Key Flows
11.1 Feed Generation
11.2 Swipe Producing a Match
If the reverse item does not exist or is false, the flow ends after the update
and the filter write. The other user's swipe, whenever it happens, will find A's
true and create the match then.
12. Deep Dives
12.1 Why Sharding by Location, Not User Id
The dominant read is "who is near this point". Sharding by location means one shard answers it. Sharding by user id would mean every geo query fans out to every shard and merges results, which multiplies both latency and load by the shard count.
The cost is uneven shards, because a city centre holds far more users than a rural cell. Geohash cells of variable precision handle this: split a hot cell into finer cells and spread those across nodes.
12.2 Why Feeds Are Precomputed
Computing a feed on request means a geo query, a seen check for hundreds of candidates, profile fetches and scoring, all inside a request that must return in tens of milliseconds. It does not fit.
Precomputing moves all of that off the request path. The read becomes a single DynamoDB query on a hash key. The tradeoff is storage, since we hold a deck per user, and mild staleness, since distances were computed at generation time. Both are acceptable, and a distance that is a few minutes old is not something a user can detect.
12.3 Bloom Filter Sizing
For 50 million users with an average of a few thousand swipes each, exact storage of every seen pair gets large and every check is a database read. A Bloom filter with a one percent false positive rate needs roughly 10 bits per entry, so a user with 5000 swipes costs about 6 KB. Even a heavy user is cheap, and every check is one in memory operation.
We keep the authoritative record in feed_items anyway, so the filter is a fast
path and not a source of truth. It can be rebuilt from the feed table if a Redis
node is lost.
12.4 Ranking
Once candidates are filtered, scoring decides the order:
| Signal | Effect |
|---|---|
| Distance | Closer is better, with sharp falloff past the radius |
| Shared interests | Overlap count from the profile tags |
| Activity | A user active today beats a user last seen in March |
| Desirability, or Elo | Balances exposure so popular profiles do not take every slot |
| Reciprocity | Candidates whose own preferences match this user rank higher |
Ranking sits in the generator, so changing the formula never touches the read path.
12.5 Handling the Empty Feed
In a low density area a radius query can come back nearly empty. The generator falls back in steps: widen the radius, relax the age range by a year or two, allow slightly older location entries, and only then return a small deck with a message the app can show. Silently returning nothing looks like a bug to the user.
13. Failure Handling
| Failure | Effect | Handling |
|---|---|---|
| Location shard down | Cannot find nearby candidates | Serve the existing deck, retry generation. A stale deck is better than no cards |
| Bloom filter Redis down | Cannot check seen status fast | Fall back to reading feed_items for the candidate batch, slower but correct. Rebuild the filter from the feed table |
| Feed generator backlog | Deck runs dry | Autoscale on queue depth, and refill early enough that the buffer absorbs a delay |
| Profile DB slow | Generation stalls | Generate with reference rows only, and let the read path fill profiles from cache |
| Duplicate swipe from a retry | Double counted | clientEventId idempotency, and the feed item update is naturally idempotent |
| Simultaneous mutual right swipes | Two match rows | Unique index on the ordered (userA, userB) pair, the loser of the race reads the existing match |
| Match push fails | User does not see the match | The match row is already written, so the match list shows it on next open. Push is an optimization |
| Stale location | Someone shown at the wrong distance | TTL on location entries, and drop candidates whose position is older than a threshold |
14. Tradeoffs
| Decision | Chosen | Alternative | Reason |
|---|---|---|---|
| Location store | Redis, geo sharded | Relational with a spatial index | Write rate is enormous and the data is tiny |
| Location shard key | Geo cell | User id | Radius queries must hit one shard |
| Profile store | Document store | Relational | No standard shape across social providers |
| Feed rows | Reference to candidate | Full profile copy | Avoids storage blowup and stale profiles |
| Feed shape | One row per item | Array inside one document | Documents bloat, and serialization gets expensive |
| Feed store | DynamoDB | Relational | Hash key plus range key is exactly the access pattern |
| Feed timing | Precomputed asynchronously | Computed on request | Too much work for a request budget |
| Refill trigger | Frontend asks | Backend counts swipes | Faster refill, no work on the hottest write path |
| Swipe store | Reuse feed_items | Separate swipes table | The row already represents the pair |
| Seen check | Bloom filter | Exact set | Constant time and tiny, with a harmless failure direction |
| Enrichment | Async workers behind a broker | Inline at login | Third party APIs are slow and unreliable |
15. Interview Talking Points
- Do the location sizing out loud. Twelve bytes times fifty million users is 600 MB, so the concern is query and write rate, not storage. That sentence justifies Redis and sharding in one move.
- Explain why the location shard key is the geo cell and not the user id. This is the detail that most candidates get backwards.
- Give both feed storage approaches and pick one with a reason, then add the point about never storing a feed as an array in a document.
- Say that swipes reuse the feed table. Not creating a new database for every new noun is a real design instinct and interviewers notice it.
- Describe the match check as a reverse lookup with a unique ordered pair index, and name the simultaneous swipe race it prevents.
- Note that the Bloom filter's false positive direction is the safe one, and
that
feed_itemsremains the source of truth.