Skip to content

Designing Tinder

Geo sharded location tracking, profile enrichment, precomputed feeds, swipes and match creation.

Builds on Sharding and Partitioning and Message Queues and Streams

Stage 5advanced16 min read

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:

  1. Generating a feed of nearby, relevant profiles.
  2. Recording swipes, and turning two matching right swipes into a match.

2. Requirements

2.1 Functional Requirements

RequirementDetail
Feed generationShow profiles that fit the user's preferences
ProximityNearer profiles rank higher, filtered by a radius
Common interestShared interests rank higher
Preference filtersAge range, gender, distance radius
SwipeLeft means not interested, right means interested
No repeatsA swiped profile never appears again
MatchTwo mutual right swipes create a match
Match notificationBoth users learn about a match quickly

2.2 Non Functional Requirements

  1. Feed must feel instant. The next card is already on the device before the user reaches it. Any wait kills the swiping rhythm.
  2. Very high write volume. Swipes are the highest volume event in the system, far more than matches or messages.
  3. Horizontally scalable location writes. Every device emits its position periodically, which is a constant stream of writes.
  4. Eventual consistency is fine for the feed. A profile appearing a minute late is not a problem. A profile appearing twice is.
  5. 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.
Loading diagram…

The stored data is simply:

key   -> user_id
value -> lat, long

Sizing it:

user_id      32 bits =  4 B
lat, long    64 bits =  8 B
total per user      = 12 B

12 B x 50M users = 600 MB

Six 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:

  1. Ask the user to fill in profile information.
  2. Capture it from social login, where the user authorizes us to read profile, interests and connections.
Loading diagram…
  • 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:

TriggerBehaviourVerdict
Frontend calls an API when the remaining card count gets lowThe faster the user swipes, the sooner the call fires. Better experienceChosen
Backend keeps a counter and checks on every swipeExtra work on the hottest write path, and the refill starts laterSlower 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_id

Small 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_id

No 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_at

The 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:

  1. Asynchronous, because it is time consuming.
  2. Triggered by the frontend, when the user's buffer is running low.
Loading diagram…

What one generator run does:

  1. Read the user's preferences: radius, age range, gender.
  2. Query the location shard for candidate user ids inside the radius.
  3. Drop anyone the user has already swiped on, using the seen filter.
  4. Score the survivors on distance, shared interests, activity and desirability.
  5. 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:

  1. Mark is_interested = true on A's feed item for B.
  2. Look up (B, A) in the feed DB.
  3. If that entry does not exist, or is_interested is false, do nothing.
  4. 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

MethodPathPurpose
POST/api/locationReport 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

MethodPathPurpose
GET/api/feed?limit=20Next batch of unseen cards
POST/api/feed/refillAsk for more, called when the buffer is low
GET/api/preferencesRead filters
PUT/api/preferencesUpdate 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

MethodPathPurpose
POST/api/swipesRecord one swipe
GET/api/matchesList matches, newest first
DELETE/api/matches/:matchIdUnmatch

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

DirectionEventPayload
Server to clientmatch:new{ matchId, user, matchedAt }
Server to clientfeed: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

ColumnTypeNotes
iduuidPrimary key
phonetextUnique
emailtextUnique, nullable
date_of_birthdateAge is derived, never stored
genderenum
created_attimestamptz
last_active_attimestamptzFeeds ranking
is_activeboolean

social_accounts

ColumnTypeNotes
iduuidPrimary key
user_iduuidForeign key to users.id
providerenumFACEBOOK, GOOGLE, TWITTER
provider_user_idtextUnique with provider
access_tokentextEncrypted at rest
scopestextWhat the user authorized
last_enriched_attimestamptzWhen the enricher last ran

user_preferences

ColumnTypeNotes
user_iduuidPrimary key, foreign key to users.id
min_ageinteger
max_ageinteger
interested_inenum
radius_kminteger
show_mebooleanPause being shown to others

10.2 Profile, Document Store

profiles (MongoDB or DynamoDB)

FieldTypeNotes
_iduuidSame value as users.id
displayNamestring
biostring
photosarrayCDN urls with order and moderation status
interestsarrayNormalized interest tags
job, schoolstringOften from social enrichment
enrichedSourcesobjectRaw payload per provider, shape varies
eloScorenumberDesirability signal used in ranking
updatedAtdate

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 500

Keyed 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

AttributeTypeNotes
user_idstringHash key, the viewer
created_atnumberRange key, orders the deck
candidate_user_idstringWho is shown
scorenumberRanking score at generation time
distance_kmnumberSnapshot at generation time
is_interestedbooleanNull until swiped, true is right, false is left
swiped_atnumberNull 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)

FieldTypeNotes
_iduuidThis is the match_id messaging uses
userAuuidLower user id of the pair, for a stable unique key
userBuuidHigher user id of the pair
matchedAtdate
statusenumACTIVE, UNMATCHED, BLOCKED
lastMessageAtdateSorts 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

Loading diagram…

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 have is_interested = true.

11. Key Flows

11.1 Feed Generation

Loading diagram…

11.2 Swipe Producing a Match

Loading diagram…

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:

SignalEffect
DistanceCloser is better, with sharp falloff past the radius
Shared interestsOverlap count from the profile tags
ActivityA user active today beats a user last seen in March
Desirability, or EloBalances exposure so popular profiles do not take every slot
ReciprocityCandidates 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

FailureEffectHandling
Location shard downCannot find nearby candidatesServe the existing deck, retry generation. A stale deck is better than no cards
Bloom filter Redis downCannot check seen status fastFall back to reading feed_items for the candidate batch, slower but correct. Rebuild the filter from the feed table
Feed generator backlogDeck runs dryAutoscale on queue depth, and refill early enough that the buffer absorbs a delay
Profile DB slowGeneration stallsGenerate with reference rows only, and let the read path fill profiles from cache
Duplicate swipe from a retryDouble countedclientEventId idempotency, and the feed item update is naturally idempotent
Simultaneous mutual right swipesTwo match rowsUnique index on the ordered (userA, userB) pair, the loser of the race reads the existing match
Match push failsUser does not see the matchThe match row is already written, so the match list shows it on next open. Push is an optimization
Stale locationSomeone shown at the wrong distanceTTL on location entries, and drop candidates whose position is older than a threshold

14. Tradeoffs

DecisionChosenAlternativeReason
Location storeRedis, geo shardedRelational with a spatial indexWrite rate is enormous and the data is tiny
Location shard keyGeo cellUser idRadius queries must hit one shard
Profile storeDocument storeRelationalNo standard shape across social providers
Feed rowsReference to candidateFull profile copyAvoids storage blowup and stale profiles
Feed shapeOne row per itemArray inside one documentDocuments bloat, and serialization gets expensive
Feed storeDynamoDBRelationalHash key plus range key is exactly the access pattern
Feed timingPrecomputed asynchronouslyComputed on requestToo much work for a request budget
Refill triggerFrontend asksBackend counts swipesFaster refill, no work on the hottest write path
Swipe storeReuse feed_itemsSeparate swipes tableThe row already represents the pair
Seen checkBloom filterExact setConstant time and tiny, with a harmless failure direction
EnrichmentAsync workers behind a brokerInline at loginThird 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_items remains the source of truth.

In this topic

Whiteboard diagram

Open full screen
Loading diagram…
Designing Tinder

Done with this topic?

How well do you know it?

PreviousDesign Ecommerce Product Listing (Video Processing)