Skip to content
Unverified — AI-generated content. Help verify this page

Design Tinder — Dating App with Geospatial Matching

1. Problem Statement & Requirements

Functional Requirements

#RequirementDetails
FR-1Profile CreationPhotos, bio, age, preferences (gender, age range, distance)
FR-2Swipe/DiscoveryShow nearby profiles; swipe right (like), left (pass), up (super like)
FR-3MatchingWhen two users both swipe right, create a match
FR-4ChatReal-time messaging between matched users
FR-5Geospatial FilteringFind users within a configurable radius (1-160 km)
FR-6Profile VerificationPhoto verification to prevent catfishing
FR-7Boost/PremiumPaid features: see who liked you, boost profile, unlimited swipes
FR-8UndoUndo last swipe (premium feature)
FR-9Location UpdateUpdate user location periodically
FR-10Block & ReportBlock users, report inappropriate content

Non-Functional Requirements

#RequirementTarget
NFR-1Availability99.99% uptime
NFR-2LatencyCard stack load < 200ms
NFR-3Throughput2B+ swipes per day
NFR-4ConsistencyStrong consistency for matching, eventual for profiles
NFR-5PrivacyGDPR compliance, location obfuscation
NFR-6Scale75M MAU, 10M DAU

2. Back-of-Envelope Estimation

User Scale

MAU=75MDAU=10MPeak Concurrent=3M

Swipe Volume

Avg Swipes per DAU=200Daily Swipes=10M×200=2BSwipe QPS=2B86,40023,000 swipes/sPeak Swipe QPS23,000×3=69,000 swipes/s

Match Rate

Right Swipe Rate30%Match Rate (mutual right swipe)5% of right swipesDaily Matches=2B×0.30×0.05=30M matches/day

Storage Estimation

User Profiles:

Profile Size=5 KB metadata+6 photos×500 KB=3 MBTotal Profile Storage=75M×3 MB=225 TB

Swipe History:

Swipe Record=32 bytes (2 UUIDs + timestamp + action)Daily Swipe Storage=2B×32 B=64 GB/dayAnnual Swipe Storage=64×36523 TB/year

Geospatial Query Volume

Location Updates=10M×10 per day=100M updates/dayDiscovery Queries=10M×20 sessions=200M geo queries/dayGeo QPS=200M86,4002,300 req/s

3. High-Level Design

Architecture Diagram

API Design

typescript
// Profile APIs
POST   /api/v1/profiles
PUT    /api/v1/profiles/{userId}
POST   /api/v1/profiles/{userId}/photos
DELETE /api/v1/profiles/{userId}/photos/{photoId}
POST   /api/v1/profiles/{userId}/verify

// Discovery APIs
GET    /api/v1/discovery/recommendations?limit=20
POST   /api/v1/discovery/location
       // Body: { latitude, longitude }

// Swipe APIs
POST   /api/v1/swipes
       // Body: { targetUserId, action: "like"|"pass"|"super_like" }
POST   /api/v1/swipes/undo

// Match APIs
GET    /api/v1/matches
GET    /api/v1/matches/{matchId}
DELETE /api/v1/matches/{matchId}  // Unmatch

// Chat APIs
GET    /api/v1/matches/{matchId}/messages?before={cursor}
POST   /api/v1/matches/{matchId}/messages
       // Body: { text, mediaUrl? }
WS     /api/v1/ws/chat  // WebSocket for real-time messages

// Moderation APIs
POST   /api/v1/reports
       // Body: { reportedUserId, reason, details }
POST   /api/v1/blocks/{userId}

4. Database Schema

Users Table (PostgreSQL)

sql
CREATE TABLE users (
    user_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    phone_number    VARCHAR(20) UNIQUE,
    email           VARCHAR(255),
    name            VARCHAR(100) NOT NULL,
    birth_date      DATE NOT NULL,
    gender          VARCHAR(20) NOT NULL,
    bio             TEXT,
    job_title       VARCHAR(100),
    company         VARCHAR(100),
    school          VARCHAR(200),
    verified        BOOLEAN DEFAULT FALSE,
    subscription    VARCHAR(20) DEFAULT 'free', -- free, plus, gold, platinum
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    last_active     TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_users_last_active ON users(last_active DESC);

User Preferences Table

sql
CREATE TABLE user_preferences (
    user_id         UUID PRIMARY KEY REFERENCES users(user_id),
    interested_in   VARCHAR(20)[] NOT NULL, -- ['male', 'female', 'non-binary']
    age_min         SMALLINT DEFAULT 18,
    age_max         SMALLINT DEFAULT 100,
    distance_km     SMALLINT DEFAULT 50,
    show_me         BOOLEAN DEFAULT TRUE,
    global_mode     BOOLEAN DEFAULT FALSE
);

User Locations (Redis Geo)

-- Redis GEO commands for location storage
GEOADD user_locations {longitude} {latitude} {userId}
GEORADIUS user_locations {lng} {lat} {radius} km COUNT 200 ASC

Photos Table

sql
CREATE TABLE photos (
    photo_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL REFERENCES users(user_id),
    url             VARCHAR(500) NOT NULL,
    position        SMALLINT NOT NULL, -- 0 = primary
    is_verified     BOOLEAN DEFAULT FALSE,
    moderation_status VARCHAR(20) DEFAULT 'pending',
    width           INT,
    height          INT,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_photos_user ON photos(user_id, position);

Swipes Table (Cassandra)

sql
CREATE TABLE swipes (
    swiper_id   UUID,
    swiped_id   UUID,
    action      TEXT,       -- 'like', 'pass', 'super_like'
    swiped_at   TIMESTAMP,
    PRIMARY KEY ((swiper_id), swiped_id)
);

-- Reverse index for "who liked me" feature
CREATE TABLE incoming_likes (
    target_id   UUID,
    liker_id    UUID,
    liked_at    TIMESTAMP,
    PRIMARY KEY ((target_id), liked_at)
) WITH CLUSTERING ORDER BY (liked_at DESC);

Matches Table (PostgreSQL)

sql
CREATE TABLE matches (
    match_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id_1       UUID NOT NULL REFERENCES users(user_id),
    user_id_2       UUID NOT NULL REFERENCES users(user_id),
    matched_at      TIMESTAMPTZ DEFAULT NOW(),
    unmatched_at    TIMESTAMPTZ,
    last_message_at TIMESTAMPTZ,
    status          VARCHAR(20) DEFAULT 'active',
    CONSTRAINT unique_match UNIQUE (user_id_1, user_id_2),
    CONSTRAINT ordered_ids CHECK (user_id_1 < user_id_2)
);

CREATE INDEX idx_matches_user1 ON matches(user_id_1, status) WHERE status = 'active';
CREATE INDEX idx_matches_user2 ON matches(user_id_2, status) WHERE status = 'active';

Messages Table (Cassandra)

sql
CREATE TABLE messages (
    match_id    UUID,
    message_id  TIMEUUID,
    sender_id   UUID,
    content     TEXT,
    media_url   TEXT,
    read_at     TIMESTAMP,
    PRIMARY KEY ((match_id), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

5. Detailed Component Design

5.1 Geospatial Matching with Geohash

The core challenge is efficiently finding nearby users within a configurable radius.

Geohash encoding:

Geohash maps (lat,lng)base32 string
Geohash LengthCell SizeUse Case
4~39 km x 19.5 kmCoarse filtering
5~5 km x 5 kmCity-level
6~1.2 km x 0.6 kmNeighborhood-level
7~150 m x 150 mBlock-level

Redis GEO implementation:

typescript
class LocationService {
  private redis: Redis;

  async updateLocation(userId: string, lat: number, lng: number): Promise<void> {
    // Store in Redis GEO set
    await this.redis.geoadd('user_locations', lng, lat, userId);

    // Also store in gender-specific sets for faster filtering
    const user = await this.getUser(userId);
    await this.redis.geoadd(`user_locations:${user.gender}`, lng, lat, userId);

    // Update last-active timestamp
    await this.redis.zadd('user_activity', Date.now(), userId);

    // Publish location update event
    await this.kafka.publish('user.location.updated', { userId, lat, lng });
  }

  async findNearbyUsers(
    userId: string,
    lat: number,
    lng: number,
    radiusKm: number,
    preferences: UserPreferences
  ): Promise<NearbyUser[]> {
    // Query Redis GEO for each preferred gender
    const nearbyUserIds: string[] = [];

    for (const gender of preferences.interested_in) {
      const results = await this.redis.georadius(
        `user_locations:${gender}`,
        lng, lat,
        radiusKm, 'km',
        'WITHCOORD', 'WITHDIST',
        'COUNT', 500,
        'ASC' // Sort by distance
      );
      nearbyUserIds.push(...results.map(r => r.member));
    }

    return nearbyUserIds.map(id => ({
      userId: id,
      distance: this.getDistance(id),
    }));
  }
}

Geohash-based sharding for scale:

typescript
// Shard user locations by geohash prefix
class GeoShardedLocationService {
  private readonly SHARD_GEOHASH_PRECISION = 3; // ~156km cells

  getShardKey(lat: number, lng: number): string {
    const geohash = this.encodeGeohash(lat, lng, this.SHARD_GEOHASH_PRECISION);
    return `geo_shard:${geohash}`;
  }

  async findNearby(lat: number, lng: number, radiusKm: number): Promise<string[]> {
    // Get all geohash cells that overlap with the search radius
    const centerHash = this.encodeGeohash(lat, lng, this.SHARD_GEOHASH_PRECISION);
    const neighborHashes = this.getNeighborGeohashes(centerHash);
    const cellsToQuery = [centerHash, ...neighborHashes];

    // Query each shard in parallel
    const results = await Promise.all(
      cellsToQuery.map(hash =>
        this.queryShard(`geo_shard:${hash}`, lat, lng, radiusKm)
      )
    );

    return results.flat();
  }

  private encodeGeohash(lat: number, lng: number, precision: number): string {
    const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';
    let hash = '';
    let minLat = -90, maxLat = 90;
    let minLng = -180, maxLng = 180;
    let isLng = true;
    let bits = 0;
    let charIndex = 0;

    while (hash.length < precision) {
      if (isLng) {
        const mid = (minLng + maxLng) / 2;
        if (lng > mid) {
          charIndex = charIndex * 2 + 1;
          minLng = mid;
        } else {
          charIndex = charIndex * 2;
          maxLng = mid;
        }
      } else {
        const mid = (minLat + maxLat) / 2;
        if (lat > mid) {
          charIndex = charIndex * 2 + 1;
          minLat = mid;
        } else {
          charIndex = charIndex * 2;
          maxLat = mid;
        }
      }

      isLng = !isLng;
      bits++;

      if (bits === 5) {
        hash += BASE32[charIndex];
        bits = 0;
        charIndex = 0;
      }
    }

    return hash;
  }
}

5.2 Recommendation Algorithm

Tinder's recommendation engine determines the order of cards shown.

Elo-like scoring system:

New RatingA=Old RatingA+K×(SE)

Where:

  • S = actual outcome (1 for right swipe received, 0 for left swipe)
  • E = expected outcome based on rating difference
  • K = adjustment factor (higher for new users)
E=11+10(RatingBRatingA)/400
typescript
class RecommendationEngine {
  async getRecommendations(userId: string, limit: number = 20): Promise<Profile[]> {
    const user = await this.getUser(userId);
    const preferences = await this.getPreferences(userId);
    const location = await this.getLocation(userId);

    // Phase 1: Candidate generation (fast, rough filtering)
    let candidates = await this.locationService.findNearby(
      location.lat, location.lng, preferences.distance_km
    );

    // Phase 2: Preference filtering
    candidates = await this.filterByPreferences(candidates, preferences);

    // Phase 3: Remove already-swiped users (Bloom filter for speed)
    candidates = await this.filterAlreadySwiped(userId, candidates);

    // Phase 4: Score and rank
    const scored = await Promise.all(
      candidates.map(async (candidateId) => {
        const score = await this.computeScore(userId, candidateId);
        return { userId: candidateId, score };
      })
    );

    // Phase 5: Sort, apply diversity, return top N
    scored.sort((a, b) => b.score - a.score);
    const diversified = this.applyDiversity(scored);

    return this.loadProfiles(diversified.slice(0, limit));
  }

  private async computeScore(userId: string, candidateId: string): Promise<number> {
    const userRating = await this.getRating(userId);
    const candidateRating = await this.getRating(candidateId);

    // Components
    const eloScore = this.normalizeRating(candidateRating);
    const activityScore = await this.getActivityScore(candidateId);
    const freshnessScore = await this.getFreshnessScore(candidateId);
    const mutualScore = await this.getMutualInterestScore(userId, candidateId);

    // Weighted combination
    return (
      0.30 * eloScore +
      0.25 * activityScore +
      0.20 * freshnessScore +
      0.15 * mutualScore +
      0.10 * Math.random() // Small random factor for exploration
    );
  }

  // Bloom filter to efficiently check if user already swiped
  private async filterAlreadySwiped(
    userId: string,
    candidates: string[]
  ): Promise<string[]> {
    const bloomFilter = await this.getSwipeBloomFilter(userId);
    return candidates.filter(c => !bloomFilter.mightContain(c));
  }

  // Ensure diversity in card stack
  private applyDiversity(scored: ScoredCandidate[]): ScoredCandidate[] {
    const result: ScoredCandidate[] = [];
    const recentTypes = new Map<string, number>();

    for (const candidate of scored) {
      const type = candidate.profileType; // e.g., verified, premium, new
      const recentCount = recentTypes.get(type) ?? 0;

      // Penalize if too many of the same type in a row
      if (recentCount < 3) {
        result.push(candidate);
        recentTypes.set(type, recentCount + 1);
      }

      if (result.length >= scored.length) break;
    }

    return result;
  }
}

5.3 Swipe Mechanics & Matching

typescript
class SwipeService {
  async recordSwipe(
    swiperId: string,
    targetId: string,
    action: 'like' | 'pass' | 'super_like'
  ): Promise<SwipeResult> {
    // Rate limiting: max 100 right swipes per day for free users
    if (action !== 'pass') {
      const dailyLikes = await this.getDailyLikeCount(swiperId);
      const subscription = await this.getSubscription(swiperId);
      if (subscription === 'free' && dailyLikes >= 100) {
        throw new SwipeLimitExceededError();
      }
    }

    // Store the swipe
    await this.cassandra.execute(
      'INSERT INTO swipes (swiper_id, swiped_id, action, swiped_at) VALUES (?, ?, ?, ?)',
      [swiperId, targetId, action, new Date()]
    );

    // Update Elo ratings
    await this.updateRatings(swiperId, targetId, action);

    // Check for match (only if like or super_like)
    if (action === 'like' || action === 'super_like') {
      // Store in Redis for fast reverse lookup
      await this.redis.sadd(`likes:${targetId}`, swiperId);

      // Store in incoming_likes table
      await this.cassandra.execute(
        'INSERT INTO incoming_likes (target_id, liker_id, liked_at) VALUES (?, ?, ?)',
        [targetId, swiperId, new Date()]
      );

      // Check if target already liked swiper
      const isMatch = await this.redis.sismember(`likes:${swiperId}`, targetId);

      if (isMatch) {
        return this.createMatch(swiperId, targetId, action === 'super_like');
      }
    }

    return { matched: false };
  }

  private async createMatch(
    userId1: string,
    userId2: string,
    isSuperLike: boolean
  ): Promise<SwipeResult> {
    // Ensure consistent ordering
    const [first, second] = userId1 < userId2
      ? [userId1, userId2]
      : [userId2, userId1];

    const matchId = crypto.randomUUID();

    await this.postgres.query(
      `INSERT INTO matches (match_id, user_id_1, user_id_2, matched_at)
       VALUES ($1, $2, $3, NOW())
       ON CONFLICT DO NOTHING`,
      [matchId, first, second]
    );

    // Notify both users
    await this.notificationService.sendMatchNotification(userId1, userId2, isSuperLike);

    // Clean up likes from Redis
    await this.redis.srem(`likes:${userId1}`, userId2);
    await this.redis.srem(`likes:${userId2}`, userId1);

    // Publish event for analytics
    await this.kafka.publish('match.created', {
      matchId, userId1, userId2, isSuperLike,
    });

    return { matched: true, matchId };
  }

  private async updateRatings(
    swiperId: string,
    targetId: string,
    action: string
  ): Promise<void> {
    const swiperRating = await this.getRating(swiperId);
    const targetRating = await this.getRating(targetId);

    const expected = 1 / (1 + Math.pow(10, (swiperRating - targetRating) / 400));
    const actual = action === 'pass' ? 0 : 1;
    const K = 32;

    const newTargetRating = targetRating + K * (actual - expected);
    await this.setRating(targetId, newTargetRating);
  }
}

5.4 Real-Time Chat

typescript
class ChatService {
  private connections: Map<string, WebSocket> = new Map();

  handleConnection(ws: WebSocket, userId: string): void {
    this.connections.set(userId, ws);
    this.presenceService.setOnline(userId);

    ws.on('message', (data: string) => {
      const message = JSON.parse(data);
      this.handleMessage(userId, message);
    });

    ws.on('close', () => {
      this.connections.delete(userId);
      this.presenceService.setOffline(userId);
    });
  }

  async handleMessage(senderId: string, payload: ChatPayload): Promise<void> {
    switch (payload.type) {
      case 'text_message':
        await this.sendTextMessage(senderId, payload.matchId, payload.text);
        break;
      case 'typing_start':
        await this.broadcastTyping(senderId, payload.matchId, true);
        break;
      case 'typing_stop':
        await this.broadcastTyping(senderId, payload.matchId, false);
        break;
      case 'read_receipt':
        await this.markAsRead(senderId, payload.matchId, payload.messageId);
        break;
    }
  }

  private async sendTextMessage(
    senderId: string,
    matchId: string,
    text: string
  ): Promise<void> {
    // Validate the match exists and sender is a participant
    const match = await this.validateMatch(matchId, senderId);
    const recipientId = match.user_id_1 === senderId ? match.user_id_2 : match.user_id_1;

    // Content moderation check
    const modResult = await this.moderationService.checkMessage(text);
    if (modResult.blocked) {
      throw new ContentPolicyViolation(modResult.reason);
    }

    const messageId = TimeUuid.now();

    // Store in Cassandra
    await this.cassandra.execute(
      'INSERT INTO messages (match_id, message_id, sender_id, content) VALUES (?, ?, ?, ?)',
      [matchId, messageId, senderId, text]
    );

    // Update last_message_at on match
    await this.postgres.query(
      'UPDATE matches SET last_message_at = NOW() WHERE match_id = $1',
      [matchId]
    );

    // Deliver to recipient
    const recipientWs = this.connections.get(recipientId);
    if (recipientWs) {
      recipientWs.send(JSON.stringify({
        type: 'new_message',
        matchId,
        messageId: messageId.toString(),
        senderId,
        text,
        timestamp: new Date().toISOString(),
      }));
    } else {
      // User offline - send push notification
      await this.notificationService.sendPush(recipientId, {
        title: 'New message',
        body: text.substring(0, 100),
        data: { matchId },
      });
    }
  }
}

5.5 Profile Verification

typescript
class VerificationService {
  async verifyUser(userId: string, selfieBuffer: Buffer): Promise<VerificationResult> {
    // Get user's profile photos
    const profilePhotos = await this.getProfilePhotos(userId);

    // Step 1: Face detection in selfie
    const selfiFace = await this.faceDetector.detect(selfieBuffer);
    if (!selfiFace) {
      return { status: 'rejected', reason: 'No face detected in selfie' };
    }

    // Step 2: Liveness check (anti-spoofing)
    const livenessScore = await this.livenessDetector.check(selfieBuffer);
    if (livenessScore < 0.8) {
      return { status: 'rejected', reason: 'Liveness check failed' };
    }

    // Step 3: Pose matching
    const poseScore = await this.poseChecker.compare(
      selfiFace.landmarks,
      this.currentPoseTemplate
    );

    // Step 4: Face matching against profile photos
    const selfieEmbedding = await this.faceEncoder.encode(selfiFace);
    let bestMatchScore = 0;

    for (const photo of profilePhotos) {
      const photoFace = await this.faceDetector.detect(photo.buffer);
      if (!photoFace) continue;

      const photoEmbedding = await this.faceEncoder.encode(photoFace);
      const similarity = this.cosineSimilarity(selfieEmbedding, photoEmbedding);
      bestMatchScore = Math.max(bestMatchScore, similarity);
    }

    // Step 5: Combined score
    const combinedScore = 0.4 * bestMatchScore + 0.3 * poseScore + 0.3 * livenessScore;

    if (combinedScore > 0.95) {
      await this.markVerified(userId);
      return { status: 'verified', confidence: combinedScore };
    } else if (combinedScore > 0.7) {
      await this.queueForHumanReview(userId, selfieBuffer, combinedScore);
      return { status: 'pending_review', confidence: combinedScore };
    } else {
      return { status: 'rejected', reason: 'Face does not match profile photos' };
    }
  }
}

5.6 Abuse Prevention

typescript
class AbusePreventionService {
  // Detect inappropriate content in photos
  async moderatePhoto(photoBuffer: Buffer): Promise<ModerationResult> {
    const analysis = await this.nsfwDetector.analyze(photoBuffer);

    if (analysis.nsfw > 0.9) {
      return { action: 'reject', reason: 'explicit_content' };
    }
    if (analysis.nsfw > 0.7) {
      return { action: 'human_review', reason: 'possible_nsfw' };
    }
    return { action: 'approve' };
  }

  // Detect spam/scam patterns in messages
  async moderateMessage(text: string, senderId: string): Promise<ModerationResult> {
    // Check for known scam patterns
    const scamScore = await this.scamDetector.score(text);
    if (scamScore > 0.8) {
      await this.flagUser(senderId, 'potential_scam');
      return { action: 'block', reason: 'scam_detected' };
    }

    // Check message velocity (spam detection)
    const recentMessages = await this.getMessageCount(senderId, 60); // Last 60 seconds
    if (recentMessages > 20) {
      return { action: 'rate_limit', reason: 'message_spam' };
    }

    // Check for external links (common in scams)
    if (this.containsUrl(text) && !await this.isUrlSafe(text)) {
      return { action: 'block', reason: 'unsafe_url' };
    }

    return { action: 'allow' };
  }

  // Behavioral analysis for fake profiles
  async detectFakeProfile(userId: string): Promise<FakeProfileAssessment> {
    const signals = {
      photoAnalysis: await this.analyzeProfilePhotos(userId),
      behaviorPattern: await this.analyzeBehavior(userId),
      reportCount: await this.getReportCount(userId),
      accountAge: await this.getAccountAge(userId),
      verificationStatus: await this.isVerified(userId),
    };

    // ML model for fake detection
    const fakeScore = await this.fakeDetectorModel.predict(signals);

    if (fakeScore > 0.9) {
      await this.shadowBan(userId); // User can still use app but is not shown to others
      return { isFake: true, confidence: fakeScore, action: 'shadow_ban' };
    }

    return { isFake: false, confidence: fakeScore };
  }
}

6. Scaling & Bottlenecks

What Breaks First

ComponentBottleneckSolution
Geo QueriesRedis single-node memory limitShard by geohash prefix
Swipe History2B writes/dayCassandra with time-bucketed partitions
Bloom FiltersMemory per user (already-swiped set)Redis Bloom filters with TTL
Match CheckingEvery right swipe = 1 lookupRedis SET with O(1) SISMEMBER
Card StackComputing recommendationsPre-compute and cache card stacks
Photos225 TB, high read throughputCDN with aggressive caching
WebSocket3M concurrent connectionsHorizontal WS gateway fleet

Pre-Computing Card Stacks

typescript
class CardStackPrecomputer {
  // Run periodically (every 30 minutes) for active users
  async precompute(userId: string): Promise<void> {
    const recommendations = await this.recoEngine.getRecommendations(userId, 100);
    const profiles = await this.loadProfiles(recommendations);

    // Cache the pre-computed stack
    await this.redis.set(
      `card_stack:${userId}`,
      JSON.stringify(profiles),
      'EX', 1800 // 30 min TTL
    );
  }

  // When user opens app, serve cached stack
  async getCardStack(userId: string): Promise<Profile[]> {
    const cached = await this.redis.get(`card_stack:${userId}`);
    if (cached) return JSON.parse(cached);

    // Cache miss: compute on the fly (slower)
    return this.recoEngine.getRecommendations(userId, 20);
  }
}

Handling Location Updates Efficiently

typescript
class BatchedLocationUpdater {
  private buffer: Map<string, { lat: number; lng: number }> = new Map();
  private readonly FLUSH_INTERVAL = 5000; // 5 seconds

  constructor() {
    setInterval(() => this.flush(), this.FLUSH_INTERVAL);
  }

  enqueue(userId: string, lat: number, lng: number): void {
    this.buffer.set(userId, { lat, lng }); // Deduplicate: keep latest
  }

  private async flush(): Promise<void> {
    const batch = new Map(this.buffer);
    this.buffer.clear();

    // Pipeline Redis commands for efficiency
    const pipeline = this.redis.pipeline();
    for (const [userId, loc] of batch) {
      pipeline.geoadd('user_locations', loc.lng, loc.lat, userId);
    }
    await pipeline.exec();
  }
}

7. Trade-offs & Alternatives

Geospatial Index Options

ApproachProConBest For
Redis GEOFast, simple, in-memoryMemory cost, single-threadedHot data, < 100M users
PostGISRich queries, ACIDSlower than RedisComplex spatial queries
ElasticsearchFull-text + geoHeavier, more latencyCombined search + geo
Custom GeohashShardable, controllableMore code to maintainMassive scale (1B+ users)

Matching Algorithm

ApproachProCon
Elo RatingSimple, well-understoodCan create echo chambers
ML-basedMore nuanced matchingBlack box, harder to debug
Mutual interestBetter matchesSlower to surface new users
Random with filtersMaximum discoveryPoor match quality

Message Storage

ApproachProCon
CassandraHigh write throughput, time-series optimizedNo full-text search
MongoDBFlexible schema, decent readsWrite contention at scale
DynamoDBManaged, auto-scalingCost at high volume

8. Advanced Topics

8.1 Location Privacy

typescript
class LocationPrivacyManager {
  // Obfuscate location to protect user privacy
  obfuscateLocation(lat: number, lng: number): { lat: number; lng: number } {
    // Add random noise within 500m radius
    const noiseRadius = 0.005; // ~500m in degrees
    const angle = Math.random() * 2 * Math.PI;
    const distance = Math.random() * noiseRadius;

    return {
      lat: lat + distance * Math.cos(angle),
      lng: lng + distance * Math.sin(angle),
    };
  }

  // Show approximate distance, not exact
  formatDistance(distanceKm: number): string {
    if (distanceKm < 1) return 'Less than 1 km away';
    if (distanceKm < 5) return 'About 2 km away';
    if (distanceKm < 10) return `About ${Math.round(distanceKm)} km away`;
    return `${Math.round(distanceKm / 5) * 5} km away`; // Round to nearest 5
  }
}

8.2 Smart Photos (Auto-Reorder)

Tinder's Smart Photos feature automatically reorders a user's photos to put the best-performing photo first.

Photo Scorei=Right Swipes when Photoi is firstTotal Impressions when Photoi is first
typescript
class SmartPhotos {
  async reorderPhotos(userId: string): Promise<void> {
    const photos = await this.getPhotos(userId);
    const photoStats = await this.getPhotoPerformance(userId);

    // Multi-armed bandit: Thompson Sampling
    const ranked = photos.map(photo => {
      const stats = photoStats.get(photo.id) ?? { impressions: 0, rightSwipes: 0 };
      const alpha = stats.rightSwipes + 1;
      const beta = stats.impressions - stats.rightSwipes + 1;
      const sample = this.betaSample(alpha, beta);
      return { photo, sample };
    });

    ranked.sort((a, b) => b.sample - a.sample);

    // Update photo positions
    for (let i = 0; i < ranked.length; i++) {
      await this.updatePhotoPosition(ranked[i].photo.id, i);
    }
  }
}

8.3 Passport Feature (Global Mode)

Users can "teleport" to a different location to discover users in another city.

8.4 Group Activities (Tinder Social)

Enabling group matching where groups of friends can match with other groups.


9. Interview Tips

Key Points to Emphasize

  1. Geospatial indexing is the core challenge — Explain Redis GEO or geohash-based approaches.
  2. Matching must be real-time — The check-for-match on every right swipe must be O(1).
  3. Bloom filters for "already swiped" — Efficiently track billions of swipe pairs.
  4. Privacy is critical — Location obfuscation, data deletion (GDPR), content moderation.
  5. Scalable WebSocket layer — Millions of concurrent connections for chat.

Common Mistakes

  • Using SQL for swipe history — 2B writes/day demands a write-optimized store like Cassandra.
  • Doing real-time recommendation computation — Pre-compute card stacks for active users.
  • Ignoring the "already swiped" filter — Without it, users see the same profiles repeatedly.
  • Not discussing abuse prevention — It is a critical part of any social/dating platform.
  • Forgetting about the bidirectional nature of matching — Both users must like each other.

Follow-Up Questions to Expect

  • How would you handle the "New User Boost" (showing new profiles more prominently)?
  • How would you implement "Super Like" differently from a regular like?
  • How would you design the "Top Picks" daily curated list?
  • How would you handle cross-timezone matching for the Passport feature?
  • How does the system handle gender-specific load imbalance (often unequal M/F ratios)?

Time Allocation in 45-min Interview

PhaseTimeFocus
Requirements5 minClarify features, scale, privacy requirements
High-Level Design8 minArchitecture diagram, core services
Deep Dive: Geo Matching10 minGeohash, Redis GEO, candidate generation
Deep Dive: Swipe & Match8 minBloom filter, bidirectional check, Elo rating
Deep Dive: Chat7 minWebSocket, message delivery, moderation
Scaling5 minPre-computed stacks, sharding strategy
Q&A2 minTrade-offs, privacy, abuse

"What I cannot create, I do not understand." — Richard Feynman