Skip to content

Tinder Swipe Match (LLD)#

Problem statement (interviewer prompt)

Design the class model for Tinder swipe matching. Users browse profiles, swipe LIKE / NOPE / SUPERLIKE, and a mutual LIKE creates a Match. Add a match feed with pagination, a block list, daily quotas, and a pluggable recommendation strategy.

The Tinder swipe LLD design hinges on one core question: when two users like each other concurrently, exactly one Match row must be created. A unique constraint or CAS on the ordered pair (userA, userB) is the cleanest answer.

classDiagram
  class User { +id, name, age }
  class Profile {
    +user : User
    +bio, photos, interests
    +visibility : Visibility
  }
  class Swipe {
    +swiper : User
    +swipee : User
    +action : SwipeAction
    +at : Instant
  }
  class SwipeAction {
    <<enumeration>>
    LIKE
    NOPE
    SUPERLIKE
  }
  class Match {
    +id, userA, userB : User
    +createdAt : Instant
    +lastActiveAt : Instant
  }
  class BlockList { +blocker : User, +blocked : Set~User~ }
  class DailyQuota { +user : User, +remaining : int }
  class RecommendationStrategy { <<interface>> +next(user, n) List~Profile~ }
  User *-- Profile
  Swipe --> SwipeAction
  Match --> User

A swipe is immutable; a Match is a separate aggregate created only on a mutual LIKE / SUPERLIKE. The block list short-circuits both recommendations and swipe acceptance. Daily quotas live on a per-user counter that resets at the user's local midnight.

A focused OO interview problem: tight domain, one tricky concurrency race, and clear extension points for recommendations and feed.

Problem statement (interviewer prompt)

Design the class model for Tinder's swipe and match flow. Users see a stream of profiles and tap LIKE / NOPE / SUPERLIKE. A mutual LIKE creates a Match. Add pagination over the match feed, a block list, a daily-swipe quota, and a recommendation strategy interface. Discuss what happens when both users swipe LIKE at the same instant.

Requirements#

Functional

  • Browse a feed of candidate profiles.
  • Swipe LIKE, NOPE, or SUPERLIKE on each profile.
  • A mutual LIKE / SUPERLIKE produces a single Match between the two users.
  • Match feed: list current matches, paginated, sorted by recent activity.
  • Block another user: stops them from appearing as a candidate and prevents new matches.
  • Daily quota on LIKE and SUPERLIKE; resets at user-local midnight.

Non-functional

  • Double-swipe race must never produce two Match rows.
  • Recommendation strategy must be swappable (geo, ML-ranked, popularity).
  • Swipe writes are write-heavy; reads of "have I swiped on X" must be O(1).

Class design#

classDiagram
  direction TB

  class User {
    +id : String
    +name : String
    +dob : LocalDate
    +location : LatLng
  }

  class Visibility {
    <<enumeration>>
    PUBLIC
    HIDDEN
    PAUSED
  }

  class Profile {
    +user : User
    +bio : String
    +photos : List~String~
    +interests : Set~String~
    +visibility : Visibility
  }

  class SwipeAction {
    <<enumeration>>
    LIKE
    NOPE
    SUPERLIKE
  }

  class Swipe {
    +id : String
    +swiper : User
    +swipee : User
    +action : SwipeAction
    +at : Instant
  }

  class Match {
    +id : String
    +userA : User
    +userB : User
    +createdAt : Instant
    +lastActiveAt : Instant
    +pairKey() String
  }

  class BlockList {
    +blocker : User
    +blocked : Set~User~
    +block(u)
    +unblock(u)
    +isBlocked(u) boolean
  }

  class DailyQuota {
    +user : User
    +date : LocalDate
    +likesRemaining : int
    +superLikesRemaining : int
    +consume(action) boolean
  }

  class RecommendationStrategy {
    <<interface>>
    +next(user : User, n : int) List~Profile~
  }
  class GeoRecommender
  class MLRecommender
  class PopularityRecommender

  class SwipeService {
    -swipes : SwipeRepo
    -matches : MatchRepo
    -blocks : BlockList
    -quotas : QuotaService
    -events : EventBus
    +swipe(swiper, swipee, action) Optional~Match~
  }

  class FeedService {
    -matches : MatchRepo
    +feed(user, cursor, limit) Page~Match~
  }

  class SwipeRepo {
    <<interface>>
    +save(s : Swipe)
    +find(swiper, swipee) Optional~Swipe~
  }
  class MatchRepo {
    <<interface>>
    +tryCreate(m : Match) Optional~Match~
    +findByUser(u, cursor, limit) Page~Match~
  }

  User *-- Profile
  Profile --> Visibility
  Swipe --> SwipeAction
  Swipe --> User
  Match --> User
  SwipeService ..> SwipeRepo
  SwipeService ..> MatchRepo
  SwipeService ..> BlockList
  SwipeService ..> RecommendationStrategy
  FeedService ..> MatchRepo
  RecommendationStrategy <|.. GeoRecommender
  RecommendationStrategy <|.. MLRecommender
  RecommendationStrategy <|.. PopularityRecommender

Match.pairKey() returns the ordered tuple min(userA.id, userB.id) + ":" + max(userA.id, userB.id). The database has a UNIQUE index on pair_key. That single constraint is what kills the double-swipe race.

Swipe to match flow#

sequenceDiagram
  actor A as Alice
  actor B as Bob
  participant S as SwipeService
  participant SR as SwipeRepo
  participant MR as MatchRepo
  participant E as EventBus

  A->>S: swipe(Alice, Bob, LIKE)
  S->>S: blocks.isBlocked(Bob, Alice)?
  S->>S: quota.consume(LIKE)
  S->>SR: save(Swipe(A, B, LIKE))
  S->>SR: find(B, A)
  alt Bob has already liked Alice
    SR-->>S: Swipe(B, A, LIKE)
    S->>MR: tryCreate(Match(A, B))
    MR-->>S: Match (or existing on conflict)
    S->>E: publish(MatchCreated)
    S-->>A: matched
  else not yet
    SR-->>S: none
    S-->>A: ok, no match yet
  end

The race condition#

Alice taps LIKE on Bob at the same instant Bob taps LIKE on Alice. Without care, both threads observe the other side's swipe and both try to insert a Match row. Two Match rows for the same pair is a data bug.

Three resolutions, pick one:

  1. UNIQUE constraint on pair_key: both threads attempt insert, the database guarantees exactly one succeeds; the loser catches the duplicate-key exception and returns the winner's row. Simplest and recommended.
  2. CAS on a synthetic match_key row: insert into a match_keys table first; whoever inserts wins. Same semantics as UNIQUE, more moving parts.
  3. Pessimistic lock on the pair: SELECT ... FOR UPDATE on pair_key. Higher latency, holds row locks.
sequenceDiagram
  participant TA as Thread A (Alice likes Bob)
  participant TB as Thread B (Bob likes Alice)
  participant DB as MatchRepo

  TA->>DB: insert pair_key=alice:bob
  TB->>DB: insert pair_key=alice:bob
  DB-->>TA: ok (row created)
  DB-->>TB: DuplicateKeyException
  Note over TB: select existing match by pair_key, return it

Code (Python)#

from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Optional, List

class SwipeAction(Enum):
    LIKE = "like"
    NOPE = "nope"
    SUPERLIKE = "superlike"

@dataclass(frozen=True)
class Swipe:
    swiper_id: str
    swipee_id: str
    action: SwipeAction
    at: datetime = field(default_factory=datetime.utcnow)

@dataclass
class Match:
    user_a: str
    user_b: str
    created_at: datetime = field(default_factory=datetime.utcnow)

    @property
    def pair_key(self) -> str:
        a, b = sorted([self.user_a, self.user_b])
        return f"{a}:{b}"

class DuplicateMatchError(Exception):
    pass

class SwipeService:
    def __init__(self, swipes, matches, blocks, quotas, events):
        self.swipes = swipes
        self.matches = matches
        self.blocks = blocks
        self.quotas = quotas
        self.events = events

    def swipe(self, swiper_id: str, swipee_id: str,
              action: SwipeAction) -> Optional[Match]:
        if swiper_id == swipee_id:
            raise ValueError("cannot swipe on self")
        if self.blocks.is_blocked(swiper_id, swipee_id):
            return None
        if action in (SwipeAction.LIKE, SwipeAction.SUPERLIKE):
            if not self.quotas.consume(swiper_id, action):
                raise QuotaExceeded(action)

        self.swipes.save(Swipe(swiper_id, swipee_id, action))

        if action == SwipeAction.NOPE:
            return None

        reciprocal = self.swipes.find(swipee_id, swiper_id)
        if reciprocal and reciprocal.action in (
            SwipeAction.LIKE, SwipeAction.SUPERLIKE):
            return self._create_match(swiper_id, swipee_id)
        return None

    def _create_match(self, a: str, b: str) -> Match:
        m = Match(user_a=a, user_b=b)
        try:
            created = self.matches.try_create(m)
            self.events.publish("MatchCreated", created)
            return created
        except DuplicateMatchError:
            # the other thread won the race, return its row
            return self.matches.find_by_pair_key(m.pair_key)

class RecommendationStrategy:
    def next(self, user_id: str, n: int) -> List[str]:
        raise NotImplementedError

class GeoRecommender(RecommendationStrategy):
    def __init__(self, profiles, blocks, swipes, radius_km=50):
        self.profiles = profiles
        self.blocks = blocks
        self.swipes = swipes
        self.radius_km = radius_km

    def next(self, user_id: str, n: int) -> List[str]:
        candidates = self.profiles.nearby(user_id, self.radius_km)
        seen = self.swipes.swiped_set(user_id)
        return [
            p.user_id for p in candidates
            if p.user_id not in seen
            and not self.blocks.is_blocked(user_id, p.user_id)
        ][:n]

Match.pair_key is the deduplication weapon. The repository raises DuplicateMatchError when the UNIQUE constraint fires; the service catches it and returns the existing row.

Concurrency#

  • Match creation: UNIQUE on pair_key. Simple, correct, no locks.
  • Reading "have I swiped on X?": cache the user's swiped set in Redis (SET data type). O(1) check, asynchronously rebuilt from the swipe log.
  • Quota counters: per-user counter in Redis with INCR / GET, reset by a scheduled job at user-local midnight. INCR is atomic.
  • Block writes: a block must invalidate any pending swipes from the blocked user against the blocker; do this on the read side instead of cascade-deleting (cheaper).

Edge cases#

  • Self swipe: reject in the service.
  • Swipe on a hidden / paused profile: drop silently (do not leak visibility state).
  • Block after match: existing match stays in the feed but is marked hidden; chat is disabled. (Tinder unmatches; either is defensible.)
  • Duplicate swipes from the same user: idempotent. Repository save is an UPSERT on (swiper, swipee).
  • Time zone for quota reset: store the user's IANA zone; the reset job filters by user offset.

Feed pagination#

flowchart LR
  C[Client] --> F[FeedService.feed user, cursor, limit]
  F --> Q[MatchRepo.findByUser]
  Q --> R[(matches table)]
  R --> Q
  Q --> F
  F --> C

    classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
    classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
    class C client;
    class F service;
    class Q service;
    class R datastore;

Cursor is (last_active_at, match_id) to give stable ordering even when two matches share a timestamp. Index: (user_id, last_active_at DESC, id).

Extensions#

  • Rewind / undo last swipe: track the last swipe per user separately; rewind deletes it and undoes any side-effects (only valid if no Match was produced).
  • Boost: temporarily inflate the user's recommendation rank for N minutes.
  • Read receipts in chat: separate Chat aggregate keyed by match_id.
  • ML re-ranking: MLRecommender calls a feature store and a ranker, falls back to GeoRecommender on timeout.

Where Tinder Match LLD fits#

flowchart TB
  TM((Tinder Match<br/>LLD))
  SM[State machines<br/>swipe and match lifecycle]
  BP[Behavioral patterns<br/>Strategy recommender]
  RP[Repository pattern<br/>SwipeRepo and MatchRepo]
  SM --> TM
  BP --> TM
  RP --> TM

    classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
    classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
    classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
    classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
    classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
    classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
    classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
    classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
    classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
    classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
    class TM service;
    class SM,BP,RP datastore;

Glossary & fundamentals#

Tag Concept What it is Page
LLD State machines swipe / match lifecycle state-machines
LLD Repository pattern Swipe and Match storage abstraction repository-pattern
LLD Behavioral patterns Strategy for recommendations behavioral-patterns

Quick reference#

Classes#

  • User, Profile (visibility: PUBLIC / HIDDEN / PAUSED)
  • Swipe (swiper, swipee, action, at) immutable
  • SwipeAction: LIKE, NOPE, SUPERLIKE
  • Match (userA, userB, createdAt, lastActiveAt, pairKey)
  • BlockList (per blocker, set of blocked)
  • DailyQuota (per user, per date)
  • RecommendationStrategy: GeoRecommender, MLRecommender, PopularityRecommender
  • SwipeRepo, MatchRepo (repository pattern)
  • SwipeService, FeedService
  • EventBus (MatchCreated)

Match rule#

Mutual LIKE or SUPERLIKE creates exactly one Match. pair_key = min(idA, idB) + ":" + max(idA, idB). UNIQUE index on pair_key.

The race#

Both users LIKE at the same instant. Both threads see the reciprocal swipe, both insert Match. The UNIQUE constraint guarantees one winner; the loser catches DuplicateKeyException and returns the existing row.

Concurrency cheats#

  • UNIQUE on pair_key for match creation (preferred over locking).
  • Redis SET for "have I swiped on X" lookups; O(1).
  • INCR / GET for quota counters; atomic.
  • Per-user quota reset at user-local midnight via scheduled job.

Blocks#

  • Read-side filter: drop blocked users from the recommender and short-circuit swipes.
  • No cascading deletes; existing match flagged hidden (or unmatched, both defensible).

Feed pagination#

Cursor = (last_active_at, match_id). Index (user_id, last_active_at DESC, id).

Edge cases#

  • Self swipe: reject.
  • Hidden/paused profile target: silent drop.
  • Duplicate swipe: UPSERT, idempotent.
  • Rewind: only if no match was produced.

Interview tricks#

  • Drop the pair_key + UNIQUE trick early; interviewer will probe concurrency.
  • Call out Strategy for recommender.
  • Call out Repository to keep storage swappable.
  • Use immutable Swipe records; the Match is the only mutable aggregate.

Complexity#

  • Swipe write: O(1) plus reciprocal lookup O(1) via cache.
  • Match create: O(1) insert plus index check.
  • Feed fetch: O(limit) with cursor; index-driven.

Refs#

  • LeetCode-style writeups on Tinder LLD
  • Tinder engineering blog: recommendation system
  • Designing Data-Intensive Applications (Kleppmann): uniqueness and consensus

FAQ#

How do you detect a mutual like?#

On every LIKE, check if the reciprocal LIKE already exists. If yes, atomically create a Match row keyed by the ordered pair (min_user, max_user). A unique constraint prevents duplicates.

How is the match uniqueness enforced under concurrency?#

Store the pair as (userA, userB) where userA is the smaller id. A unique constraint plus INSERT IGNORE or ON CONFLICT DO NOTHING ensures exactly one Match even if both users swipe at the same instant.

How is the match feed paginated?#

Cursor-based pagination on Match.created_at descending, with a tie-breaker on match id. Cursors avoid the offset drift that page-numbered pagination suffers when new matches arrive.

How does a block list work?#

BlockList is a directed edge (blocker, blocked). The recommendation engine filters out blocked pairs both ways before serving cards, and existing matches are hidden but not deleted.

What is the role of the recommendation strategy?#

A pluggable Recommender picks candidate profiles per user based on distance, age, gender preferences, and behaviour signals. Strategy pattern lets you A/B test different ranking algorithms.