Tinder
0-day streakReview

Your progress

Level 1

0 XP100 XP to level 2
0day streak
0best streak
0topics started
0cards reviewed
    Review cards

    Progress is stored only in this browser. No account is required.

    Layer 4 · Full breakdown · 32 min · 13 figures

    Design Tinder

    Design a swipe-based dating app: geosharded search for nearby candidates, precomputed decks, a seen-set that never repeats a face, race-free match detection for billions of swipes a day, and instant match notifications.

    Tinder looks like the simplest app on your phone: one photo, swipe left or right, and sometimes “It’s a match!”. Behind the card sit a location-aware search engine that finds people near you who would also want to see you, a write path absorbing billions of swipes a day, and a matching step that must never miss — or double-count — two people liking each other. Three things make the design interesting: searching by location when users are piled into a few dense cities, serving a deck that never repeats a face and never makes you wait, and detecting a mutual like exactly once, even when both people swipe in the same instant.

    Understand the problem

    A dating app is a two-sided recommender with a geography constraint. Unlike a news feed, where you choose whom to follow, here the system chooses every card, and a card only helps if that person might choose you back.

    Functional requirements

    1. Users should be able to create a profile with photos, a short bio, and preferences: age range, maximum distance and the genders they want to see.
    2. Users should be able to see a stack of nearby candidates who fit their preferences and whose preferences they fit, one card at a time.
    3. Users should be able to swipe right (like) or left (pass) on each card, and should not see the same person again.
    4. Users should get a match when both have swiped right, be told instantly, and then be able to chat with that match.

    Non-functional requirements

    • Scale. Assume 50M daily active users swiping about 40 cards each: 2 billion swipes a day. That is in line with Tinder engineers’ own 2020 description of serving about 2 billion daily member actions. Traffic peaks on Sunday evenings and in the week before Valentine’s Day.
    • Swiping feels instant. The next card is already on the phone; recording a swipe takes under 100 ms at p99. Fetching a new batch of cards takes under 200 ms at p99.
    • Matches are never lost or doubled. A mutual like must produce exactly one match, durably. This is the one place that needs strong, per-pair consistency.
    • Matches arrive quickly. Both people learn about a match within 2 seconds at p99 if their app is open.
    • Freshness is relaxed elsewhere. A profile edit, a new photo or a location change may take up to a minute to show in other people’s decks. Eventual consistency is fine for search.
    • Availability. 99.95% for swiping and decks. If ranking is down, show a plainer deck; never show an empty screen because a model is slow.
    • Privacy. Never reveal anyone’s exact location; show distance rounded to whole kilometres, and never show a profile to someone who has blocked it.

    In the interview

    Ask: “What’s the maximum search radius?” (Tinder caps it at 100 miles, about 160 km, which bounds every query.) “Do both sides’ preferences have to match?” (Usually yes — a card for someone who would never see you is wasted.) “Can someone see a person again after passing?” (It decides whether the seen-set must remember forever.) And “Must a match be exactly once?” — that answer tells you where you need strong consistency.

    Out of scope

    • Payments and paid features such as boosts or “see who liked you”; they reuse the same data (see the follow-ups).
    • Trust and safety pipelines — photo verification, fake-profile detection, moderation. They feed a “hidden” flag into ours.
    • The internals of chat. After a match, chat is ordinary one-to-one messaging; design WhatsApp covers it.
    • Training the ranking model. We design how scores are served, not trained.
    • Video, audio and events features — different products on the same identity.

    The set-up

    Plan the approach

    Walk the requirements with the simplest thing that works — a profile store, a search index queried on demand, a swipe table, a match check — then spend the deep dives on what breaks at scale: the geo query, deck serving, the seen-set, match detection, notifications and users who move. Say the shape early: “a read-heavy, location-bounded search problem feeding a write-heavy swipe log, with one small strongly consistent core — the match.”

    Core entities

    • User profile. ID, name, birth date, gender, bio, photo keys, a hidden flag, and the last-active time.
    • Preferences. Age range, maximum distance and wanted genders — copied into the search document, because other people’s searches must check them too.
    • Location. Latitude and longitude, the S2 cell that contains them, and the geoshard that cell maps to.
    • Swipe. Swiper, target, direction (like or pass), time and a client-generated swipe ID for retries.
    • Match. A pair of users, a creation time and an ID derived from the pair, so the same two people always get the same ID.
    • Deck. A per-user queue of candidate IDs, already filtered and ranked, waiting to be served.

    The search document is a denormalised copy of the profile, shaped for filtering:

    {
      "user_id": "u_83f1",
      "location": { "lat": 19.1197, "lon": 72.8468 },
      "geoshard": 17,
      "age": 29,
      "gender": "woman",
      "wants_genders": ["man"],
      "pref_age_min": 26,
      "pref_age_max": 34,
      "pref_max_km": 25,
      "last_active": "2026-10-27T18:42:00Z",
      "hidden": false
    }

    API

    All endpoints sit behind the API gateway, which authenticates the user.

    Profile and location. Photos are uploaded straight to object storage with a short-lived signed URL; the server only stores the key.

    PUT /v1/me/profile
    { "bio": "Chai over coffee", "prefs": { "age_min": 26, "age_max": 34, "max_km": 25, "genders": ["man"] } }
    → 200
    
    POST /v1/me/photos
    → 200 { "photo_id": "ph_19", "upload_url": "https://uploads.example.com/…?sig=…", "expires_in": 300 }
    
    PUT /v1/me/location
    { "lat": 19.1197, "lng": 72.8468, "accuracy_m": 35 }
    → 204

    The deck. A batch of cards; the app keeps a local stack and asks for more when a handful are left.

    GET /v1/recs?count=25
    → 200
    {
      "cards": [
        { "user_id": "u_9a40", "name": "Rohan", "age": 30, "distance_km": 4,
          "photos": ["https://cdn.example.com/p/9a40/1_1080.webp"],
          "bio": "Weekend trekker" }
      ]
    }

    Swipes. One call per swipe. The client-generated swipe_id makes retries harmless; if the like completes a match, the response says so.

    POST /v1/swipes
    { "swipe_id": "sw_01JB7Q…", "target_id": "u_9a40", "direction": "like" }
    → 200 { "match": { "match_id": "m_5e1c…", "user_id": "u_9a40" } }
    → 200 { "match": null }

    Matches and chat. Real-time events arrive over a WebSocket; a sync call fills any gap after a reconnect.

    GET  /v1/matches?since=2026-10-27T18:00:00Z
    WSS  /v1/events            → { "type": "match.created", "match_id": "m_5e1c…", "with": "u_83f1" }
    POST /v1/matches/m_5e1c…/messages   { "client_msg_id": "c_77", "text": "Hi!" }

    Back-of-envelope estimates

    These numbers decide what needs sharding and what needs a cache (method: back-of-envelope estimation).

    Swipes. 50M DAU × 40 swipes = 2B swipes/day. 2B ÷ 86,400 ≈ 23k swipes/s on average; with a 3× Sunday-evening peak, about 70k swipes/s.

    Likes and matches. Assume 30% of swipes are likes: 2B × 0.3 = 600M likes/day, about 7k/s on average and 21k/s at peak. Assume 5% of likes are returned: 600M × 0.05 = 30M matches/day, about 350/s on average and roughly 1k/s at peak. Each match notifies two people: 60M notifications a day. (Tinder reports more than 100 billion matches since 2012 — roughly 20M a day averaged over its lifetime — so 30M a day today is a reasonable planning number.)

    Deck fetches. Every swipe consumes one card, and cards travel 25 at a time: 2B ÷ 25 = 80M fetches/day, about 930/s on average and 2.8k/s at peak.

    Deck builds. Assume two background rebuilds per active user per day: 50M × 2 = 100M builds/day, about 1.2k/s on average and 3.5k/s at peak. Each build searches two or three geoshards, so the search cluster sees about 10k shard queries/s at peak.

    Location updates. Six per active user per day: 50M × 6 = 300M/day, about 3.5k/s on average and 10k/s at peak. Most do not change geoshard.

    Storage.

    • Swipe log: a row of two 8-byte IDs, a timestamp, a direction and overhead, about 40 bytes. 2B × 40 B = 80 GB/day, 80 GB × 365 ≈ 29 TB/year, about 88 TB with three replicas.
    • Search documents: 100M monthly-active profiles × 2 KB = 200 GB, 600 GB with two replicas. Small; the index is bound by CPU, not disk.
    • Photos: 100M profiles × 6 photos × 600 KB for four sizes ≈ 360 TB in object storage.

    Photo bandwidth. Every card shows at least one photo. At about 100 KB per card: 2B × 100 KB = 200 TB/day, and 200 × 10¹² × 8 bits ÷ 86,400 s ≈ 18.5 Gbps on average, about 55 Gbps at peak. Only a CDN should carry that.

    Open connections. If 20% of daily users are online at the peak, that is 10M WebSockets. At 100k connections per gateway node, about 100 nodes.

    Tip

    The numbers that shape this design are 70k swipe writes per second (a partitioned write store), 10M open sockets (a dedicated gateway tier) and 200 TB of photos a day (a CDN). The geo search is modest — about 10k shard queries a second — but only because decks are precomputed rather than searched per swipe.

    High-level design

    Build one requirement at a time; by the end the app works for one city, with no sharding and no cleverness.

    Users should be able to create a profile

    A profile service validates and stores profile fields and preferences in a profile database, the source of truth — a key-value store partitioned by user ID, since every read is “give me user X”. Tinder’s engineers describe this pattern — DynamoDB as the source of truth with Redis in front as a cache-aside layer — because the overwhelming majority of profile operations are reads.

    Photos take a different road: the app gets a signed upload URL and sends the bytes straight to object storage, where an image worker makes four sizes and strips location metadata. Card views pull photos from a CDN — at 200 TB a day, not optional.

    Step 1: profile fields go to the profile database; photo bytes go straight from the phone to object storage and out through a CDN.Notice Photo bytes never pass through the API servers — the app uploads with a signed URL and downloads from the edge.

    Every change to the profile database is also published to a profile-changes stream using change data capture. The next requirement consumes it.

    Users should be able to see a stack of nearby candidates

    A recommendation service answers “who should Asha see next?” with one query to a search index — Elasticsearch here — that holds a document per active profile. An indexer consumes profile-changes and upserts documents, so the index trails the database by a second or two.

    The query is a set of filters in two directions. Asha, 29, in Andheri, wants men aged 26 to 34 within 25 km. A candidate must fit her preferences and she must fit his: he wants women, his age range includes 29, and she is inside his own distance limit.

    {
      "size": 500,
      "query": { "bool": { "filter": [
        { "geo_distance": { "distance": "25km", "location": { "lat": 19.1197, "lon": 72.8468 } } },
        { "range": { "age": { "gte": 26, "lte": 34 } } },
        { "term":  { "gender": "man" } },
        { "term":  { "wants_genders": "woman" } },
        { "range": { "pref_age_min": { "lte": 29 } } },
        { "range": { "pref_age_max": { "gte": 29 } } },
        { "range": { "last_active": { "gte": "now-7d" } } },
        { "term":  { "hidden": false } }
      ] } }
    }

    The geo_distance filter uses the document’s geo_point, and everything else is a cheap term or range filter, so the index never scores text. One condition does not fit a filter: “Asha is within his pref_max_km” depends on the distance between two people, which differs for every candidate. Check it after retrieval, in the service, along with dropping anyone Asha has already swiped on.

    Step 2: the recommendation service runs one filtered geo query against a search index fed from the profile database.Notice The filters run in both directions: each candidate must fit Asha’s preferences, and Asha must fit theirs.

    The service orders the survivors — for now, most recently active first, since someone who opened the app an hour ago is far likelier to reply — and returns 25 cards with CDN photo URLs.

    Users should be able to swipe right or left

    The app keeps the 25 cards in a local stack. A swipe removes the top card at once and sends POST /v1/swipes in the background; if the call fails, the app retries with the same swipe_id. A swipe service writes one row to a swipe log partitioned by swiper — (swiper_id, target_id) → direction, time — and publishes a swipe event. Consumers of that stream update the swiper’s seen-set, ranking features (“Rohan gets liked a lot this week”) and analytics.

    A wide-column store such as Cassandra fits the swipe log: 70k small appends a second at peak, spread across partitions by swiper, with “has Asha swiped on Rohan?” as a single-partition lookup.

    Step 3: the app swipes from a local stack of prefetched cards and records each swipe with one write.Notice The swipe is optimistic: the card leaves the screen before the server answers, so a slow write never slows the swiping.

    Users should get a match when both swipe right

    When Asha likes Rohan, the swipe service must answer one question: has Rohan already liked Asha? The first version reads the swipe log at (Rohan, Asha). If it holds a like, the service writes a match row, returns the match in Asha’s swipe response, and publishes a match.created event. A notifier consumes it and reaches Rohan over his open WebSocket, or through Apple and Google push if the app is closed. After that, the two can chat; the chat service only needs to check that a match exists between sender and receiver.

    Step 4: when Asha likes Rohan, the swipe service checks whether Rohan already liked her; if he did, it creates the match and tells both.Notice Asha learns about the match in her own swipe response, while Rohan learns through a separate notification path.

    This works for a single city. At 2 billion swipes a day, one global index is too slow and expensive, searching per request makes decks slow, the “not already seen” filter has nowhere cheap to live, and the match check has a race. The deep dives fix each, then handle notifications and moving users.

    Deep dives

    How do we find nearby candidates without searching the whole world?

    You need a spatial index: a way to turn two dimensions into something a normal index can range-scan. (The geospatial indexes article goes deeper.)

    Bad: plain latitude and longitude columns with B-tree indexes. “Latitude between 18.9 and 19.3” returns a band of people circling the planet, and the database filters longitude row by row. Two one-dimensional indexes cannot answer a two-dimensional question efficiently.

    Good: geohash. Interleave the bits of latitude and longitude into one number, so points sharing a long prefix are usually close. Redis builds its geo commands this way: members of a sorted set are scored by a 52-bit interleaved geohash, and a radius query checks the centre cell plus its eight neighbours. Simple and fast, with two flaws: two people either side of a cell edge can share almost no prefix, which is why every query needs those neighbours; and cells are equal in degrees, not area, so they narrow away from the equator and one prefix length means different distances in Chennai and Oslo.

    Good: quadtree. Start with one square and split a leaf into four when it holds more than, say, 1,000 people. Cities get deep, small leaves and countryside one big leaf, so leaves hold similar numbers of people. You gain balance under skew; you pay with a structure that changes shape as people move, awkward to store and to shard.

    Great: S2 cells. S2 projects the sphere onto a cube’s six faces and numbers each face along a Hilbert curve, which keeps nearby points close in the numbering. Cells form a quadtree, from level 0 (a cube face) to level 30 (about a square centimetre), and cells at one level have similar area anywhere on Earth: at level 7, between about 3,200 and 6,500 km², with edges of roughly 54 to 78 km. The library can cover a circle with a handful of cells, which usually form a few contiguous ID ranges.

    Three ways to answer “who is within 25 km of me?”Notice On the Hilbert curve the circle’s four cells are numbered 6 to 9 — one contiguous range — so nearby places stay nearby in the index.
    Lat/lng B-trees Geohash Quadtree S2 cells
    How it works Two 1-D range scans Interleaved bits, prefix = area Split a cell when it fills up Cube projection + Hilbert curve, 31 levels
    Radius query Scans a whole latitude band Centre cell + 8 neighbours Walk the leaves the circle touches Cover the circle with a few cells
    Cell size n/a Equal in degrees; narrows towards the poles Adapts to density Similar area everywhere at one level
    Under city skew Slow Hot cells in cities Balanced by design Balanced if you group cells by load
    Good fit Small datasets Redis GEOSEARCH, quick prototypes In-memory index on one machine Sharding a planet-scale index
    Spatial indexes for “people near me”

    In practice you combine two ideas: S2 decides which shard to ask, and the search engine’s own geo index answers the radius query inside that shard. Elasticsearch already handles geo_distance filters; what you must stop is every query searching the whole world.

    How do we shard the candidate index by geography and keep shards balanced?

    Tinder’s engineers wrote up this exact journey in a three-part series. For years their recommendations ran on one Elasticsearch cluster with one index and the default five shards, scaled with replicas and bigger machines until CPU stayed high and costs climbed. Their load tests showed performance improving roughly linearly as the index shrank.

    Bad: one global index, scaled with replicas. Every query from Pune searches documents from Peru. Each replica still holds and scans the whole world, so cost grows with total users although each user only cares about people within 160 km.

    Good: equal latitude–longitude tiles, one index per tile. Queries touch only nearby data, but load is wildly uneven: ocean tiles are empty, a Delhi NCR tile holds millions, and tile area distorts towards the poles. One slow index becomes a few red-hot ones.

    Great: geoshards — S2 cells grouped by load. The idea has four steps:

    1. Measure load per cell. Pick a load score — unique users, active users, or queries per hour — and compute it for every S2 cell at a fixed level. Tinder chose levels 7 and 8 (cell edges of very roughly 45 and 22 miles), because most users’ distance preferences fall within 50 miles.
    2. Walk the cells in Hilbert order and pack them. Pour each cell’s load into a container of fixed capacity; when the next cell would overflow it, seal that container as a geoshard and start a new one. Because the Hilbert curve preserves locality, each geoshard is a geographically compact region.
    3. Pick the best capacity. Try many capacities and keep the one whose geoshards have the smallest standard deviation of load. Capacity is the only knob.
    4. Store a mapping from S2 cell token to geoshard. Indexing maps a point to its cell and then to a geoshard; querying covers the search circle with cells and maps those to a small set of geoshards.
    Packing S2 cells into geoshards: walk the cells in Hilbert order and seal a geoshard when the next cell would overflow it.Notice The rural geoshard spans eight cells while the megacity gets one: geoshards are equal in load, not in area.

    How many geoshards? Too few and you are back to one big index. Too many and one query fans out widely: Tinder notes that with 100-square-mile geoshards, a 100-mile search circle would touch about 314 of them, ruining tail latency, and more users would cross boundaries as they move. Their load tests landed on 40 to 100 geoshards worldwide; in their example map, a 100-mile query touched 3 of 55. This design uses about 60: with 100M active profiles, under 2M documents per geoshard on average.

    One cluster or many? Each geoshard can be its own cluster (clean isolation, but you must fan out queries and shard moves yourself) or an index inside one cluster (Elasticsearch searches several indices in one request). Tinder chose one cluster with many indices for simpler code and operations, and scales a hot geoshard by giving it more replicas.

    Time zones. A geoshard’s load follows its local evening, and Tinder reported more than a 10× difference in requests per second between geoshards at the same moment. With one geoshard per node, nodes would take turns being overloaded. Mixing time zones by hand is a bin-packing problem that breaks at every reshard, so they used randomness: each data node holds about four indices — primaries and replicas of different geoshards — placed at random, with the replica count chosen so two same-peak geoshards rarely share a node.

    Geoshards peak at different hours, so each data node holds a random mix of geoshard copies from different time zones.Notice No node holds only evening-peak shards, so each node’s load is flatter than any single geoshard’s.

    The payoff Tinder reports: 20 times more computation than the single index. The cost is a mapping to maintain, fan-out across a few indices, and users who must move between geoshards — the last deep dive. Resharding is done offline: build a second set of indices from a new mapping, re-index, cut over.

    In the interview

    The sentence to say: “I shard the search index by geography, but I balance shards by load, not by area — S2 cells walked in Hilbert order and packed until each geoshard carries about the same traffic. A query covers its circle with S2 cells and asks only the two or three geoshards they map to.”

    How do we serve a deck that feels instant?

    A fetch must return 25 good cards in under 200 ms, and each card needs a geo query, two-way preference checks, a seen-set check and a ranking model. Doing that on the request path is the mistake.

    Bad: search and rank on every fetch. Each fetch queries two or three geoshards, post-filters, scores with the model and returns the top 25. At 2.8k fetches a second that is about 8k shard queries a second, the slowest of three parallel queries plus a model call puts p99 at 400 to 600 ms, and the same candidates are rescored on every batch.

    Good: cache each user’s search result for a few minutes. Later fetches page through it. Faster, but the first fetch of every session is still slow.

    Great: a precomputed deck per user, built in the background. A deck builder keeps a queue of about 200 ranked candidate IDs per active user in Redis. The deck service just pops the next 25 IDs, loads their cards from the profile cache in one batch, drops anyone who became ineligible since the build — hidden, deleted, blocked, or swiped on another device — and returns: two or three in-memory round trips, well under 50 ms. The builder runs:

    • when the queue falls below 50 IDs, leaving minutes of swiping to finish;
    • when the user changes preferences or geoshard (the old queue is discarded);
    • on the first open of the day, so nobody swipes yesterday’s ranking.

    A brand-new user has no queue, so their first build runs synchronously with a target of 50 IDs: 400 ms instead of 50 ms, once. The queues are small: 50M users × 200 IDs × 8 bytes = 80 GB, about twice that with Redis overhead.

    Building a deck: cheap filters first, the expensive model last, and only 25 cards leave per request.Notice The model scores 1,200 candidates once per rebuild — not once per swipe or per fetch.

    What should the ranking optimise? Tinder says publicly that it prioritises members who are active — and active at the same time — and also weighs distance, interests on the profile, cues from photos similar to ones you have liked, and the stream of Likes and Nopes. It also calls the Elo score that once made headlines “old news”. You can see why one Elo-style number fits poorly: it puts everyone on a single ladder of desirability, says nothing about taste, and pushes the same top-rated profiles into everyone’s deck. A better target for a two-sided product is reciprocal: rank Rohan for Asha by the chance that she likes him and he likes her back. A like that is never returned is a wasted card for both.

    Fair exposure. Popularity on dating apps is lopsided: a small share of profiles attract a large share of right swipes. Rank purely by “will Asha like him?” and those few profiles fill every deck and drown in likes they never answer, while most people get none. The re-rank stage spreads exposure: an hourly impression budget per profile, tracked in a Redis counter, so the most-liked appear in fewer new decks once their inbox of likes is full; a modest boost for new and under-shown active profiles; and no five near-identical profiles in a row. You pay with a lower raw like-rate per card; you gain more matches overall and a marketplace where most people get some replies.

    Degrade, never empty

    If the ranking model times out, the builder falls back to “most recently active first” and the deck service keeps serving the old queue. A slightly worse deck costs a little engagement; an empty screen on a Sunday evening sends people to another app.

    How do we never show the same person twice?

    Both the builder and the deck service must answer “has Asha already swiped on this person?” for thousands of candidates. An active user who has been swiping for months has easily 10,000 swipes behind them.

    Bad: look each candidate up in the swipe log, or send the list to the search engine. 2,000 lookups per build at 3.5k builds a second is 7M lookups a second. A “must not” filter instead ships 10,000 IDs — about 200 KB — with every query, and each shard evaluates a giant exclusion list.

    Good: an exact set per user in memory. A Redis set answers in microseconds. But Redis documents a set’s memory as roughly 192 bits of overhead plus the value per member, so a 64-bit ID costs about 32 bytes: 10,000 × 32 B = 320 KB per user, and 50M × 320 KB = 16 TB of RAM. Correct, and very expensive.

    Great: a Bloom filter per user. A Bloom filter answers “definitely not seen” or “probably seen” using a fixed number of bits per item. From the Redis Bloom filter documentation: a 1% false-positive rate needs about 9.6 bits per item and 7 hash functions; 0.1% needs about 14.4 bits and 10. For 10,000 swipes at 1%: 10,000 × 9.585 ÷ 8 ≈ 12 KB per user, and 50M × 12 KB ≈ 600 GB — about 27 times less than the exact set. More detail in Bloom filters.

    Memory needed to remember who each active user has already swiped on.Notice The trade: the 1% Bloom filter is about 27× smaller, and its only error is hiding 1 in 100 unseen profiles — it never shows anyone twice.

    The direction of the error is what makes this safe. A Bloom filter never says “not seen” for someone you have seen, so it never repeats a face. Its only mistake is the reverse: about 1 in 100 genuinely new candidates is wrongly treated as seen and silently skipped. In Mumbai, with tens of thousands of eligible people, nobody notices. In a small town with 150 eligible people, it could hide one or two — so when a build finds fewer than about 300 candidates, the builder re-checks the “probably seen” ones against the exact swipe log. That is a few hundred lookups, only for small pools.

    Two practical details. Growth: a filter that keeps filling sees its error rate climb, so Redis stacks a new, larger sub-filter when one fills, at the cost of checking each layer. Ageing: Bloom filters cannot delete, so if passes may resurface after six months, keep likes in one permanent filter and passes in monthly filters, and drop the oldest each month. The swipe-event consumer adds each swipe with BF.ADD, 70k a second at peak, and the filter shares a Redis hash tag with the deck queue, such as {u_83f1}, so one round trip reads both.

    How do we record billions of swipes and detect matches without races?

    Asha and Rohan are both online on a Sunday evening — exactly when the ranking puts active people in front of each other — and swipe right on each other within the same 50 ms. The first design can fail two ways.

    • Missed match. If each request checks first (“has the other person liked me?”) and writes second, both checks can run before either write lands. Both see “no”, both store a like, and no match is ever created.
    • Double match. If each request writes first and checks second, a linearizable store guarantees at least one check sees the other like — but both may, creating two matches and two notifications each. With asynchronous replicas, a check served by a lagging replica can still miss.
    Two people like each other in the same instant: two separate records can miss the match; one record per pair cannot.Notice On the right only Rohan’s update sees both flags set, so exactly one request creates the match.

    Bad: two rows, check-then-write in the service. Correct only when people don’t swipe at the same time — which on a busy evening they do.

    Good: a lock per pair. A short distributed lock on the pair key min(A,B):max(A,B) around check and write is correct, but every like — 21k a second at peak — pays a round trip to a lock service, and a request that stalls holding the lock blocks the other like until it expires.

    Great: one row per pair, updated atomically. Store every like in a row keyed by the unordered pair: lo = min(A, B), hi = max(A, B), with one “liked at” column for each side. A like is a single upsert that sets the caller’s column and returns the row. Updates to one row are serialised by the store, so exactly one update sees the row change from “one side liked” to “both liked” — and that request creates the match. No lock service, one round trip, no race. Any store with atomic single-row read-modify-write works: a sharded relational database, or a key-value store whose conditional update returns the new item. (Routing likes through a queue partitioned by pair key is also race-free, but then Asha hears about the match from a later event, not her swipe response.)

    -- Asha (u_83f1) likes Rohan (u_9a40). lo < hi, so Asha is "lo".
    INSERT INTO pair_likes (lo, hi, lo_liked_at, hi_liked_at)
    VALUES ('u_83f1', 'u_9a40', now(), NULL)
    ON CONFLICT (lo, hi) DO UPDATE
      SET lo_liked_at = COALESCE(pair_likes.lo_liked_at, EXCLUDED.lo_liked_at)
    RETURNING (lo_liked_at IS NOT NULL AND hi_liked_at IS NOT NULL) AS mutual;
    
    -- Only if mutual: create the match exactly once. The ID is derived from the pair.
    INSERT INTO matches (match_id, lo, hi, created_at)
    VALUES ('m_' || md5('u_83f1:u_9a40'), 'u_83f1', 'u_9a40', now())
    ON CONFLICT (match_id) DO NOTHING;

    Three details make it robust:

    • Idempotent swipes. If Asha’s app retries after a timeout, COALESCE keeps the first timestamp and the match insert is a no-op, because the match ID comes from the pair.
    • Only likes take this path. Passes never create matches, so they go only to the swipe log. The pair store sees 21k writes a second at peak instead of 70k: 600M likes a day at about 60 bytes is 36 GB a day, roughly 13 TB a year. Shard it by a hash of (lo, hi); at a comfortable 3k upserts a second per primary, eight primaries cover the peak with headroom.
    • Events can’t be lost. A crash between committing a match and publishing match.created would leave Rohan uninformed, so write the event to an outbox table in the same transaction and publish from there (see transactional outbox). A nightly job looking for mutual pairs with no match row is a cheap safety net.
    Check, then write (two rows) Lock per pair One row per pair, atomic upsert Queue partitioned by pair
    Missed matches Yes, under concurrency No No No
    Duplicate matches Possible No No (deterministic ID) No
    Extra work per like None Lock round trip + lock service None A queue hop
    Swiper sees “It’s a match!” instantly Yes Yes Yes No — it arrives later as an event
    Main risk Wrong answers Stalled lock holders Hot rows (rare: one pair) Consumer lag on busy partitions
    Detecting a mutual like

    This is the classic contention pattern: push the conflict into one record the database already serialises.

    How do we tell both people about a match instantly?

    Asha learns about the match in her swipe response. Rohan should hear within two seconds if his app is open, and get a push notification if it is not. That is about 60M notifications a day — roughly 700 a second, around 2k at peak. The volume is easy; 10M open connections are not.

    Bad: clients poll. Ten million online users asking “anything new?” every 5 seconds is 2M requests a second, almost all answered “no”, and a match still takes up to 5 seconds to show.

    Good: send everything through Apple and Google push. No connections to run, but push is best-effort — it can take seconds, the OS may delay or collapse messages — and the app still needs a live event stream for things like “Rohan is typing”.

    Great: a WebSocket gateway, a presence registry, and push as the fallback.

    • Each open app holds one WebSocket to a gateway node. On connect, and on every 30-second heartbeat, the gateway writes presence:{user} → node-42 to Redis with a 60-second expiry.
    • The notifier consumes match.created. For each of the two users it looks up presence and, if there is an entry, forwards the event to that gateway node, which writes it to the socket.
    • If there is no presence entry, or the app does not acknowledge within about 3 seconds, the notifier sends an APNs or FCM push instead.
    • Delivery is at-least-once. The app deduplicates by match_id, and on every reconnect it calls GET /v1/matches?since=… so anything lost while it was offline is filled in. (More in real-time connections and design a notification system.)
    Telling both people: Asha hears in her swipe response; Rohan hears over his open socket, or by push if his app is closed.Notice The notifier looks up which gateway node holds Rohan’s socket instead of broadcasting the event to every gateway.

    The connection count is the real engineering. At about 20 KB per socket, 10M sockets need roughly 200 GB of gateway memory, around 100k per node. The dangerous moment is a deploy: drop 100k sockets at once and 100k phones reconnect in the same second, so drain nodes slowly and have clients reconnect with random jitter. After the match, chat rides the same sockets; the chat service only checks that a match exists between sender and receiver.

    How do we handle location updates and moves between geoshards?

    The app sends its location when it opens and after moving about a kilometre: around 10k updates a second at peak. Most stay inside the same geoshard. A few cross a boundary — and a “Passport”-style jump to another city always does.

    A cross-geoshard update is a move: index the document into the new geoshard, delete it from the old one, and update the mapping of user to geoshard. Three writes to two systems, not atomic. Tinder’s consistency write-up describes the two ways this breaks.

    Out-of-order moves. A user flips Passport to Paris and back home within milliseconds. If any worker can take any message, “move to Mumbai” can run first — a no-op — and “move to Paris” last. She is home, but her document sits in the Paris geoshard, invisible to her neighbours.

    A user changes location twice within milliseconds. Unordered processing can leave her document in the wrong geoshard.Notice Ordering per user is all you need — different users can still be moved in parallel on other partitions.

    The fix is ordering per user, not global ordering. Publish location changes to a Kafka topic keyed by user ID: every update for one user lands on the same partition and is applied by one consumer, in order, while other users’ moves run in parallel on other partitions. Tinder moved to Kafka for this reason: many queues offer only best-effort ordering, or strict FIFO at low throughput. See message ordering.

    Stale reads. Elasticsearch is near real-time: a write sits in an in-memory buffer until the next refresh — every second by default — before search can see it. A move that finds the current document with a search, or the reindex API, can miss a write made milliseconds earlier and leave the document behind. Tinder’s fix was to read it with the GET API, which is real-time and refreshes if that document has a pending write, then index it into the new geoshard. The user-to-geoshard mapping store must be read with strong consistency for the same reason.

    Crashes and drift. Retry failed writes with exponential backoff and jitter; the Kafka consumer resumes from its last committed offset. A move is still delete-plus-insert, so a user can briefly exist in two geoshards, and the deck builder deduplicates by user ID. A background refeed job compares the profile database with the index and repairs drift — the source of truth wins.

    Finally, a user who changes geoshard gets a rebuilt deck, and the deck service drops any queued candidate now beyond the viewer’s distance limit.

    The final design

    The request path is short — gateway, a service, one in-memory or single-row store — and everything expensive happens in the background: indexing, geosharded search, ranking and notifications.

    The complete design: request-path services on the left, background indexing and deck building along the top, events and notifications along the bottom.Notice No request waits on the search cluster or the ranking model: decks are built ahead of time, and the only synchronous write that needs strong consistency is the pair row.

    Follow Asha and Rohan through the system:

    1. Profile and location. Edits and location changes go through the gateway to the profile service and the profile database; photos go straight to object storage.
    2. Index. Changes flow by CDC into Kafka, keyed by user ID, to the indexer, which maps location to S2 cell to geoshard and updates the document — or moves it, in order, after a real-time GET.
    3. Build. The deck builder covers Asha’s 25 km circle with S2 cells, queries the two or three geoshards they map to, applies both people’s preferences, drops anyone in her seen-set Bloom filter, ranks for mutual interest, re-ranks for fair exposure, and stores a queue of 200 IDs in Redis.
    4. Fetch. GET /v1/recs pops 25 IDs, loads their cards from the profile cache, drops anyone now hidden, blocked, too far away or already swiped, and returns them with CDN photo URLs. A queue below 50 triggers a rebuild.
    5. Swipe. POST /v1/swipes appends to the swipe log. A like also upserts the pair row; if both flags are now set, the service inserts the match with an ID derived from the pair and returns it in the response. Events leave through an outbox into Kafka.
    6. Fan out. Consumers update Asha’s Bloom filter and ranking features, and hand match.created to the notifier.
    7. Notify. The notifier finds Rohan’s gateway node in the presence registry and sends the event down his WebSocket, or falls back to push. Rohan’s app deduplicates by match ID and syncs on reconnect.
    8. Photos. Every card image is served by the CDN, never by the API.

    Check it against the requirements. Swipes are one or two single-partition writes, well under 100 ms, and the card leaves the screen before that. Deck fetches are a Redis pop plus a batch read, inside 200 ms at p99. Matches are created exactly once by a single-row atomic update and a deterministic ID, and published through an outbox so a crash cannot lose them. Online users hear about a match in well under 2 seconds. Search, decks and seen-sets are eventually consistent, within a minute, and every expensive stage has a fallback, so a slow model or a sick geoshard gives a plainer deck, never an empty one.

    What interviewers look for by level

    Area Mid-level Senior Staff
    Requirements Profiles, swipes, matches Two-way preference filter, distance cap, exactly-once matches Marketplace health: reciprocal ranking, fair exposure, abuse and privacy
    Geo search “Store latitude and longitude, query by radius” Geohash or S2 with the search engine’s geo filters Geoshards balanced by load, fan-out sizing, time-zone skew, resharding
    Decks Query on every request Precomputed queue and client prefetch Funnel economics, rebuild triggers, cold start, degrade paths
    Seen-set Look up the swipe table Bloom filter per user Direction of the error, small-pool fallback, ageing filters, memory maths
    Matching Check the other person’s like Spots the race, uses an atomic operation One row per pair, deterministic match ID, outbox, reconciliation
    Real-time Push notification WebSocket plus push fallback Presence registry, reconnect storms, at-least-once delivery with sync
    Data movement Not discussed Index updated asynchronously Per-user ordering, near-real-time refresh pitfalls, refeed jobs

    Follow-up questions

    What if we add “see who liked you”?

    The pair table cannot answer “who liked Asha?” cheaply. Add a table partitioned by the person liked — (target_id, liked_at) → liker_id — written asynchronously from the pair-row change stream, plus a Redis counter for the badge (“24 people like you”). The deck builder can also mix a few people who already liked you into your queue: their like is certain, so their card has a much higher chance of becoming a match.

    What if someone tries to work out a user’s exact location?

    An attacker who can spoof their own location can measure “distance to Asha” from three points and trilaterate her position. Rounding the displayed distance to whole kilometres is not enough on its own, because enough samples narrow it down again. Snap each user’s stored location to the centre of a coarse cell — an S2 cell about a kilometre across — before any distance is computed; show a floor such as “less than 2 km”; rate-limit location changes and Passport jumps per account; and flag accounts whose location hops around in patterns no human produces.

    What if a geoshard’s search nodes go down?

    Each geoshard has replicas spread over random data nodes, so losing one node rarely loses a whole geoshard. If one is unavailable, the builder skips it and builds a partial deck from the rest of the circle. And because decks are precomputed, users keep swiping their existing queues: 200 cards is several days of swiping at 40 a day. Precomputation buys resilience as well as speed — the search cluster can be down for an hour without most users noticing.

    A celebrity joins, or a profile goes viral. Their pair rows are fine — each pair is a different row — but their “liked you” partition and counter become hot, and they receive far more likes than anyone can answer. Cap their impressions with the hourly exposure budget, keep the likes-received counter in a sharded counter rather than one key, and shed load on their inbox by showing them a ranked sample rather than every like. The general pattern is in hot keys.

    How to say it in the interview

    “I’ll design a swipe-based dating app for about 50 million daily users and 2 billion swipes a day — around 70 thousand a second at peak.

    Search is location-bounded, so I shard the candidate index by geography. I map the world to S2 cells, walk them in Hilbert-curve order and pack them into about 60 geoshards with similar load, so a megacity gets its own small geoshard and a rural region shares a big one. A query covers the user’s circle with S2 cells and asks only the two or three geoshards they map to. Each geoshard is an Elasticsearch index with replicas placed at random, so time zones peaking at different hours share nodes.

    Searching per swipe would be slow, so a background builder keeps a queue of about 200 ranked candidates per user in Redis. It filters both people’s preferences, removes anyone already seen with a per-user Bloom filter — about 12 kilobytes for ten thousand swipes, and it never repeats a face — ranks for mutual interest, and spreads exposure so a few popular profiles don’t fill every deck. The app prefetches 25 cards, so swiping never waits.

    For matching, every like upserts one row per pair of users. The store serialises updates to that row, so exactly one request sees both flags set, and it creates the match with an ID derived from the pair, which makes retries harmless. Events leave through an outbox, and the notifier reaches the other person over their WebSocket — found through a presence registry — or by push if they are offline.

    Location changes go through Kafka keyed by user, so moves between geoshards apply in order, with a real-time read before each move. Everything except the pair row is eventually consistent, and every expensive stage degrades to a plainer deck, never an empty one.”

    Concepts used in this design

    References and further reading

    1. Geosharded Recommendations Part 1: Sharding Approach (Tinder Engineering, 2019, archived copy)web.archive.orgOne Elasticsearch index with five shards at first; 100-mile limit; load score; 40–100 geoshards; S2 levels 7 and 8; Hilbert-curve packing; 20× more computation.
    2. Geosharded Recommendations Part 2: Architecture (Tinder Engineering, 2019, archived copy)web.archive.orgMulti-index versus multi-cluster, more than 10× load difference between geoshards across time zones, random placement of four indices per data node.
    3. Geosharded Recommendations Part 3: Consistency (Tinder Engineering, 2019, archived copy)web.archive.orgOut-of-order moves after Passport, Kafka partitions keyed by document for ordering, a real-time GET before moving a document, retries and refeeding.
    4. Powering Tinder® — The Method Behind Our Matching (Tinder Newsroom)tinderpressroom.comActive members are prioritised; age, distance and gender preferences; Likes, Nopes, interests and photo cues; Elo is “old news” and no longer used.
    5. Building resiliency at scale at Tinder with Amazon ElastiCache (AWS Database Blog, 2020)aws.amazon.comTinder engineers on serving about 2 billion daily member actions with Redis cache-aside in front of DynamoDB.
    6. About Tinder (Tinder Newsroom)tinderpressroom.comMore than 100 billion matches since launch, 185+ countries.
    7. S2 cell statistics (S2 Geometry)s2geometry.ioArea and edge length of S2 cells at each level, including levels 7 and 8.
    8. GEOADD command (Redis documentation)redis.ioGeo indexes are sorted sets scored by a 52-bit interleaved geohash; radius queries check the 1 + 8 surrounding areas.
    9. Near real-time search (Elasticsearch documentation)elastic.coWrites become searchable after a refresh, every second by default.
    10. Bloom filter (Redis documentation)redis.ioBits per item for a given false-positive rate (9.585 bits at 1%, 14.378 at 0.1%) and the memory cost of an exact set.