system-design45 min read

How to Design a Social Network & Dating Platform - Senior+ Guide | Ayodhyya

How to Design a Social Network & Dating Platform

A Senior+ Guide to Building Tinder, Hinge & Bumble at Scale

10,000+ Words 30+ Sections System Design Deep-Dive Ayodhyya

1. System Overview and Requirements

A social network and dating platform connects millions of users who want to discover, evaluate, and communicate with potential matches. Unlike general social networks, dating platforms have unique requirements around mutual consent (two people must both express interest before messaging is allowed), ephemeral discovery (profiles are shown in a feed, not searched), and high privacy (location, personal info, and conversations must be tightly controlled).

Functional Requirements

  • Profile Creation: Users upload photos, write bios, answer prompts, and list interests.
  • Discovery Feed: Algorithmically curated profiles shown one at a time for swiping.
  • Swipe Mechanics: Like, Pass, and Super Like actions.
  • Match System: When two users both Like each other, a mutual match is created.
  • Real-Time Chat: Matched users can exchange messages with typing indicators, read receipts, and media sharing.
  • Location-Based Discovery: Show profiles within a configurable distance radius.
  • Preference Filters: Age range, distance, interests, height, education, and more.
  • Verification: Photo verification (selfie matching) and optional ID verification.
  • Premium Features: Boost (increase visibility), Super Like limits, Who Liked You, unlimited swipes.
  • Video Dating: In-app video calls for matched users.
  • Activity Status: Show when a user was last active.
  • Reporting and Moderation: Report users, auto-detect inappropriate content, ban enforcement.
  • Push Notifications: New matches, messages, likes, and promotional notifications.

Non-Functional Requirements

RequirementTarget
Latency (feed load)Less than 200ms p99
Latency (swipe action)Less than 100ms p99
Latency (chat message delivery)Less than 150ms p99
Availability99.95%
Throughput50K swipes/sec peak, 5K messages/sec
Data durability99.999999999% (11 nines)
Storage (photos)~500TB growing 15%/year
Concurrent users10M DAU, 2M simultaneous
Key Insight: The dating platform core challenge is the matching marketplace. Unlike content feeds (Instagram, TikTok), the content here is other people. Every decision (like, pass) affects both the swiper experience and the swiped user visibility. The algorithm must balance relevance, fairness, and business objectives simultaneously.

2. High-Level Architecture

graph TB subgraph Client iOS[iOS App] Android[Android App] Web[Web App] end subgraph Edge Layer CDN[CDN / CloudFront] WAF[WAF] LB[Load Balancer] end subgraph API Gateway GW[API Gateway / Kong] Auth[Auth Service] RateLimit[Rate Limiter] end subgraph Core Services ProfileSvc[Profile Service] DiscoverySvc[Discovery / Feed Service] MatchSvc[Match Service] ChatSvc[Chat Service] SwipeSvc[Swipe Service] SearchSvc[Search / Geo Service] MediaSvc[Media Service] NotifSvc[Notification Service] ModerationSvc[Moderation Service] PremiumSvc[Premium / Billing Service] AnalyticsSvc[Analytics Service] end subgraph Data Layer PG[(PostgreSQL)] Redis[(Redis Cluster)] ES[(Elasticsearch)] S3[(S3 / Blob Storage)] Kafka[Kafka] Neo4j[(Neo4j Graph)] DynamoDB[(DynamoDB)] end subgraph ML Pipeline RecEngine[Recommendation Engine] EloSvc[Elo Rating Service] NLP[NLP / Text Moderation] Vision[Computer Vision] end iOS --> CDN Android --> CDN Web --> CDN CDN --> WAF --> LB --> GW GW --> Auth GW --> RateLimit GW --> ProfileSvc GW --> DiscoverySvc GW --> MatchSvc GW --> ChatSvc GW --> SwipeSvc GW --> SearchSvc GW --> MediaSvc GW --> PremiumSvc ProfileSvc --> PG ProfileSvc --> S3 DiscoverySvc --> Redis DiscoverySvc --> Neo4j MatchSvc --> PG MatchSvc --> Kafka ChatSvc --> DynamoDB ChatSvc --> Redis SwipeSvc --> Kafka SwipeSvc --> Redis SearchSvc --> ES MediaSvc --> S3 MediaSvc --> Vision NotifSvc --> Kafka ModerationSvc --> NLP ModerationSvc --> Vision AnalyticsSvc --> Kafka EloSvc --> Redis RecEngine --> Neo4j RecEngine --> PG

Service Responsibilities

ServiceResponsibilityTechnology
Profile ServiceCRUD for user profiles, photo managementC# / ASP.NET, PostgreSQL
Discovery ServiceRank and serve the swipe feedC# / .NET, Redis, Neo4j
Swipe ServiceRecord like/pass/super-like actionsC# / .NET, Kafka
Match ServiceDetect mutual matches, create match recordsC# / .NET, PostgreSQL
Chat ServiceReal-time messaging via WebSocketC# / SignalR, DynamoDB
Search/Geo ServiceGeohash indexing, proximity queriesElasticsearch, Redis
Media ServiceImage/video upload, transcoding, CDNC#, S3, Lambda
Notification ServicePush, in-app, email notificationsC#, FCM, APNs, Kafka
Moderation ServiceContent review, NSFW detection, text analysisC#, ML, third-party APIs
Premium ServiceSubscriptions, boosts, billingC#, Stripe
Analytics ServiceEvent tracking, funnel analysis, A/B testingKafka, Spark, Redshift
Recommendation EngineML-driven profile ranking and suggestionsPython (training), C# (serving)
Elo Rating ServiceCompute and update attractiveness scoresC#, Redis

3. Data Model and Schema Design

Core Entities

SQL
-- Users table
CREATE TABLE users (
    user_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    phone_number    VARCHAR(20) UNIQUE,
    email           VARCHAR(255) UNIQUE,
    password_hash   VARCHAR(255) NOT NULL,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    status          VARCHAR(20) DEFAULT 'active',
    last_active_at  TIMESTAMPTZ,
    elo_score       FLOAT DEFAULT 1200.0,
    boost_expires_at TIMESTAMPTZ,
    is_premium      BOOLEAN DEFAULT FALSE
);

-- Profiles table (1:1 with users)
CREATE TABLE profiles (
    user_id         UUID PRIMARY KEY REFERENCES users(user_id),
    display_name    VARCHAR(100) NOT NULL,
    bio             TEXT,
    date_of_birth   DATE NOT NULL,
    gender          VARCHAR(20),
    show_gender     BOOLEAN DEFAULT TRUE,
    looking_for     VARCHAR(20)[],
    school          VARCHAR(200),
    job_title       VARCHAR(200),
    company         VARCHAR(200),
    height_cm       INT,
    zodiac_sign     VARCHAR(20),
    lifestyle_smoke VARCHAR(20),
    lifestyle_drink VARCHAR(20),
    lifestyle_workout VARCHAR(20),
    kids_plans      VARCHAR(50),
    religion        VARCHAR(50),
    political_views VARCHAR(50),
    languages       VARCHAR(50)[],
    completed_setup BOOLEAN DEFAULT FALSE
);

-- Profile photos
CREATE TABLE profile_photos (
    photo_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID REFERENCES users(user_id),
    photo_url       VARCHAR(500) NOT NULL,
    thumbnail_url   VARCHAR(500),
    sort_order      INT DEFAULT 0,
    is_verified     BOOLEAN DEFAULT FALSE,
    is_primary      BOOLEAN DEFAULT FALSE,
    moderation_status VARCHAR(20) DEFAULT 'pending',
    uploaded_at     TIMESTAMPTZ DEFAULT NOW()
);

-- Profile prompts (Hinge-style)
CREATE TABLE profile_prompts (
    prompt_id       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID REFERENCES users(user_id),
    prompt_text     VARCHAR(300) NOT NULL,
    response_text   VARCHAR(300) NOT NULL,
    sort_order      INT DEFAULT 0
);

-- Interests / tags
CREATE TABLE interests (
    interest_id     SERIAL PRIMARY KEY,
    name            VARCHAR(100) UNIQUE NOT NULL,
    category        VARCHAR(50),
    icon_url        VARCHAR(500)
);

CREATE TABLE user_interests (
    user_id         UUID REFERENCES users(user_id),
    interest_id     INT REFERENCES interests(interest_id),
    PRIMARY KEY (user_id, interest_id)
);

-- Swipe actions
CREATE TABLE swipe_actions (
    swipe_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    swiper_id       UUID REFERENCES users(user_id),
    swiped_id       UUID REFERENCES users(user_id),
    action          VARCHAR(10) NOT NULL,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(swiper_id, swiped_id)
);

-- Matches
CREATE TABLE matches (
    match_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_a_id       UUID REFERENCES users(user_id),
    user_b_id       UUID REFERENCES users(user_id),
    matched_at      TIMESTAMPTZ DEFAULT NOW(),
    is_active       BOOLEAN DEFAULT TRUE,
    unmatched_by    UUID REFERENCES users(user_id),
    unmatched_at    TIMESTAMPTZ,
    UNIQUE(user_a_id, user_b_id)
);

-- Chat messages
CREATE TABLE chat_messages (
    message_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    match_id        UUID REFERENCES matches(match_id),
    sender_id       UUID REFERENCES users(user_id),
    content         TEXT,
    message_type    VARCHAR(20) DEFAULT 'text',
    is_read         BOOLEAN DEFAULT FALSE,
    read_at         TIMESTAMPTZ,
    is_deleted      BOOLEAN DEFAULT FALSE,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Preferences
CREATE TABLE user_preferences (
    user_id             UUID PRIMARY KEY REFERENCES users(user_id),
    min_age             INT DEFAULT 18,
    max_age             INT DEFAULT 50,
    distance_km         INT DEFAULT 50,
    show_me             VARCHAR(20) DEFAULT 'everyone',
    global_mode         BOOLEAN DEFAULT FALSE,
    hide_age            BOOLEAN DEFAULT FALSE,
    hide_distance       BOOLEAN DEFAULT FALSE
);

-- Reports
CREATE TABLE reports (
    report_id       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    reporter_id     UUID REFERENCES users(user_id),
    reported_id     UUID REFERENCES users(user_id),
    reason          VARCHAR(50) NOT NULL,
    description     TEXT,
    status          VARCHAR(20) DEFAULT 'pending',
    reviewed_by     UUID,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    resolved_at     TIMESTAMPTZ
);

-- Bans
CREATE TABLE bans (
    ban_id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID REFERENCES users(user_id),
    reason          TEXT NOT NULL,
    ban_type        VARCHAR(20) DEFAULT 'temporary',
    expires_at      TIMESTAMPTZ,
    banned_by       VARCHAR(50),
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Subscriptions
CREATE TABLE subscriptions (
    subscription_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID REFERENCES users(user_id),
    plan_type       VARCHAR(30) NOT NULL,
    status          VARCHAR(20) DEFAULT 'active',
    stripe_sub_id   VARCHAR(255),
    current_period_start TIMESTAMPTZ,
    current_period_end   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

Redis Schema (Hot Data)

Redis
-- Swipe cooldown per user
SET swipe:cooldown:{user_id} "{timestamp}" EX 86400

-- Daily swipe limit counter
INCR swipe:count:{user_id}:{date}
EXPIRE swipe:count:{user_id}:{date} 86400

-- Feed queue (pre-computed candidate profiles)
ZADD feed:{user_id} {score} {candidate_user_id}

-- User seen set to avoid repeats
SADD feed:seen:{user_id} {candidate_user_id}

-- Active boost tracking
SADD boost:active {user_id}

-- Elo scores (fast lookup)
HSET elo:scores {user_id} {elo_score}

-- User online status
SET user:online:{user_id} "1" EX 300

-- Geohash index for proximity
GEOADD geo:users {longitude} {latitude} {user_id}

-- Read receipts
HSET chat:read:{match_id} {user_id} {timestamp}

-- Who liked you (for premium users)
ZADD likes:received:{user_id} {timestamp} {swiper_id}
Design Note: The swipe_actions table uses a UNIQUE constraint to prevent duplicate swipes. The feed queue in Redis is pre-computed asynchronously, so the swipe action itself is O(1) and just writes to Kafka and updates Redis counters.

4. Profile Creation

Profile creation is the first impression. A well-designed profile system balances self-expression with structured data that the algorithm can use for matching.

Photo Management

Users can upload up to 9 photos. The upload flow:

  1. Client requests a pre-signed upload URL from the Media Service.
  2. Client uploads the original photo directly to S3.
  3. An S3 event triggers a Lambda function that generates thumbnails, runs NSFW detection, performs face detection, and stores moderation status.
  4. Photo is marked as pending until moderation passes, then becomes approved.
C#
public class PhotoUploadService
{
    private readonly IS3Client _s3;
    private readonly IImageProcessor _imageProcessor;
    private readonly IModerationClient _moderation;
    private readonly IProfileRepository _profileRepo;

    public async Task<PhotoUploadResult> UploadPhotoAsync(
        Guid userId, Stream imageStream, string fileName)
    {
        var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp" };
        var contentType = GetContentType(fileName);
        if (!allowedTypes.Contains(contentType))
            throw new InvalidMediaTypeException(contentType);
        if (imageStream.Length > 10 * 1024 * 1024)
            throw new FileTooLargeException("Max 10MB allowed");

        var thumbnails = await _imageProcessor.GenerateThumbnailsAsync(
            imageStream, new[] { 200, 400, 800 });

        var photoId = Guid.NewGuid();
        var key = $"users/{userId}/photos/{photoId}";
        await _s3.PutObjectAsync(key, imageStream, contentType);

        foreach (var thumb in thumbnails)
        {
            var thumbKey = $"users/{userId}/photos/{photoId}_{thumb.Width}px";
            await _s3.PutObjectAsync(thumbKey, thumb.Stream, contentType);
        }

        var moderationResult = await _moderation.AnalyzeImageAsync(imageStream);

        var photo = new ProfilePhoto
        {
            PhotoId = photoId,
            UserId = userId,
            PhotoUrl = $"https://cdn.example.com/{key}",
            ThumbnailUrl = $"https://cdn.example.com/{key}_400px",
            ModerationStatus = moderationResult.IsApproved
                ? "approved" : "pending",
            IsVerified = moderationResult.ContainsRealFace,
            UploadedAt = DateTime.UtcNow
        };

        await _profileRepo.SavePhotoAsync(photo);
        return new PhotoUploadResult { PhotoId = photoId, Status = photo.ModerationStatus };
    }
}

Bio and Prompts

The bio is a free-text field (max 500 characters). Prompts are structured Q&A pairs that help showcase personality:

PromptExample Response
"A perfect first date...""Coffee at a cozy bookstore, then a walk through the park"
"My simple pleasures...""Morning sunlight, fresh bread, and a good playlist"
"I geek out on...""Distributed systems, sci-fi novels, and sourdough baking"
"The way to win me over...""Send me your favorite recipe"
"I am looking for...""Someone who laughs at bad puns and plans spontaneous trips"

Interests Tags

Users select from a taxonomy of about 500 interests organized into categories:

  • Activities: Hiking, Running, Yoga, Rock Climbing, Surfing, Photography
  • Entertainment: Movies, TV Shows, Gaming, Music, Reading, Podcasts
  • Food and Drink: Cooking, Wine, Coffee, Vegan, BBQ, Sushi
  • Travel: Weekend Getaways, Backpacking, City Trips, Beach Vacations
  • Values: Fitness, Family, Career-Driven, Creative, Intellectual
Profile Completeness Score: We compute a completeness score (0-100) based on: photo count (0-30), bio length (0-15), prompt responses (0-20), interests selected (0-15), verified status (0-10), and linked accounts (0-10). Users with scores above 70 get a 20% boost in discovery ranking.

5. Discovery / Feed Algorithm

The discovery algorithm is the heart of the dating platform. It determines which profiles to show each user and in what order. This directly impacts user satisfaction, match rates, and ultimately retention.

flowchart LR A[User Opens App] --> B[Fetch Feed Request] B --> C{Premium User?} C -->|Yes| D[Full Candidate Pool] C -->|No| E[Standard Candidate Pool] D --> F[Apply Hard Filters] E --> F F --> G[Compute Compatibility Score] G --> H[Apply Elo Boost] H --> I[Apply Recency Decay] I --> J[Apply Diversity Rules] J --> K[Apply Boost Multiplier] K --> L[Return Top N Profiles] L --> M[Client Renders Card Stack]

Candidate Generation

We do not score all users in the database. Instead, we use a two-stage funnel:

Stage 1: Candidate Retrieval (Broad)

  • Geohash-based proximity filter using Redis GEO queries.
  • Hard filters: age range, gender preference, dealbreakers.
  • Exclusion: users already swiped, blocked, or banned.
  • Target: about 1000 candidates per request.

Stage 2: Candidate Ranking (Fine-grained)

  • ML-based compatibility scoring from feature vectors of both profiles.
  • Elo-based attractiveness weighting.
  • Activity recency: recently active profiles preferred.
  • Diversity rules: avoid showing the same type of profile repeatedly.
  • Boost factor: boosted profiles get a visibility multiplier.
  • Target: top 50 ranked candidates served to the client.
C#
public class DiscoveryEngine
{
    private readonly IGeoService _geoService;
    private readonly ICompatibilityScorer _scorer;
    private readonly IEloService _eloService;
    private readonly IFeedCache _feedCache;
    private readonly IPreferencesService _prefsService;

    public async Task<List<FeedProfile>> GetFeedAsync(
        Guid userId, int page = 0, int pageSize = 20)
    {
        var cached = await _feedCache.GetFeedAsync(userId, page);
        if (cached != null && cached.Any())
            return cached;

        var prefs = await _prefsService.GetAsync(userId);
        var userProfile = await _profileService.GetCompleteAsync(userId);

        // Stage 1: Candidate retrieval
        var candidates = await _geoService.GetNearbyUsersAsync(
            userId, prefs.DistanceKm, prefs.MinAge, prefs.MaxAge,
            prefs.ShowMe, maxCandidates: 1000);

        candidates = await FilterSwipedAndBlockedAsync(userId, candidates);

        // Stage 2: Score and rank
        var scoredCandidates = new List<ScoredCandidate>();
        foreach (var candidate in candidates)
        {
            var compatibility = await _scorer.ScoreAsync(userProfile, candidate);
            var eloBoost = await _eloService.GetRelativeBoostAsync(
                userId, candidate.UserId);
            var activityRecency = CalculateRecencyScore(candidate.LastActiveAt);
            var boostMultiplier = candidate.HasActiveBoost ? 1.5 : 1.0;

            var finalScore = (compatibility * 0.50)
                           + (eloBoost * 0.20)
                           + (activityRecency * 0.15)
                           + (candidate.CompletenessScore * 0.15);

            finalScore *= boostMultiplier;

            scoredCandidates.Add(new ScoredCandidate
            {
                UserId = candidate.UserId,
                Score = finalScore,
                Profile = candidate
            });
        }

        var diversified = ApplyDiversityRules(scoredCandidates);

        var pageResults = diversified
            .Skip(page * pageSize)
            .Take(pageSize)
            .ToList();

        await _feedCache.CacheFeedAsync(userId, page, pageResults);
        return pageResults.Select(c => c.Profile).ToList();
    }

    private double CalculateRecencyScore(DateTimeOffset lastActive)
    {
        var hoursSinceActive = (DateTimeOffset.UtcNow - lastActive).TotalHours;
        if (hoursSinceActive < 1) return 1.0;
        if (hoursSinceActive < 24) return 0.8;
        if (hoursSinceActive < 72) return 0.5;
        if (hoursSinceActive < 168) return 0.3;
        return 0.1;
    }

    private List<ScoredCandidate> ApplyDiversityRules(
        List<ScoredCandidate> candidates)
    {
        var sorted = candidates.OrderByDescending(c => c.Score).ToList();
        var diversified = new List<ScoredCandidate>();
        var lastInterestCluster = "";
        var consecutiveCount = 0;

        foreach (var candidate in sorted)
        {
            var cluster = GetInterestCluster(candidate.Profile);
            if (cluster == lastInterestCluster)
            {
                consecutiveCount++;
                if (consecutiveCount >= 3)
                    continue;
            }
            else
            {
                lastInterestCluster = cluster;
                consecutiveCount = 1;
            }
            diversified.Add(candidate);
        }

        return diversified;
    }
}
Cold Start Problem: New users with no swipe history cannot be scored by collaborative filtering. For the first 48 hours, new profiles are shown to a random sample of active users to gather initial signal. Their Elo starts at 1200 (median) and adjusts rapidly based on early engagement.

6. Elo-like Rating System

Every user has an implicit attractiveness score modeled after the Elo rating system (originally designed for chess). This score determines how often a user profile appears in other users feeds and their relative position.

How Elo Works for Dating

When User A swipes on User B:

  • If A has a higher Elo than B and A likes B then B Elo goes up slightly.
  • If A has a lower Elo than B and A likes B then B Elo goes up more.
  • If A passes on B then B Elo goes down proportionally.
  • Super Likes carry 3x the weight of a regular like.
C#
public class EloRatingService
{
    private readonly IRedisCache _cache;
    private const int K_FACTOR = 32;
    private const int INITIAL_ELO = 1200;
    private const double SCALE_FACTOR = 400.0;

    public async Task UpdateEloAfterSwipeAsync(
        Guid swiperId, Guid swipedId, SwipeAction action)
    {
        var swiperElo = await GetEloAsync(swiperId);
        var swipedElo = await GetEloAsync(swipedId);

        var expectedSwiped = 1.0 / (1.0 + Math.Pow(
            10, (swiperElo - swipedElo) / SCALE_FACTOR));

        double actualScore = action switch
        {
            SwipeAction.Like => 1.0,
            SwipeAction.SuperLike => 1.0,
            SwipeAction.Pass => 0.0,
            _ => 0.0
        };

        double kMultiplier = action == SwipeAction.SuperLike ? 3.0 : 1.0;

        double delta = K_FACTOR * kMultiplier * (actualScore - expectedSwiped);

        if (swipedElo > 1800) delta *= 0.7;
        if (swipedElo < 800) delta *= 1.3;

        var newElo = Math.Max(600, Math.Min(2000, swipedElo + delta));
        await SetEloAsync(swipedId, newElo);

        double swiperDelta = K_FACTOR * 0.3 * (1.0 - actualScore);
        var newSwiperElo = Math.Max(600, Math.Min(2000, swiperElo + swiperDelta));
        await SetEloAsync(swiperId, newSwiperElo);
    }

    public async Task<double> GetRelativeBoostAsync(
        Guid viewerId, Guid profileOwnerId)
    {
        var viewerElo = await GetEloAsync(viewerId);
        var ownerElo = await GetEloAsync(profileOwnerId);

        var diff = ownerElo - viewerElo;
        if (diff >= 0 && diff <= 200) return 1.2;
        if (diff > 200 && diff <= 400) return 1.0;
        if (diff > 400) return 0.7;
        return 0.85;
    }

    public async Task<double> GetEloAsync(Guid userId)
    {
        var cached = await _cache.HashGetAsync("elo:scores", userId.ToString());
        if (cached.HasValue) return double.Parse(cached.ToString());

        var elo = await _db.Users
            .Where(u => u.UserId == userId)
            .Select(u => u.EloScore)
            .FirstOrDefaultAsync();

        await _cache.HashSetAsync("elo:scores", userId.ToString(), elo.ToString());
        return elo;
    }

    private async Task SetEloAsync(Guid userId, double elo)
    {
        await _db.Users
            .Where(u => u.UserId == userId)
            .ExecuteUpdateAsync(s => s.SetProperty(u => u.EloScore, elo));
        await _cache.HashSetAsync("elo:scores", userId.ToString(), elo.ToString());
    }
}

Elo Distribution

Elo RangePercentileFeed PositionDescription
600-900Bottom 10%Rarely shownLow engagement, possibly new or incomplete profiles
900-110010th-40thOccasionallyAverage engagement level
1100-130040th-70thFrequentlyAbove average, healthy engagement
1300-160070th-95thVery frequentlyHigh engagement, attractive profiles
1600-2000Top 5%DominantExtremely high engagement
Fairness Concern: Pure Elo creates a rich-get-richer cycle. We mitigate this with: (1) new user boost for the first 48 hours, (2) periodic Elo decay so scores drift toward median over time, (3) diversity injection where 10% of feed is randomly selected from mid-tier Elo, and (4) anti-catfishing where flagged profiles get Elo penalties.

7. Swipe Mechanics

The swipe is the primary interaction model. Users see one profile at a time and make a binary decision: Like (right swipe), Pass (left swipe), or Super Like (up swipe).

Swipe Processing Pipeline

sequenceDiagram participant Client participant API participant SwipeSvc as Swipe Service participant Kafka participant MatchSvc as Match Service participant EloSvc as Elo Service participant NotifSvc as Notification Service participant FeedCache as Feed Cache Client->>API: POST /api/swipes {target_id, action} API->>SwipeSvc: RecordSwipe() SwipeSvc->>SwipeSvc: Validate rate limit SwipeSvc->>Kafka: Publish SwipeEvent SwipeSvc-->>Client: 200 OK {result} Kafka->>MatchSvc: Consume SwipeEvent MatchSvc->>MatchSvc: Check reverse swipe alt Mutual Like MatchSvc->>Kafka: Publish MatchCreatedEvent MatchSvc->>NotifSvc: Push notification end Kafka->>EloSvc: Consume SwipeEvent EloSvc->>EloSvc: Update Elo ratings Kafka->>FeedCache: Consume SwipeEvent FeedCache->>FeedCache: Remove target from feed

Swipe Rate Limiting

C#
public class SwipeService
{
    private readonly IRedisCache _cache;
    private readonly IKafkaProducer _kafka;

    private const int DAILY_SWIPE_LIMIT_FREE = 100;
    private const int DAILY_SWIPE_LIMIT_PLUS = 150;
    private const int DAILY_SWIPE_LIMIT_GOLD = 250;
    private const int SUPER_LIKES_PER_DAY_FREE = 2;
    private const int SUPER_LIKES_PER_DAY_PLUS = 5;
    private const int SUPER_LIKES_PER_DAY_GOLD = 10;
    private const int SWIPE_COOLDOWN_MS = 1000;

    public async Task<SwipeResult> SwipeAsync(
        Guid swiperId, Guid targetId, SwipeAction action)
    {
        var lastSwipe = await _cache.GetAsync($"swipe:last:{swiperId}");
        if (lastSwipe.HasValue)
        {
            var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
                        - long.Parse(lastSwipe.ToString());
            if (elapsed < SWIPE_COOLDOWN_MS)
                throw new RateLimitedException(
                    $"Please wait {SWIPE_COOLDOWN_MS - elapsed}ms");
        }

        var today = DateTime.UtcNow.ToString("yyyyMMdd");
        var dailyCount = await _cache.IncrementAsync(
            $"swipe:count:{swiperId}:{today}");
        await _cache.KeyExpireAsync(
            $"swipe:count:{swiperId}:{today}", TimeSpan.FromHours(24));

        var limit = await GetDailyLimitAsync(swiperId);
        if (dailyCount > limit)
            throw new DailySwipeLimitExceededException(limit);

        if (action == SwipeAction.SuperLike)
        {
            var superCount = await _cache.IncrementAsync(
                $"swipe:super:{swiperId}:{today}");
            var superLimit = await GetSuperLikeLimitAsync(swiperId);
            if (superCount > superLimit)
                throw new SuperLikeLimitExceededException(superLimit);
        }

        var existingSwipe = await _db.SwipeActions
            .FirstOrDefaultAsync(s =>
                s.SwiperId == swiperId && s.SwipedId == targetId);
        if (existingSwipe != null)
            throw new AlreadySwipedException();

        var swipe = new SwipeAction
        {
            SwipeId = Guid.NewGuid(),
            SwiperId = swiperId,
            SwipedId = targetId,
            Action = action,
            CreatedAt = DateTime.UtcNow
        };

        await _db.SwipeActions.AddAsync(swipe);
        await _db.SaveChangesAsync();

        await _cache.SetAsync(
            $"swipe:last:{swiperId}",
            DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(),
            TimeSpan.FromMinutes(5));

        await _kafka.PublishAsync("swipe-events", swiperId.ToString(),
            new SwipeEvent
            {
                SwiperId = swiperId,
                SwipedId = targetId,
                Action = action,
                Timestamp = DateTimeOffset.UtcNow
            });

        var reverseSwipe = await _db.SwipeActions
            .FirstOrDefaultAsync(s =>
                s.SwiperId == targetId && s.SwipedId == swiperId
                && s.Action == SwipeAction.Like);

        bool isMatch = reverseSwipe != null
                    && action == SwipeAction.Like;

        return new SwipeResult
        {
            IsMatch = isMatch,
            MatchId = isMatch ? await CreateMatchAsync(swiperId, targetId) : null
        };
    }

    private async Task<int> GetDailyLimitAsync(Guid userId)
    {
        var tier = await _premiumService.GetTierAsync(userId);
        return tier switch
        {
            SubscriptionTier.Plus => DAILY_SWIPE_LIMIT_PLUS,
            SubscriptionTier.Gold => DAILY_SWIPE_LIMIT_GOLD,
            _ => DAILY_SWIPE_LIMIT_FREE
        };
    }
}

Super Like Special Behavior

Super Likes are special signals that notify the recipient before they swipe:

  • User B sees a blue star indicator on User A profile in their feed.
  • User B gets a push notification: "Someone Super Liked you!"
  • User A profile is pinned to the top of User B feed queue.
  • The Elo impact is 3x a regular like.

8. Mutual Match Detection and Notification

When two users both Like each other, a mutual match is created. This is the core aha moment in dating apps.

C#
public class MatchService
{
    private readonly AppDbContext _db;
    private readonly INotificationService _notifService;
    private readonly IKafkaProducer _kafka;

    public async Task<Match> CreateMatchAsync(Guid userIdA, Guid userIdB)
    {
        var (a, b) = string.Compare(userIdA.ToString(), userIdB.ToString()) < 0
            ? (userIdA, userIdB)
            : (userIdB, userIdA);

        var existing = await _db.Matches
            .FirstOrDefaultAsync(m =>
                m.UserAId == a && m.UserBId == b && m.IsActive);
        if (existing != null) return existing;

        var match = new Match
        {
            MatchId = Guid.NewGuid(),
            UserAId = a,
            UserBId = b,
            MatchedAt = DateTimeOffset.UtcNow,
            IsActive = true
        };

        _db.Matches.Add(match);
        await _db.SaveChangesAsync();

        await _feedCache.RemoveFromFeedAsync(a, b);
        await _feedCache.RemoveFromFeedAsync(b, a);

        await _notifService.SendMatchNotificationAsync(a, b, match.MatchId);
        await _notifService.SendMatchNotificationAsync(b, a, match.MatchId);

        await _kafka.PublishAsync("match-events", a.ToString(),
            new MatchCreatedEvent
            {
                MatchId = match.MatchId,
                UserAId = a,
                UserBId = b,
                Timestamp = DateTimeOffset.UtcNow
            });

        var icebreaker = await GenerateIcebreakerAsync(a, b);
        if (icebreaker != null)
        {
            await CreateSystemMessageAsync(match.MatchId,
                $"Icebreaker suggestion: {icebreaker}");
        }

        return match;
    }

    public async Task UnmatchAsync(Guid userId, Guid matchId)
    {
        var match = await _db.Matches.FindAsync(matchId);
        if (match == null) throw new MatchNotFoundException();
        if (match.UserAId != userId && match.UserBId != userId)
            throw new UnauthorizedException();

        match.IsActive = false;
        match.UnmatchedBy = userId;
        match.UnmatchedAt = DateTimeOffset.UtcNow;
        await _db.SaveChangesAsync();

        var otherUserId = match.UserAId == userId
            ? match.UserBId : match.UserAId;
        await _notifService.SendUnmatchNotificationAsync(otherUserId);
    }
}
Notification Priority: Match notifications are high-priority and delivered via push (FCM/APNs), in-app real-time (SignalR), and optionally email. We debounce match notifications within a 5-second window to avoid spam if a user rapidly matches with multiple people.

9. Real-Time Chat (WebSocket)

Once matched, users can message each other. We use ASP.NET Core SignalR (WebSocket abstraction) for real-time bidirectional communication.

Chat Architecture

flowchart TB A[Client A] -->|WebSocket| B[SignalR Hub 1] C[Client B] -->|WebSocket| D[SignalR Hub 2] B --> E[Redis Backplane] D --> E B --> F[Kafka - Chat Events] F --> G[Chat Persistence Worker] G --> H[(DynamoDB)] F --> I[Moderation Worker] F --> J[Analytics Worker]
C#
[Authorize]
public class ChatHub : Hub
{
    private readonly IMessageService _messageService;
    private readonly IMatchService _matchService;
    private readonly IUserPresenceService _presenceService;
    private readonly IMessageModerationService _moderation;

    public override async Task OnConnectedAsync()
    {
        var userId = GetUserId();
        await _presenceService.SetOnlineAsync(userId);
        await Groups.AddToGroupAsync(
            Context.ConnectionId, $"user:{userId}");
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        var userId = GetUserId();
        await _presenceService.SetOfflineAsync(userId);
        await base.OnDisconnectedAsync(exception);
    }

    public async Task JoinMatch(string matchId)
    {
        var userId = GetUserId();
        if (!await _matchService.IsParticipantAsync(matchId, userId))
            throw new HubException("Not authorized for this match");
        await Groups.AddToGroupAsync(
            Context.ConnectionId, $"match:{matchId}");
    }

    public async Task SendMessage(string matchId, string content, string type = "text")
    {
        var userId = GetUserId();
        if (!await _matchService.IsParticipantAsync(matchId, userId))
            throw new HubException("Not authorized");

        var moderationResult = await _moderation.CheckContentAsync(content);
        if (moderationResult.IsFlagged)
        {
            await Clients.Caller.SendAsync("MessageBlocked",
                new { reason = moderationResult.Reason });
            return;
        }

        var message = new ChatMessage
        {
            MessageId = Guid.NewGuid(),
            MatchId = matchId,
            SenderId = userId,
            Content = type == "text"
                ? SanitizeHtml(content) : content,
            MessageType = type,
            CreatedAt = DateTimeOffset.UtcNow
        };

        await _messageService.SaveMessageAsync(message);

        await Clients.Group($"match:{matchId}")
            .SendAsync("ReceiveMessage", new
            {
                messageId = message.MessageId.ToString(),
                senderId = userId.ToString(),
                content = message.Content,
                type = message.MessageType,
                timestamp = message.CreatedAt
            });

        var match = await _matchService.GetAsync(matchId);
        var otherUserId = match.UserAId == userId
            ? match.UserBId : match.UserAId;

        if (!await _presenceService.IsOnlineAsync(otherUserId))
        {
            var senderProfile = await _profileService.GetAsync(userId);
            await _notifService.SendPushAsync(otherUserId, new PushNotification
            {
                Title = senderProfile.DisplayName,
                Body = type == "text"
                    ? Truncate(content, 100)
                    : $"Sent a {type}",
                Data = new Dictionary<string, string>
                {
                    { "matchId", matchId },
                    { "type", "new_message" }
                }
            });
        }
    }

    public async Task SendTypingIndicator(string matchId)
    {
        var userId = GetUserId();
        await Clients.Group($"match:{matchId}")
            .SendAsync("TypingIndicator",
                new { userId = userId.ToString(), isTyping = true });
    }

    public async Task MarkAsRead(string matchId)
    {
        var userId = GetUserId();
        await _messageService.MarkMessagesAsReadAsync(matchId, userId);
        var match = await _matchService.GetAsync(matchId);
        var otherUserId = match.UserAId == userId
            ? match.UserBId : match.UserAId;
        await Clients.User(otherUserId.ToString())
            .SendAsync("MessagesRead", new
            {
                matchId,
                readBy = userId.ToString(),
                readAt = DateTimeOffset.UtcNow
            });
    }

    private string SanitizeHtml(string input) =>
        System.Net.WebUtility.HtmlEncode(input);

    private string Truncate(string input, int maxLength) =>
        input.Length > maxLength
            ? input.Substring(0, maxLength) + "..."
            : input;
}

Chat Message Schema (DynamoDB)

JSON
{
    "PK": "MATCH#match-uuid",
    "SK": "MSG#2024-01-15T10:30:00Z#msg-uuid",
    "SenderId": "user-uuid",
    "Content": "Hey! I see you love hiking too!",
    "MessageType": "text",
    "IsRead": false,
    "ReadAt": null,
    "IsDeleted": false,
    "TTL": 31536000
}
Why DynamoDB for Chat? Chat messages follow a clear access pattern: get messages by match_id, ordered by timestamp. DynamoDB partition key = match_id, sort key = timestamp gives us single-digit millisecond reads at any scale. We use TTL for automatic message expiration.

10. Messaging Safety and Content Moderation

Safety is paramount. Every message and image must be screened for inappropriate content, harassment, spam, and scams.

flowchart LR A[User Sends Message] --> B{Message Type} B -->|Text| C[NLP Pipeline] B -->|Image| D[Computer Vision] B -->|Link| E[URL Scanner] C --> F[Toxicity Classifier] C --> G[Spam Detector] C --> H[PII Detector] D --> I[NSFW Classifier] D --> J[Face Match] D --> K[Known CSAM Hash] E --> L[Phishing Check] F --> M{Decision Engine} G --> M I --> M K --> M L --> M M -->|Safe| N[Deliver Message] M -->|Suspicious| O[Human Review] M -->|Unsafe| P[Block and Alert]
C#
public class MessageModerationService
{
    private readonly INlpClassifier _nlp;
    private readonly IImageClassifier _vision;
    private readonly IUrlScanner _urlScanner;
    private readonly IHashMatcher _csamHashMatcher;

    public async Task<ModerationResult> CheckContentAsync(string content)
    {
        var toxicity = await _nlp.ClassifyToxicityAsync(content);
        var spam = await _nlp.DetectSpamAsync(content);
        var pii = await _nlp.DetectPiiAsync(content);

        var urls = ExtractUrls(content);
        foreach (var url in urls)
        {
            var urlResult = await _urlScanner.ScanAsync(url);
            if (urlResult.IsMalicious)
                return ModerationResult.Unsafe("Malicious URL detected");
        }

        if (toxicity.Score > 0.9)
            return ModerationResult.Unsafe("Highly toxic content");
        if (toxicity.Score > 0.7)
            return ModerationResult.FlagForReview("Potentially toxic");
        if (spam.IsSpam)
            return ModerationResult.Unsafe("Spam detected");
        if (pii.HasPhoneNumbers || pii.HasAddresses)
            return ModerationResult.FlagForReview("Contains PII");

        return ModerationResult.Safe();
    }

    public async Task<ModerationResult> CheckImageAsync(
        Stream imageStream, string senderId, string recipientId)
    {
        var nsfw = await _vision.ClassifyNsfwAsync(imageStream);
        if (nsfw.NudityScore > 0.85)
            return ModerationResult.Unsafe("Explicit content");

        var hash = await _csamHashMatcher.MatchHashAsync(imageStream);
        if (hash.IsMatch)
            return ModerationResult.Unsafe("CSAM detected");

        return ModerationResult.Safe();
    }
}

Blocking

When a user blocks another user:

  • All chat messages are soft-deleted (hidden, not permanently removed for evidence).
  • The blocked user can no longer see the blocker profile.
  • All pending swipe actions between them are invalidated.
  • The block is recorded for pattern analysis.

11. Location-Based Discovery (Geohash)

Location is fundamental to dating. Users want to discover people nearby. We use geohashing for efficient spatial queries.

C#
public class GeoService
{
    private readonly IRedisCache _redis;
    private const int GEOHASH_PRECISION = 6;

    public async Task UpdateLocationAsync(
        Guid userId, double latitude, double longitude)
    {
        await _db.Users
            .Where(u => u.UserId == userId)
            .ExecuteUpdateAsync(s => s
                .SetProperty(u => u.Latitude, latitude)
                .SetProperty(u => u.Longitude, longitude)
                .SetProperty(u => u.LastLocationUpdate, DateTimeOffset.UtcNow));

        await _redis.GeoAddAsync("geo:users",
            longitude, latitude, userId.ToString());

        var geohash = Geohash.Encode(latitude, longitude, GEOHASH_PRECISION);
        await _redis.SetAddAsync($"geo:hash:{geohash}", userId.ToString());

        for (int precision = 4; precision <= 5; precision++)
        {
            var parentHash = Geohash.Encode(latitude, longitude, precision);
            await _redis.SetAddAsync($"geo:hash:{parentHash}", userId.ToString());
        }
    }

    public async Task<List<Guid>> GetNearbyUsersAsync(
        Guid userId, int radiusKm, int minAge, int maxAge,
        string showMe, int maxCandidates = 1000)
    {
        var userLocation = await _redis.GeoSearchAsync(
            "geo:users", userId.ToString(), radiusKm * 1000);

        var candidateIds = new HashSet<Guid>();

        if (radiusKm <= 100)
        {
            var nearby = await _redis.GeoRadiusAsync(
                "geo:users",
                userLocation.Longitude,
                userLocation.Latitude,
                radiusKm,
                GeoUnit.Kilometers,
                Order.ByDistance,
                count: maxCandidates * 2,
                exclude: true);

            candidateIds = nearby.Select(n =>
                Guid.Parse(n.Member)).ToHashSet();
        }
        else
        {
            var userHash = Geohash.Encode(
                userLocation.Latitude,
                userLocation.Longitude,
                GEOHASH_PRECISION);
            var neighboringHashes = Geohash.GetNeighbors(userHash);

            foreach (var hash in neighboringHashes)
            {
                var users = await _redis.SetMembersAsync($"geo:hash:{hash}");
                candidateIds.UnionWith(users.Select(u => Guid.Parse(u)));
            }
        }

        var filtered = await _db.Users
            .Where(u =>
                candidateIds.Contains(u.UserId)
                && u.Status == "active"
                && u.DateOfBirth >= DateTime.Today.AddYears(-maxAge)
                && u.DateOfBirth <= DateTime.Today.AddYears(-minAge))
            .Take(maxCandidates)
            .Select(u => u.UserId)
            .ToListAsync();

        return filtered;
    }
}

Haversine Distance Formula

C#
public static class Haversine
{
    private const double EARTH_RADIUS_KM = 6371.0;

    public static double CalculateDistance(
        double lat1, double lon1, double lat2, double lon2)
    {
        var dLat = ToRadians(lat2 - lat1);
        var dLon = ToRadians(lon2 - lon1);

        var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
                Math.Cos(ToRadians(lat1)) * Math.Cos(ToRadians(lat2)) *
                Math.Sin(dLon / 2) * Math.Sin(dLon / 2);

        var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));

        return EARTH_RADIUS_KM * c;
    }

    private static double ToRadians(double degrees)
        => degrees * Math.PI / 180.0;
}
Location Privacy: We never show exact locations. The displayed distance is rounded to the nearest mile/km. Users can enable Hide Distance (premium feature) or use Smart Distance which shows approximate location. GPS coordinates are encrypted at rest.

12. Preference Filters

Users configure discovery preferences that determine who appears in their feed. These filters combine hard filters (must match) and soft preferences (influence ranking).

FilterTypeFree TierPremium Tier
Age RangeHard18-65, max range 3018-100, unlimited range
Maximum DistanceHardUp to 100 milesUnlimited (Global mode)
Gender / Show MeHardMen, Women, EveryoneCustom options
HeightSoftNot availablePremium only
Education LevelSoftNot availablePremium only
Verified OnlySoftNot availablePremium only
InterestsSoftUp to 3 preferredUnlimited
Zodiac / LifestyleSoftNot availablePremium only
C#
public class PreferenceFilterService
{
    public IQueryable<UserProfile> ApplyHardFilters(
        IQueryable<UserProfile> query, UserPreferences prefs, Guid viewerId)
    {
        var minDob = DateTime.Today.AddYears(-prefs.MaxAge);
        var maxDob = DateTime.Today.AddYears(-prefs.MinAge);
        query = query.Where(p => p.DateOfBirth >= minDob
                              && p.DateOfBirth <= maxDob);

        if (prefs.ShowMe != "everyone")
        {
            query = prefs.ShowMe switch
            {
                "men" => query.Where(p => p.Gender == "male"),
                "women" => query.Where(p => p.Gender == "female"),
                _ => query
            };
        }

        query = query.Where(p =>
            p.Status == "active" && p.UserId != viewerId);

        return query;
    }

    public double CalculateSoftScore(
        UserProfile candidate, UserPreferences prefs)
    {
        double score = 0.0;

        if (prefs.PreferredHeightMin.HasValue
            && candidate.HeightCm >= prefs.PreferredHeightMin)
            score += 0.1;
        if (prefs.PreferredHeightMax.HasValue
            && candidate.HeightCm <= prefs.PreferredHeightMax)
            score += 0.1;

        if (prefs.PreferCollegeEducated && candidate.HasCollegeDegree)
            score += 0.15;

        if (prefs.PreferredInterests?.Any() == true)
        {
            var overlap = candidate.Interests
                .Intersect(prefs.PreferredInterests).Count();
            score += Math.Min(0.3, overlap * 0.1);
        }

        if (prefs.PreferNonSmoker && candidate.LifestyleSmoke == "never")
            score += 0.1;
        if (prefs.PreferActive && candidate.LifestyleWorkout == "often")
            score += 0.1;

        if (prefs.PreferVerified && candidate.IsVerified)
            score += 0.1;

        return score;
    }
}

13. Verification (Photo and ID)

Trust is critical in dating. Catfishing, fake profiles, and misleading photos destroy user confidence. We offer two levels of verification.

Photo Verification (Selfie Matching)

  1. User is prompted to take a selfie mimicking a specific pose.
  2. The selfie is compared against all profile photos using a face embedding model (FaceNet or ArcFace).
  3. If the similarity score exceeds 0.85 across at least 3 profile photos, verification passes.
  4. A blue checkmark badge is added to the profile.
C#
public class PhotoVerificationService
{
    private readonly IFaceRecognitionModel _faceModel;
    private readonly IImageStorage _storage;

    public async Task<VerificationResult> VerifyPhotoAsync(
        Guid userId, Stream selfieStream, VerificationPose requiredPose)
    {
        var selfieValidation = await ValidateSelfieAsync(
            selfieStream, requiredPose);
        if (!selfieValidation.IsValid)
            return VerificationResult.Failed(selfieValidation.Reason);

        var selfieEmbedding = await _faceModel.ExtractEmbeddingAsync(selfieStream);

        var profilePhotos = await _storage.GetUserPhotosAsync(userId);
        int matchCount = 0;

        foreach (var photo in profilePhotos)
        {
            var photoStream = await _storage.DownloadAsync(photo.PhotoUrl);
            var photoEmbedding = await _faceModel.ExtractEmbeddingAsync(photoStream);
            var similarity = CalculateCosineSimilarity(
                selfieEmbedding, photoEmbedding);

            if (similarity >= 0.85)
                matchCount++;
        }

        if (matchCount >= Math.Min(3, profilePhotos.Count))
        {
            await MarkAsVerifiedAsync(userId, VerificationType.Photo);
            return VerificationResult.Success();
        }

        return VerificationResult.Failed(
            $"Only {matchCount}/{profilePhotos.Count} photos matched.");
    }

    private double CalculateCosineSimilarity(float[] a, float[] b)
    {
        double dotProduct = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.Length; i++)
        {
            dotProduct += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dotProduct / (Math.Sqrt(normA) * Math.Sqrt(normB));
    }
}

ID Verification

For additional trust, users can submit government-issued ID via a third-party service (Jumio, Onfido) for OCR-based verification, fraud detection, and age confirmation.

Verification Impact: Verified profiles receive a 15-25% increase in likes received. In A/B tests, showing verified badges in the feed increased match rates by 18% for verified users and reduced catfishing reports by 40%.

14. Profile Boost and Premium Features

Revenue comes from premium subscriptions and a la carte purchases.

Subscription Tiers

FeatureFreePlus ($9.99/mo)Gold ($29.99/mo)Platinum ($49.99/mo)
Daily Swipes100150UnlimitedUnlimited
Super Likes/day251015
Boosts/month015Unlimited
Who Liked YouNoYesYesYes
Advanced FiltersNoYesYesYes
Unlimited RewindsNoYesYesYes
Passport (change location)NoNoYesYes
Priority LikesNoNoNoYes

Boost Mechanism

When a user activates a Boost (30-minute window), their profile is shown to 10x more users than normal with priority placement in nearby feeds.

C#
public class BoostService
{
    private readonly IRedisCache _redis;
    private readonly IBoostAnalytics _analytics;

    public async Task ActivateBoostAsync(Guid userId)
    {
        var user = await _userService.GetAsync(userId);
        if (!user.IsPremium)
            throw new PremiumRequiredException("Boost requires Plus or higher");

        var credits = await _boostCredits.GetAsync(userId);
        if (credits <= 0)
            throw new NoBoostCreditsException();

        await _boostCredits.DecrementAsync(userId);

        await _redis.SetAddAsync("boost:active", userId.ToString());
        await _redis.KeyExpireAsync(
            $"boost:active:{userId}", TimeSpan.FromMinutes(30));

        await _userService.UpdateAsync(userId, new { BoostExpiresAt =
            DateTimeOffset.UtcNow.AddMinutes(30) });

        await _analytics.TrackBoostActivationAsync(userId);
    }

    public async Task<bool> IsUserBoostedAsync(Guid userId)
    {
        return await _redis.SetContainsAsync("boost:active", userId.ToString());
    }
}

15. Video Dating and Calls

Video dating allows matched users to have face-to-face conversations without exchanging phone numbers.

Implementation

  • WebRTC for peer-to-peer video/audio streaming.
  • TURN/STUN servers (Coturn) for NAT traversal.
  • Signaling server (SignalR) for call setup and teardown.
  • Recording (optional, with consent) for safety moderation.
C#
public class VideoCallHub : Hub
{
    public async Task InitiateCall(string matchId)
    {
        var callerId = GetUserId();
        if (!await _matchService.IsParticipantAsync(matchId, callerId))
            throw new HubException("Not authorized");

        var match = await _matchService.GetAsync(matchId);
        var calleeId = match.UserAId == callerId
            ? match.UserBId : match.UserAId;

        var callId = Guid.NewGuid().ToString();
        await _callSession.CreateAsync(callId, callerId, calleeId, matchId);

        await Clients.User(calleeId).SendAsync("IncomingCall", new
        {
            callId,
            callerId,
            callerName = (await _profileService.GetAsync(callerId)).DisplayName
        });
    }

    public async Task AcceptCall(string callId)
    {
        var userId = GetUserId();
        var session = await _callSession.GetAsync(callId);
        if (session == null || session.CalleeId != userId)
            throw new HubException("Invalid call");

        await _callSession.UpdateStatusAsync(callId, CallStatus.Active);
        var iceServers = await _iceConfigService.GetServersAsync();

        await Clients.User(session.CallerId).SendAsync("CallAccepted", new
        {
            callId,
            iceServers
        });
    }

    public async Task EndCall(string callId)
    {
        var userId = GetUserId();
        var session = await _callSession.GetAsync(callId);
        if (session == null) return;

        await _callSession.UpdateStatusAsync(callId, CallStatus.Ended);
        var otherUserId = session.CallerId == userId
            ? session.CalleeId : session.CallerId;

        await Clients.User(otherUserId).SendAsync("CallEnded", new { callId });
    }
}

16. Activity Status and Read Receipts

Activity status and read receipts provide transparency but can also create anxiety. We make these configurable.

Activity Status

  • Online: Green dot, user is currently in the app.
  • Recently Active: "Active 2h ago" within 24 hours.
  • Last Active: "Active 3d ago" for 1-7 days.
  • Hidden: Premium users can hide their activity status.
C#
public class ActivityStatusService
{
    private readonly IRedisCache _redis;
    private readonly IUserPreferencesService _prefsService;

    public async Task<ActivityStatus> GetStatusAsync(Guid userId, Guid viewerId)
    {
        var prefs = await _prefsService.GetAsync(userId);

        if (prefs.HideActivityStatus
            && !await _premiumService.HasFeatureAsync(
                viewerId, PremiumFeature.SeeHiddenStatus))
            return ActivityStatus.Hidden;

        var isOnline = await _redis.KeyExistsAsync($"user:online:{userId}");
        if (isOnline) return ActivityStatus.Online;

        var user = await _userService.GetAsync(userId);
        var elapsed = DateTimeOffset.UtcNow - user.LastActiveAt;

        if (elapsed.TotalMinutes < 30)
            return ActivityStatus.RecentlyActive($"{(int)elapsed.TotalMinutes}m ago");
        if (elapsed.TotalHours < 24)
            return ActivityStatus.RecentlyActive($"{(int)elapsed.TotalHours}h ago");
        if (elapsed.TotalDays < 7)
            return ActivityStatus.LastActive($"{(int)elapsed.TotalDays}d ago");

        return ActivityStatus.Offline;
    }

    public async Task SetOnlineAsync(Guid userId)
    {
        await _redis.SetAsync(
            $"user:online:{userId}", "1", TimeSpan.FromMinutes(5));
    }
}

Read Receipts

When User A opens a chat and reads messages, a Read indicator appears. Configurable: always show (default), matches only, or never (premium feature).

17. Who Liked You Feature

One of the most powerful premium features: see who has already liked you before you swipe on them.

C#
public class WhoLikedYouService
{
    private readonly IRedisCache _redis;
    private readonly IPremiumService _premiumService;

    public async Task<List<LikedByProfile>> GetWhoLikedYouAsync(
        Guid userId, int page = 0, int pageSize = 20)
    {
        if (!await _premiumService.HasFeatureAsync(
            userId, PremiumFeature.WhoLikedYou))
            throw new PremiumRequiredException("Upgrade to see who liked you!");

        var likeKeys = await _redis.ZRangeAsync(
            $"likes:received:{userId}",
            page * pageSize,
            (page + 1) * pageSize - 1);

        var profiles = new List<LikedByProfile>();
        foreach (var likeEntry in likeKeys)
        {
            var swiperId = Guid.Parse(likeEntry.Member);
            var profile = await _profileService.GetPreviewAsync(swiperId);

            profiles.Add(new LikedByProfile
            {
                UserId = swiperId,
                DisplayName = profile.DisplayName,
                PrimaryPhoto = profile.Photos.FirstOrDefault()?.ThumbnailUrl,
                Age = CalculateAge(profile.DateOfBirth),
                IsVerified = profile.IsVerified,
                SharedInterests = profile.Interests
                    .Intersect(await _profileService.GetInterestsAsync(userId))
                    .Select(i => i.Name).ToList(),
                LikedAt = DateTimeOffset.FromUnixTimeSeconds(
                    (long)likeEntry.Score)
            });
        }

        return profiles;
    }

    public async Task RecordLikeAsync(Guid swiperId, Guid swipedId)
    {
        await _redis.ZAddAsync(
            $"likes:received:{swipedId}",
            DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
            swiperId.ToString());
    }
}
Business Impact: Who Liked You is the #1 conversion driver from free to paid. Showing blurred previews increased conversion rates by 35%. Showing the count (12 people liked you!) without revealing identities increased conversion by 22%.

18. Icebreaker Prompts

After a match, users often struggle with first messages. Icebreaker prompts guide the conversation.

Types of Icebreakers

  • Shared Interest: "You both love hiking! What is your favorite trail?"
  • Prompt Reaction: "Respond to their answer about a perfect first date..."
  • Photo Comment: "Ask about their photo in [location]!"
  • Random Question: "If you could have dinner with anyone, who would it be?"
  • Game: "Start with two truths and a lie!"
C#
public class IcebreakerService
{
    private readonly IProfileService _profileService;

    public async Task<Icebreaker> GenerateIcebreakerAsync(
        Guid matchId, Guid userId, Guid matchUserId)
    {
        var userProfile = await _profileService.GetCompleteAsync(userId);
        var matchProfile = await _profileService.GetCompleteAsync(matchUserId);

        var candidates = new List<IcebreakerCandidate>();

        var sharedInterests = userProfile.Interests
            .Intersect(matchProfile.Interests).ToList();
        if (sharedInterests.Any())
        {
            candidates.Add(new IcebreakerCandidate
            {
                Type = IcebreakerType.SharedInterest,
                Text = $"You both love {sharedInterests.First().Name}! " +
                       $"Ask about their favorite {sharedInterests.First().Name.ToLower()}!",
                Priority = 1
            });
        }

        var matchPrompts = matchProfile.Prompts.ToList();
        if (matchPrompts.Any())
        {
            var prompt = matchPrompts.First();
            candidates.Add(new IcebreakerCandidate
            {
                Type = IcebreakerType.PromptReaction,
                Text = $"React to their prompt: \"{prompt.PromptText}\"",
                Priority = 2
            });
        }

        var randomQuestions = await GetRandomQuestionsAsync(3);
        foreach (var q in randomQuestions)
        {
            candidates.Add(new IcebreakerCandidate
            {
                Type = IcebreakerType.Random,
                Text = q,
                Priority = 5
            });
        }

        return candidates.OrderBy(c => c.Priority).First();
    }
}

19. Profile Moderation and Content Guidelines

Moderation operates at multiple levels: automated (ML), human review, and community reporting.

Automated Moderation Rules

SignalActionThreshold
NSFW photo (nudity 85%+)Auto-reject, notify userNudity score 0.85+
NSFW photo (borderline)Flag for human reviewNudity score 0.5-0.85
No face detectedAuto-reject0 faces in all photos
Stock photo detectedFlag for reviewReverse image search match
Underage indicatorsImmediate ban + reportAge under 18 detected
Spam/scam keywordsAuto-reject, flag accountPattern match 0.9+
Multiple accounts (same device)Flag for reviewDevice fingerprint match
Abusive messagesAuto-warn, restrict messagingToxicity 0.9+

Human Review Queue

  • Priority 1 (1 hour): CSAM, minors, violence.
  • Priority 2 (4 hours): Catfishing, impersonation.
  • Priority 3 (24 hours): Borderline content, spam.
  • Priority 4 (48 hours): General profile review requests.

20. Community Guidelines Enforcement

Prohibited Content

  • Nudity or sexually explicit content in profile photos.
  • Hate speech, discrimination, or harassment.
  • Violence, self-harm promotion, or illegal activities.
  • Impersonation or catfishing.
  • Spam, scams, or commercial solicitation.
  • Sharing others private information without consent.
  • Minors or content suggestive of minors.
  • Weapons, drugs, or illegal substances in photos.

Strike System

StrikeConsequenceDuration
1st StrikeWarning notification, content removalN/A
2nd Strike48-hour feature restriction48 hours
3rd Strike7-day account suspension7 days
4th Strike30-day suspension30 days
5th StrikePermanent banPermanent
Severe violationImmediate permanent banPermanent
Zero Tolerance: CSAM, minors on the platform, violence, and terrorism result in immediate permanent ban and mandatory report to law enforcement (NCMEC in the US).

21. Reporting and Banning System

C#
public class ReportingService
{
    private readonly AppDbContext _db;
    private readonly IBanService _banService;
    private readonly INotificationService _notifService;

    public async Task<ReportResult> SubmitReportAsync(
        Guid reporterId, ReportRequest request)
    {
        var existingReport = await _db.Reports
            .FirstOrDefaultAsync(r =>
                r.ReporterId == reporterId
                && r.ReportedId == request.ReportedUserId
                && r.Status == "pending");
        if (existingReport != null)
            throw new DuplicateReportException();

        var report = new Report
        {
            ReportId = Guid.NewGuid(),
            ReporterId = reporterId,
            ReportedId = request.ReportedUserId,
            Reason = request.Reason,
            Description = request.Description,
            Status = "pending",
            CreatedAt = DateTimeOffset.UtcNow
        };

        _db.Reports.Add(report);
        await _db.SaveChangesAsync();

        var recentReports = await _db.Reports
            .CountAsync(r =>
                r.ReportedId == request.ReportedUserId
                && r.CreatedAt > DateTimeOffset.UtcNow.AddDays(-30));

        if (recentReports >= 5)
        {
            await _banService.AutoRestrictAsync(
                request.ReportedUserId,
                "Multiple reports received - pending review");
        }

        var reporterHistory = await _db.Reports
            .CountAsync(r =>
                r.ReporterId == reporterId
                && r.CreatedAt > DateTimeOffset.UtcNow.AddDays(-90));
        if (reporterHistory > 50)
        {
            await FlagForReviewAsync(reporterId,
                "Excessive reporting pattern detected");
        }

        var priority = GetReportPriority(request.Reason, recentReports);
        await _notifService.NotifyModerationTeamAsync(report, priority);

        return new ReportResult
        {
            ReportId = report.ReportId,
            Status = "submitted",
            EstimatedReviewTime = GetEstimatedReviewTime(priority)
        };
    }

    private ReportPriority GetReportPriority(string reason, int recentCount)
    {
        if (reason is "csam" or "minors" or "violence")
            return ReportPriority.Critical;
        if (reason is "catfishing" or "impersonation")
            return ReportPriority.High;
        if (recentCount >= 3)
            return ReportPriority.High;
        return ReportPriority.Normal;
    }
}

Ban Enforcement

  • Device fingerprinting: Store device ID and hardware identifiers.
  • Phone number blocking: Banned phone numbers cannot re-register.
  • Email blocking: Banned email addresses blocked.
  • IP-based detection: Flag accounts from similar IP ranges.
  • Photo hashing: Banned user photos are hashed and blocked from re-upload.
  • Behavioral analysis: ML model detects ban-evading patterns.

22. Analytics and Metrics

Key Metrics

MetricDefinitionTarget
Daily Active UsersUnique users who open the app per dayGrowth 5% MoM
Swipe-to-Match RateMatches / Total likes given1-5%
Match-to-Message RateChats started / Total matches60%+
Response RateMessages received / Messages sent40%+
Session DurationAverage time per session10+ minutes
7-Day RetentionUsers returning within 7 days45%+
30-Day RetentionUsers returning within 30 days30%+
Conversion RatePremium subscribers / DAU5%+
Report RateReports / DAUUnder 0.5%
False Positive RateIncorrectly flagged / Total flaggedUnder 2%

Analytics Pipeline

flowchart LR A[App Events] --> B[Kafka] B --> C[Spark Streaming] C --> D[(Data Lake - S3)] D --> E[Spark Batch] E --> F[(Data Warehouse - Redshift)] F --> G[BI Dashboard - Metabase] F --> H[ML Feature Store] F --> I[Real-time Dashboard - Grafana]

Event Tracking Schema

JSON
{
    "event": "swipe",
    "user_id": "uuid",
    "session_id": "uuid",
    "timestamp": "2024-01-15T10:30:00Z",
    "properties": {
        "action": "like",
        "target_user_id": "uuid",
        "target_elo_range": "1200-1400",
        "target_is_verified": true,
        "time_spent_viewing_ms": 4500,
        "feed_position": 3,
        "device": "ios",
        "app_version": "5.2.1"
    }
}

{
    "event": "match_created",
    "user_id": "uuid",
    "timestamp": "2024-01-15T10:31:00Z",
    "properties": {
        "match_id": "uuid",
        "time_to_match_hours": 2.5,
        "swipe_count_before_match": 45,
        "is_super_like": false,
        "shared_interests": 3,
        "distance_km": 5.2
    }
}

23. A/B Testing Algorithms

Every algorithmic change is tested with A/B experiments before full rollout.

C#
public class ExperimentService
{
    private readonly IRedisCache _redis;
    private readonly IAnalyticsService _analytics;

    public async Task<T> GetVariantAsync<T>(
        Guid userId, string experimentName, Dictionary<string, T> variants)
    {
        var existing = await _redis.HashGetAsync(
            $"experiment:{experimentName}", userId.ToString());
        if (existing.HasValue)
            return variants[existing.ToString()];

        var hash = HashUserIdToExperiment(userId, experimentName);
        var variantName = SelectVariant(hash, variants.Keys.ToList());

        await _redis.HashSetAsync(
            $"experiment:{experimentName}",
            userId.ToString(),
            variantName);

        await _analytics.TrackAsync("experiment_enrolled", new
        {
            experiment = experimentName,
            variant = variantName,
            userId
        });

        return variants[variantName];
    }

    private string SelectVariant(int hash, List<string> variants)
    {
        var bucket = hash % 100;
        if (variants.Count == 2)
            return bucket < 50 ? variants[0] : variants[1];
        var bucketSize = 100 / variants.Count;
        var index = Math.Min(bucket / bucketSize, variants.Count - 1);
        return variants[index];
    }
}

// Usage in Discovery Engine:
var scoringVersion = await _experimentService.GetVariantAsync(
    userId,
    "discovery_scoring_v3",
    new Dictionary<string, Func<UserProfile, UserProfile, double>>
    {
        { "control", (a, b) => OldScoringModel(a, b) },
        { "variant_a", (a, b) => NewScoringModelV3(a, b) }
    });

Active Experiments (Example)

ExperimentHypothesisMetricStatus
Discovery Scoring V3Adding interest overlap weight increases match rateSwipe-to-match rateRunning
Carousel Feed LayoutHorizontal swipe cards increase session timeSession durationCompleted (+8%)
Blurred Likes TeaserShowing blurred profile pics increases conversionFree to Paid conversionRunning
Icebreaker NudgesSuggesting icebreakers increases message rateMatch-to-message rateCompleted (+12%)

24. Push Notifications

Push notifications are critical for re-engagement but must be carefully managed to avoid fatigue.

Notification Types and Priority

TypeChannelPriorityFrequency Cap
New MatchPush + In-AppHighImmediate
New MessagePush + In-AppHighDebounced 5s
Someone Liked YouPush (premium only)MediumMax 3/day
Super Like ReceivedPush + In-AppHighImmediate
Profile ViewIn-App onlyLowBatched hourly
Boost ResultsPush + In-AppMediumAfter boost ends
Daily RecommendationsPushLowMax 1/day
Re-engagementPush + EmailLowMax 1/week
C#
public class NotificationService
{
    private readonly IFirebaseMessaging _fcm;
    private readonly IApplePushNotification _apns;
    private readonly IRedisCache _cache;

    public async Task SendMatchNotificationAsync(
        Guid recipientId, Guid matchUserId, Guid matchId)
    {
        var prefs = await _prefs.GetAsync(recipientId);
        if (!prefs.MatchNotifications) return;

        var today = DateTime.UtcNow.ToString("yyyyMMdd");
        var count = await _cache.IncrementAsync(
            $"notif:daily:{recipientId}:{today}");
        if (count > 20) return;

        if (await _cache.KeyExistsAsync($"user:online:{recipientId}"))
        {
            await SendInAppOnlyAsync(recipientId, "match", matchId);
            return;
        }

        var senderProfile = await _profileService.GetAsync(matchUserId);
        var notification = new PushNotification
        {
            Title = "It is a Match!",
            Body = $"You and {senderProfile.DisplayName} liked each other!",
            Image = senderProfile.PrimaryPhotoUrl,
            Data = new Dictionary<string, string>
            {
                { "type", "match" },
                { "matchId", matchId.ToString() },
                { "action", "open_chat" }
            }
        };

        var deviceToken = await _deviceTokenStore.GetAsync(recipientId);
        if (deviceToken.Platform == "ios")
            await _apns.SendAsync(deviceToken.Token, notification);
        else
            await _fcm.SendAsync(deviceToken.Token, notification);
    }
}
Notification Fatigue: Users who disable notifications have 2x higher churn. We implement smart scheduling: batch low-priority notifications during active hours, avoid notifications during sleep hours, and implement a quiet mode.

25. Privacy Controls

Dating apps handle extremely sensitive personal data. Robust privacy controls are essential.

Privacy Settings Matrix

SettingDefaultPremium Override
Show distanceOnCan hide
Show ageOnCan hide
Show last activeOn (within 24h)Can hide completely
Show online statusOnCan hide
Read receiptsOnCan disable
Block contacts (phone)AvailableN/A
Data download (GDPR)AvailableN/A
Account deletionAvailableN/A

Data Encryption

  • At rest: AES-256 for all PII, photos, and messages.
  • In transit: TLS 1.3 for all communications.
  • Database: Column-level encryption for phone numbers, emails, locations.
  • Photos: Encrypted in S3 with customer-managed KMS keys.
  • Chat messages: End-to-end encryption optional for premium users.
C#
public class PrivacyService
{
    public ProfileResponse SanitizeForViewer(
        UserProfile profile, UserProfile viewer, UserPreferences prefs)
    {
        return new ProfileResponse
        {
            UserId = profile.UserId,
            DisplayName = profile.DisplayName,
            Age = prefs.HideAge
                ? null
                : CalculateAge(profile.DateOfBirth),
            Distance = prefs.HideDistance
                ? null
                : CalculateDistanceKm(
                    profile.Latitude, profile.Longitude,
                    viewer.Latitude, viewer.Longitude),
            Bio = profile.Bio,
            Photos = profile.Photos
                .OrderBy(p => p.SortOrder)
                .Select(p => new PhotoResponse
                {
                    Url = p.PhotoUrl,
                    IsVerified = p.IsVerified
                }).ToList(),
            Prompts = profile.Prompts,
            Interests = profile.Interests,
            IsVerified = profile.IsVerified,
            LastActive = prefs.HideActivityStatus
                ? null
                : profile.LastActiveAt
        };
    }
}

26. Monitoring and Observability

Monitoring Stack

  • Metrics: Prometheus + Grafana for system metrics.
  • Logging: ELK Stack for centralized logging.
  • Tracing: Jaeger for distributed tracing.
  • Alerting: PagerDuty for on-call rotation.

Critical Alerts

AlertConditionSeverity
High Error Rate5xx errors above 1% for 5 minCritical
Chat Latency SpikeP99 above 500ms for 2 minCritical
Swipe Processing LagKafka consumer lag above 10KHigh
Feed Load LatencyP95 above 300ms for 5 minHigh
Redis MemoryUsage above 80%High
Database ConnectionsPool exhaustion above 90%Critical
Match Rate DropDaily match rate below 50% of rolling avgMedium
CSAM DetectionAny positive matchCritical
C#
public class SwipeMetrics
{
    private readonly Counter _swipeCounter;
    private readonly Histogram _swipeLatency;
    private readonly Gauge _activeUsers;
    private readonly Counter _matchCounter;

    public SwipeMetrics(IMetricsFactory metrics)
    {
        _swipeCounter = metrics.CreateCounter(
            "swipes_total",
            "Total number of swipes",
            new[] { "action", "is_premium" });

        _swipeLatency = metrics.CreateHistogram(
            "swipe_latency_milliseconds",
            "Swipe processing latency in ms",
            buckets: new[] { 10.0, 25.0, 50.0, 100.0, 250.0, 500.0 });

        _activeUsers = metrics.CreateGauge(
            "active_users_current",
            "Current number of active users");

        _matchCounter = metrics.CreateCounter(
            "matches_created_total",
            "Total matches created",
            new[] { "is_super_like", "shared_interests" });
    }

    public void RecordSwipe(string action, bool isPremium, double latencyMs)
    {
        _swipeCounter.WithLabels(action, isPremium.ToString()).Inc();
        _swipeLatency.Observe(latencyMs);
    }

    public void RecordMatch(bool isSuperLike, int sharedInterests)
    {
        _matchCounter.WithLabels(
            isSuperLike.ToString(),
            sharedInterests > 0 ? "yes" : "no").Inc();
    }
}

27. Security

Threat Model

ThreatImpactMitigation
Account takeoverHighMFA, SMS verification, rate limiting
Data breachCriticalEncryption at rest, audit logs, minimal retention
Bot/scam accountsHighCAPTCHA, device fingerprinting, behavioral ML
Stalking via locationCriticalLocation fuzzing, distance rounding, hide option
Image-based abuseHighPhoto moderation, hash matching, DMCA takedowns
DoS/DDoSHighAWS Shield, rate limiting, WAF rules
API abuseMediumAPI keys, OAuth 2.0, request signing
Insider threatsCriticalLeast privilege, audit trails, data masking

Authentication Flow

C#
public class AuthService
{
    private readonly IPhoneVerification _phoneVerify;
    private readonly IJwtTokenService _jwtService;
    private readonly IBanService _banService;

    public async Task<AuthResult> LoginWithPhoneAsync(
        string phoneNumber, string verificationCode)
    {
        var codeValid = await _phoneVerify.VerifyCodeAsync(
            phoneNumber, verificationCode);
        if (!codeValid)
            throw new InvalidCodeException();

        if (await _banService.IsPhoneBannedAsync(phoneNumber))
            throw new AccountBannedException("This phone number is banned");

        var user = await _db.Users
            .FirstOrDefaultAsync(u => u.PhoneNumber == phoneNumber);

        if (user == null)
        {
            var deviceFingerprint = GetCurrentDeviceFingerprint();
            if (await _banService.IsDeviceBannedAsync(deviceFingerprint))
                throw new AccountBannedException("Device is banned");
            user = await CreateNewUserAsync(phoneNumber);
        }

        if (user.Status == "banned")
            throw new AccountBannedException("Your account has been banned");

        var accessToken = await _jwtService.GenerateAccessTokenAsync(user);
        var refreshToken = await _jwtService.GenerateRefreshTokenAsync(user);

        return new AuthResult
        {
            AccessToken = accessToken,
            RefreshToken = refreshToken,
            IsNewUser = user.CompletedSetup == false,
            UserId = user.UserId
        };
    }
}

28. Compliance

Age Verification

  • Users must be 18+ to create an account (21+ in some jurisdictions).
  • Phone number verification provides basic age gating.
  • ID verification (optional) confirms exact age.
  • Birth date required during signup and validated against ID if provided.
  • Annual re-verification for accounts flagged by ML models.

Data Privacy Regulations

RegulationRequirementsOur Implementation
GDPR (EU)Data portability, right to deletion, consentData export tool, 30-day deletion, cookie consent
CCPA (California)Do Not Sell, data access requestsPrivacy dashboard, opt-out mechanisms
COPPA (US)No users under 13Age gate at registration + ML detection
PIPL (China)Data localization, consentRegional data storage, explicit consent flows
LGPD (Brazil)Data protection officer, breach notificationDPO appointed, 72-hour breach notification

Data Retention Policy

  • Active accounts: Data retained while account is active.
  • Inactive accounts (12 months): Reminder emails, then 30-day grace period.
  • Deleted accounts: Hard-deleted within 30 days, except legal holds.
  • Chat messages: Retained for 2 years, then auto-deleted.
  • Photos: Deleted within 7 days of account deletion.
  • Swipe data: Anonymized after 12 months, deleted after 24 months.
  • Analytics: Aggregated data retained indefinitely, PII deleted.
Legal Hold: Data subject to legal proceedings, law enforcement requests, or safety investigations is preserved beyond normal retention periods. All legal hold requests go through the legal team with proper documentation.

29. API Design

RESTful API design with versioning, authentication, and rate limiting.

Core Endpoints

MethodEndpointDescriptionAuth
POST/api/v1/auth/phone/send-codeSend verification SMSNo
POST/api/v1/auth/phone/verifyVerify code, get tokensNo
PUT/api/v1/profileUpdate profileYes
POST/api/v1/profile/photosUpload photoYes
GET/api/v1/feed?page={n}Get swipe feedYes
POST/api/v1/swipesRecord swipeYes
POST/api/v1/swipes/rewindUndo last swipeYes (Premium)
GET/api/v1/matchesGet all matchesYes
GET/api/v1/matches/{id}/messagesGet chat historyYes
POST/api/v1/reportsSubmit reportYes
POST/api/v1/blocksBlock userYes
POST/api/v1/premium/boostActivate boostYes
GET/api/v1/premium/likesWho liked youYes (Premium)
POST/api/v1/verification/photoSubmit verification selfieYes
DELETE/api/v1/accountDelete accountYes
GET/api/v1/account/data-exportDownload all user dataYes
WS/hubs/chatSignalR WebSocketYes

Example Request/Response

HTTP
POST /api/v1/swipes
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/json

{
    "target_id": "550e8400-e29b-41d4-a716-446655440000",
    "action": "like"
}

---

HTTP/1.1 200 OK
{
    "swipe_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "is_match": true,
    "match_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "daily_swipes_remaining": 87,
    "super_likes_remaining": 1
}

Rate Limits

EndpointFree TierPremium Tier
GET /feed60 req/min120 req/min
POST /swipes100/dayUnlimited
POST /auth/send-code3/hour3/hour
POST /profile/photos10/day50/day

30. Cost Estimation

Estimated monthly costs for a platform with 10M DAU and 2M concurrent users at peak.

ServiceInstance/ConfigMonthly Cost
Application Servers (C#/.NET)50 x c6i.2xlarge$34,000
PostgreSQL (RDS Multi-AZ)db.r6g.2xlarge x 3$6,500
Redis Cluster6 x r6g.xlarge nodes$4,800
DynamoDB (Chat Messages)On-demand$8,000
Elasticsearch10 x r6g.xlarge.search$7,200
Kafka (MSK)6 x kafka.m5.2xlarge$5,400
S3 (Photos/Videos, 500TB)Standard + Intelligent-Tiering$11,500
CloudFront CDN100TB transfer/month$8,500
SignalR WebSockets2M concurrent connections$3,000
ML Inference (GPU)4 x g5.xlarge (recommendations, moderation)$5,600
Third-party APIsSMS, moderation, payment processing$8,000
Monitoring (Datadog/Grafana Cloud)Full stack observability$4,000
Neo4j (Graph)Cluster of 3 nodes$3,000
Data Warehouse (Redshift)ra3.xlplus 2-node cluster$4,000
CI/CD and DevOpsGitHub Actions, Docker, Kubernetes$2,500
Support and Staffing24/7 on-call, moderation team$15,000
Total Estimated~$131,000/month
Cost Optimization: Use spot instances for non-critical workloads (30-60% savings), reserved instances for steady-state services (40% savings), S3 Intelligent-Tiering for photo storage (20% savings), and auto-scaling to handle peak/off-peak differences. Realistic optimized cost: ~$85,000-95,000/month.

Revenue Projections

Revenue StreamAssumptionMonthly Revenue
Premium Subscriptions5% conversion at $20 avg/mo$10,000,000
Boost Purchases2% of DAU x $5 each$1,000,000
Super Like Packs1% of DAU x $3 each$300,000
Advertising (free tier)CPM $10, 500M impressions$5,000,000
Total~$16,300,000/month

31. Testing Strategy

Testing Pyramid

flowchart TB A[Unit Tests] --> B[Integration Tests] B --> C[Contract Tests] C --> D[E2E Tests] D --> E[Load Tests] E --> F[Chaos Engineering]

Test Categories

TypeCoverage TargetToolsWhat to Test
Unit Tests80%+xUnit, MoqElo calculation, scoring, filtering, geohash
Integration Tests70%+Testcontainers, WebApplicationFactoryAPI endpoints, database queries, Redis operations
Contract TestsAll APIsPactClient-server API contracts
E2E TestsCritical pathsPlaywright, AppiumSign up, swipe, match, chat flow
Load TestsN/Ak6, Gatling50K swipes/sec, 5K messages/sec throughput
Chaos TestsN/AChaos Monkey, LitmusRedis failure, Kafka lag, database failover
C#
public class EloRatingServiceTests
{
    private readonly EloRatingService _sut;
    private readonly Mock<IRedisCache> _redisMock;

    public EloRatingServiceTests()
    {
        _redisMock = new Mock<IRedisCache>();
        _sut = new EloRatingService(_redisMock.Object, /* db mock */);
    }

    [Fact]
    public async Task UpdateElo_LikeFromLowerEloUser_IncreasesElo()
    {
        // Arrange
        var swiperElo = 1000.0;
        var swipedElo = 1200.0;

        _redisMock.Setup(r => r.HashGetAsync("elo:scores", It.IsAny<string>()))
            .ReturnsAsync((string key, string field) =>
                field == swiperElo.ToString() ? swiperElo : swipedElo);

        // Act
        await _sut.UpdateEloAfterSwipeAsync(
            Guid.NewGuid(), Guid.NewGuid(), SwipeAction.Like);

        // Assert
        _redisMock.Verify(r => r.HashSetAsync(
            "elo:scores",
            It.IsAny<string>(),
            It.Is<string>(v => double.Parse(v) > swipedElo),
            It.IsAny<TimeSpan?>()), Times.Once);
    }

    [Theory]
    [InlineData(SwipeAction.Like, 1.0)]
    [InlineData(SwipeAction.SuperLike, 3.0)]
    [InlineData(SwipeAction.Pass, 0.0)]
    public async Task UpdateElo_VariousActions_CalculatesCorrectScore(
        SwipeAction action, double expectedWeight)
    {
        // Test that each action type produces the expected weight
        // in the Elo calculation
    }
}

public class DiscoveryEngineTests
{
    [Fact]
    public async Task GetFeed_ExcludesSwipedUsers_NotInResults()
    {
        // Arrange: user has swiped on user B
        // Act: get feed
        // Assert: user B is not in the results
    }

    [Fact]
    public async Task GetFeed_AppliesDistanceFilter_RespectsMaxRadius()
    {
        // Arrange: user has 50km distance preference
        // Act: get feed
        // Assert: all results are within 50km
    }

    [Fact]
    public async Task GetFeed_NewUser_First48HoursGetsDiverseAudience()
    {
        // Arrange: user signed up 24 hours ago
        // Act: get feed
        // Assert: feed contains diverse Elo range
    }
}
Test Data Strategy: We maintain a synthetic data generator that creates realistic user profiles (10M+) with correlated attributes (age-appropriate bios, geographically distributed, realistic Elo distributions). This enables load testing against production-like data volumes.

32. Interview Q&A

Common System Design Interview Questions

Q: How would you handle the cold start problem for new users?

A: For new users with no history, we use multiple strategies: (1) Show them to a random sample of active users for 48 hours to collect signal. (2) Use demographic-based initial ranking (age, location, profile quality). (3) Apply a new user boost in the algorithm. (4) Use prompt responses and interest selections as initial features for collaborative filtering. (5) A/B test different cold start strategies to optimize time-to-first-match.

Q: How do you prevent the Elo system from creating unfair outcomes?

A: We implement several fairness mechanisms: (1) New user boost showing profiles to diverse audiences. (2) Periodic Elo decay toward the median. (3) Diversity injection where 10% of feed comes from random mid-tier profiles. (4) Anti-catfishing penalties. (5) Regular auditing for demographic bias. (6) Business logic caps that prevent any single user from dominating the feed above a threshold.

Q: How would you design the chat system to handle 2M concurrent connections?

A: We use ASP.NET Core SignalR with a Redis backplane for horizontal scaling across multiple hub instances. Messages are persisted to DynamoDB for durability and delivered through Kafka for async processing (moderation, notifications, analytics). Each hub instance handles about 10K connections. We use sticky sessions and connection affinity. The Redis backplane handles message fanout between instances.

Q: How do you handle the fairness of showing profiles to premium vs free users?

A: Premium users get more swipes, advanced filters, and features like Who Liked You, but the core feed algorithm is the same for everyone. Boost gives temporary visibility increase (10x for 30 min). Priority Likes (Platinum tier) get shown first in a user queue, but we cap this to prevent free users from never being seen. We monitor the like distribution Gini coefficient and adjust caps accordingly.

Q: How do you handle profile photo moderation at scale?

A: We use a multi-stage pipeline: (1) NSFW classifier (custom trained model) screens all uploads. (2) PhotoDNA hash matching for known CSAM databases. (3) Face detection to ensure real photos. (4) Reverse image search to detect catfishing/stock photos. (5) Liveness detection to reject screenshots. All automated decisions above 0.85 confidence are auto-rejected. Borderline cases (0.5-0.85) go to human review within 4 hours.

Q: How would you design the Who Liked You feature efficiently?

A: We store likes in a Redis sorted set per user (ZADD with timestamp as score). This gives us O(log N) insert and ordered retrieval. When a user who has premium opens Who Liked You, we fetch from Redis with pagination. For users who are not premium, we store the count only. We also implement a bloom filter for fast membership testing when checking if someone already liked you in the feed.

Q: How do you ensure location privacy while still enabling proximity-based matching?

A: We never expose exact coordinates to other users. Distance is calculated server-side and rounded to the nearest km/mile. We use geohash indexing for efficient proximity queries without exposing locations. Users can hide distance entirely (premium). We also implement smart distance that shows general area (5km radius) instead of exact distance. GPS data is encrypted at rest with column-level encryption.

Q: Walk me through what happens when two users match.

A: (1) User A swipes right on User B. Swipe Service validates rate limits, records the action, publishes a SwipeEvent to Kafka. (2) Match Service consumes the event and checks for a reverse swipe. If User B already swiped right on User A, a Match record is created. (3) Both users are removed from each other feed queues. (4) Match notification sent to both via push and SignalR. (5) An icebreaker suggestion is generated based on shared interests/prompts and sent as a system message. (6) Analytics events are emitted for match tracking. The entire flow completes in under 200ms end-to-end.

Q: How do you handle profanity and harassment in chat messages?

A: Every message goes through a real-time moderation pipeline before delivery: NLP toxicity classifier, spam detector, PII detector, and URL scanner. Messages above 0.9 toxicity are auto-blocked. Borderline messages go to human review. Images go through NSFW classifier and CSAM hash matching. Users can block others (soft-deletes messages, prevents future contact). The strike system escalates consequences: warning, 48h restriction, 7-day ban, 30-day ban, permanent ban.

Q: How would you design the A/B testing framework?

A: We use consistent hashing for user-to-variant assignment stored in Redis. Each experiment has a control and variant(s). We track exposure (enrollment) and conversion metrics via the analytics pipeline. Statistical significance is calculated using a Bayesian approach with a minimum sample size of 10K users per variant. Experiments run for at least 2 weeks before declaring results. Guardrail metrics (retention, match rate) are monitored to prevent regressions.

Q: What metrics would you track to measure the health of the dating platform?

A: Key metrics across four dimensions: (1) Engagement: DAU, session duration, swipes per session, messages per match. (2) Match Quality: swipe-to-match rate, match-to-message rate, response rate, match-to-date rate. (3) Retention: D1, D7, D30 retention, churn rate. (4) Business: conversion rate (free to paid), ARPU, LTV, CAC. We also track safety metrics: report rate, false positive rate, ban rate. A healthy platform has a 1-5% swipe-to-match rate and 60%+ match-to-message rate.

Key Takeaway: Designing a social network and dating platform requires balancing user experience, algorithmic fairness, safety, privacy, and business objectives. The system must handle massive scale (50K swipes/second) while maintaining low latency (sub-200ms feed loads) and ensuring user safety through multi-layered moderation. Every design decision should be A/B tested and monitored for its impact on match quality, user satisfaction, and platform health.

33. Algorithm Fairness and Bias Mitigation

Dating algorithms can inadvertently create unfair outcomes by amplifying existing popularity biases. A small percentage of profiles receive the majority of likes while many users get almost no visibility. The fairness engine monitors distribution metrics and injects diversity to ensure all users have reasonable exposure, which directly impacts user retention and platform health.

public class AlgorithmFairnessMonitor
{
    private readonly IAnalyticsService _analytics;

    public FairnessReport CalculateFairnessMetrics(string cityId, DateTime date)
    {
        var likeDistribution = _analytics.GetLikeDistribution(cityId, date);
        var viewDistribution = _analytics.GetViewDistribution(cityId, date);

        // Gini coefficient: 0 = perfect equality, 1 = maximum inequality
        var likeGini = CalculateGini(likeDistribution);
        var viewGini = CalculateGini(viewDistribution);

        // Percentile analysis
        var p10Likes = Percentile(likeDistribution, 10);
        var p50Likes = Percentile(likeDistribution, 50);
        var p90Likes = Percentile(likeDistribution, 90);

        return new FairnessReport
        {
            CityId = cityId,
            Date = date,
            LikeGiniCoefficient = likeGini,
            ViewGiniCoefficient = viewGini,
            Top10PercentShare = likeDistribution
                .OrderByDescending(x => x).Take(
                    likeDistribution.Length / 10).Sum() /
                    likeDistribution.Sum(),
            MedianLikes = p50Likes,
            ExposureRatio = p90Likes / Math.Max(1, p10Likes),
            NeedsRebalancing = likeGini > 0.7
        };
    }

    private double CalculateGini(double[] values)
    {
        if (values.Length == 0 || values.Sum() == 0) return 0;
        var sorted = values.OrderBy(x => x).ToArray();
        int n = sorted.Length;
        double sumOfDifferences = 0;
        for (int i = 0; i < n; i++)
        {
            sumOfDifferences += (2 * (i + 1) - n - 1) * sorted[i];
        }
        return sumOfDifferences / (n * sorted.Sum());
    }
}

Fairness Metrics Targets

MetricTargetAlert Threshold
Like Gini Coefficient< 0.65> 0.7 (inject diversity)
View Gini Coefficient< 0.55> 0.65
Bottom 50% Visibility> 15% of total views< 10%
New User First-Like Time< 24 hours> 48 hours
Diversity Injection Rate10-15% of feedAdjust based on Gini

Social Network and Dating Platform - Senior+ Guide | Ayodhyya