How to Design a Social Network & Dating Platform
A Senior+ Guide to Building Tinder, Hinge & Bumble at Scale
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
| Requirement | Target |
|---|---|
| Latency (feed load) | Less than 200ms p99 |
| Latency (swipe action) | Less than 100ms p99 |
| Latency (chat message delivery) | Less than 150ms p99 |
| Availability | 99.95% |
| Throughput | 50K swipes/sec peak, 5K messages/sec |
| Data durability | 99.999999999% (11 nines) |
| Storage (photos) | ~500TB growing 15%/year |
| Concurrent users | 10M DAU, 2M simultaneous |
2. High-Level Architecture
Service Responsibilities
| Service | Responsibility | Technology |
|---|---|---|
| Profile Service | CRUD for user profiles, photo management | C# / ASP.NET, PostgreSQL |
| Discovery Service | Rank and serve the swipe feed | C# / .NET, Redis, Neo4j |
| Swipe Service | Record like/pass/super-like actions | C# / .NET, Kafka |
| Match Service | Detect mutual matches, create match records | C# / .NET, PostgreSQL |
| Chat Service | Real-time messaging via WebSocket | C# / SignalR, DynamoDB |
| Search/Geo Service | Geohash indexing, proximity queries | Elasticsearch, Redis |
| Media Service | Image/video upload, transcoding, CDN | C#, S3, Lambda |
| Notification Service | Push, in-app, email notifications | C#, FCM, APNs, Kafka |
| Moderation Service | Content review, NSFW detection, text analysis | C#, ML, third-party APIs |
| Premium Service | Subscriptions, boosts, billing | C#, Stripe |
| Analytics Service | Event tracking, funnel analysis, A/B testing | Kafka, Spark, Redshift |
| Recommendation Engine | ML-driven profile ranking and suggestions | Python (training), C# (serving) |
| Elo Rating Service | Compute and update attractiveness scores | C#, 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}
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:
- Client requests a pre-signed upload URL from the Media Service.
- Client uploads the original photo directly to S3.
- An S3 event triggers a Lambda function that generates thumbnails, runs NSFW detection, performs face detection, and stores moderation status.
- 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:
| Prompt | Example 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
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.
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;
}
}
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 Range | Percentile | Feed Position | Description |
|---|---|---|---|
| 600-900 | Bottom 10% | Rarely shown | Low engagement, possibly new or incomplete profiles |
| 900-1100 | 10th-40th | Occasionally | Average engagement level |
| 1100-1300 | 40th-70th | Frequently | Above average, healthy engagement |
| 1300-1600 | 70th-95th | Very frequently | High engagement, attractive profiles |
| 1600-2000 | Top 5% | Dominant | Extremely high engagement |
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
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);
}
}
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
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
}
10. Messaging Safety and Content Moderation
Safety is paramount. Every message and image must be screened for inappropriate content, harassment, spam, and scams.
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;
}
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).
| Filter | Type | Free Tier | Premium Tier |
|---|---|---|---|
| Age Range | Hard | 18-65, max range 30 | 18-100, unlimited range |
| Maximum Distance | Hard | Up to 100 miles | Unlimited (Global mode) |
| Gender / Show Me | Hard | Men, Women, Everyone | Custom options |
| Height | Soft | Not available | Premium only |
| Education Level | Soft | Not available | Premium only |
| Verified Only | Soft | Not available | Premium only |
| Interests | Soft | Up to 3 preferred | Unlimited |
| Zodiac / Lifestyle | Soft | Not available | Premium 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)
- User is prompted to take a selfie mimicking a specific pose.
- The selfie is compared against all profile photos using a face embedding model (FaceNet or ArcFace).
- If the similarity score exceeds 0.85 across at least 3 profile photos, verification passes.
- 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.
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());
}
}
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
| Signal | Action | Threshold |
|---|---|---|
| NSFW photo (nudity 85%+) | Auto-reject, notify user | Nudity score 0.85+ |
| NSFW photo (borderline) | Flag for human review | Nudity score 0.5-0.85 |
| No face detected | Auto-reject | 0 faces in all photos |
| Stock photo detected | Flag for review | Reverse image search match |
| Underage indicators | Immediate ban + report | Age under 18 detected |
| Spam/scam keywords | Auto-reject, flag account | Pattern match 0.9+ |
| Multiple accounts (same device) | Flag for review | Device fingerprint match |
| Abusive messages | Auto-warn, restrict messaging | Toxicity 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
| Strike | Consequence | Duration |
|---|---|---|
| 1st Strike | Warning notification, content removal | N/A |
| 2nd Strike | 48-hour feature restriction | 48 hours |
| 3rd Strike | 7-day account suspension | 7 days |
| 4th Strike | 30-day suspension | 30 days |
| 5th Strike | Permanent ban | Permanent |
| Severe violation | Immediate permanent ban | Permanent |
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
| Metric | Definition | Target |
|---|---|---|
| Daily Active Users | Unique users who open the app per day | Growth 5% MoM |
| Swipe-to-Match Rate | Matches / Total likes given | 1-5% |
| Match-to-Message Rate | Chats started / Total matches | 60%+ |
| Response Rate | Messages received / Messages sent | 40%+ |
| Session Duration | Average time per session | 10+ minutes |
| 7-Day Retention | Users returning within 7 days | 45%+ |
| 30-Day Retention | Users returning within 30 days | 30%+ |
| Conversion Rate | Premium subscribers / DAU | 5%+ |
| Report Rate | Reports / DAU | Under 0.5% |
| False Positive Rate | Incorrectly flagged / Total flagged | Under 2% |
Analytics Pipeline
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)
| Experiment | Hypothesis | Metric | Status |
|---|---|---|---|
| Discovery Scoring V3 | Adding interest overlap weight increases match rate | Swipe-to-match rate | Running |
| Carousel Feed Layout | Horizontal swipe cards increase session time | Session duration | Completed (+8%) |
| Blurred Likes Teaser | Showing blurred profile pics increases conversion | Free to Paid conversion | Running |
| Icebreaker Nudges | Suggesting icebreakers increases message rate | Match-to-message rate | Completed (+12%) |
24. Push Notifications
Push notifications are critical for re-engagement but must be carefully managed to avoid fatigue.
Notification Types and Priority
| Type | Channel | Priority | Frequency Cap |
|---|---|---|---|
| New Match | Push + In-App | High | Immediate |
| New Message | Push + In-App | High | Debounced 5s |
| Someone Liked You | Push (premium only) | Medium | Max 3/day |
| Super Like Received | Push + In-App | High | Immediate |
| Profile View | In-App only | Low | Batched hourly |
| Boost Results | Push + In-App | Medium | After boost ends |
| Daily Recommendations | Push | Low | Max 1/day |
| Re-engagement | Push + Email | Low | Max 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);
}
}
25. Privacy Controls
Dating apps handle extremely sensitive personal data. Robust privacy controls are essential.
Privacy Settings Matrix
| Setting | Default | Premium Override |
|---|---|---|
| Show distance | On | Can hide |
| Show age | On | Can hide |
| Show last active | On (within 24h) | Can hide completely |
| Show online status | On | Can hide |
| Read receipts | On | Can disable |
| Block contacts (phone) | Available | N/A |
| Data download (GDPR) | Available | N/A |
| Account deletion | Available | N/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
| Alert | Condition | Severity |
|---|---|---|
| High Error Rate | 5xx errors above 1% for 5 min | Critical |
| Chat Latency Spike | P99 above 500ms for 2 min | Critical |
| Swipe Processing Lag | Kafka consumer lag above 10K | High |
| Feed Load Latency | P95 above 300ms for 5 min | High |
| Redis Memory | Usage above 80% | High |
| Database Connections | Pool exhaustion above 90% | Critical |
| Match Rate Drop | Daily match rate below 50% of rolling avg | Medium |
| CSAM Detection | Any positive match | Critical |
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
| Threat | Impact | Mitigation |
|---|---|---|
| Account takeover | High | MFA, SMS verification, rate limiting |
| Data breach | Critical | Encryption at rest, audit logs, minimal retention |
| Bot/scam accounts | High | CAPTCHA, device fingerprinting, behavioral ML |
| Stalking via location | Critical | Location fuzzing, distance rounding, hide option |
| Image-based abuse | High | Photo moderation, hash matching, DMCA takedowns |
| DoS/DDoS | High | AWS Shield, rate limiting, WAF rules |
| API abuse | Medium | API keys, OAuth 2.0, request signing |
| Insider threats | Critical | Least 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
| Regulation | Requirements | Our Implementation |
|---|---|---|
| GDPR (EU) | Data portability, right to deletion, consent | Data export tool, 30-day deletion, cookie consent |
| CCPA (California) | Do Not Sell, data access requests | Privacy dashboard, opt-out mechanisms |
| COPPA (US) | No users under 13 | Age gate at registration + ML detection |
| PIPL (China) | Data localization, consent | Regional data storage, explicit consent flows |
| LGPD (Brazil) | Data protection officer, breach notification | DPO 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.
29. API Design
RESTful API design with versioning, authentication, and rate limiting.
Core Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/v1/auth/phone/send-code | Send verification SMS | No |
| POST | /api/v1/auth/phone/verify | Verify code, get tokens | No |
| PUT | /api/v1/profile | Update profile | Yes |
| POST | /api/v1/profile/photos | Upload photo | Yes |
| GET | /api/v1/feed?page={n} | Get swipe feed | Yes |
| POST | /api/v1/swipes | Record swipe | Yes |
| POST | /api/v1/swipes/rewind | Undo last swipe | Yes (Premium) |
| GET | /api/v1/matches | Get all matches | Yes |
| GET | /api/v1/matches/{id}/messages | Get chat history | Yes |
| POST | /api/v1/reports | Submit report | Yes |
| POST | /api/v1/blocks | Block user | Yes |
| POST | /api/v1/premium/boost | Activate boost | Yes |
| GET | /api/v1/premium/likes | Who liked you | Yes (Premium) |
| POST | /api/v1/verification/photo | Submit verification selfie | Yes |
| DELETE | /api/v1/account | Delete account | Yes |
| GET | /api/v1/account/data-export | Download all user data | Yes |
| WS | /hubs/chat | SignalR WebSocket | Yes |
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
| Endpoint | Free Tier | Premium Tier |
|---|---|---|
| GET /feed | 60 req/min | 120 req/min |
| POST /swipes | 100/day | Unlimited |
| POST /auth/send-code | 3/hour | 3/hour |
| POST /profile/photos | 10/day | 50/day |
30. Cost Estimation
Estimated monthly costs for a platform with 10M DAU and 2M concurrent users at peak.
| Service | Instance/Config | Monthly Cost |
|---|---|---|
| Application Servers (C#/.NET) | 50 x c6i.2xlarge | $34,000 |
| PostgreSQL (RDS Multi-AZ) | db.r6g.2xlarge x 3 | $6,500 |
| Redis Cluster | 6 x r6g.xlarge nodes | $4,800 |
| DynamoDB (Chat Messages) | On-demand | $8,000 |
| Elasticsearch | 10 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 CDN | 100TB transfer/month | $8,500 |
| SignalR WebSockets | 2M concurrent connections | $3,000 |
| ML Inference (GPU) | 4 x g5.xlarge (recommendations, moderation) | $5,600 |
| Third-party APIs | SMS, 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 DevOps | GitHub Actions, Docker, Kubernetes | $2,500 |
| Support and Staffing | 24/7 on-call, moderation team | $15,000 |
| Total Estimated | ~$131,000/month |
Revenue Projections
| Revenue Stream | Assumption | Monthly Revenue |
|---|---|---|
| Premium Subscriptions | 5% conversion at $20 avg/mo | $10,000,000 |
| Boost Purchases | 2% of DAU x $5 each | $1,000,000 |
| Super Like Packs | 1% 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
Test Categories
| Type | Coverage Target | Tools | What to Test |
|---|---|---|---|
| Unit Tests | 80%+ | xUnit, Moq | Elo calculation, scoring, filtering, geohash |
| Integration Tests | 70%+ | Testcontainers, WebApplicationFactory | API endpoints, database queries, Redis operations |
| Contract Tests | All APIs | Pact | Client-server API contracts |
| E2E Tests | Critical paths | Playwright, Appium | Sign up, swipe, match, chat flow |
| Load Tests | N/A | k6, Gatling | 50K swipes/sec, 5K messages/sec throughput |
| Chaos Tests | N/A | Chaos Monkey, Litmus | Redis 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
}
}
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.
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
| Metric | Target | Alert 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 Rate | 10-15% of feed | Adjust based on Gini |