Design Tinder: The Complete Dating App System Design Guide — A Senior+ Guide
Tinder revolutionized modern dating by introducing the swipe mechanic, a deceptively simple interaction that masks an extraordinarily complex distributed system beneath. As of 2026, Tinder boasts over 75 million monthly active users across 190 countries, processes more than 2 billion profile views daily, and facilitates approximately 1.5 billion swipes per day. Behind the sleek mobile interface lies a sophisticated backend infrastructure that must solve some of the hardest problems in distributed systems: real-time geolocation indexing at global scale, low-latency swipe processing with exactly-once semantics, intelligent recommendation algorithms that balance engagement with fairness, and real-time match notification delivery to millions of concurrent users.
This guide is written for senior engineers and system design interview candidates who want to understand every layer of the Tinder stack in depth. We will dissect the architecture from the ground up, starting with capacity estimation and functional requirements, then diving deep into geo-indexing with geohash, the swipe processing pipeline with Kafka, the Elo-based recommendation engine with modern ML enhancements, real-time WebSocket notification delivery, Cassandra-backed ephemeral chat, and the strategies that keep Tinder operational during Valentine's Day traffic spikes that can reach 10x normal volume. Every design decision is backed by concrete C# code implementations, Mermaid architecture diagrams, and detailed HTML tables that compare trade-offs across different technology choices.
1. Requirements and Capacity Estimation
Before designing any system, we must establish the functional and non-functional requirements and derive capacity estimates that drive every downstream architectural decision. Tinder's usage patterns are unique among social applications: the dominant operation is profile discovery (read-heavy, geo-indexed), followed by swipes (write-heavy, append-only), and then chat messaging (real-time bidirectional). Understanding the relative volume of each operation is critical because it determines our sharding strategy, caching layer design, and database technology choices.
Functional Requirements
- Profile Discovery: Users view a deck of potential matches filtered by location, age preference, gender preference, and distance radius. The deck should be personalized based on the user's own desirability score and historical swiping behavior.
- Swipe (Like / Pass / Super Like): Users can swipe right (like), swipe left (pass), or send a Super Like on each profile. Swipe responses must return within 200ms to maintain the fluid user experience.
- Match Notification: When two users mutually like each other, both must be notified in real-time via an in-app notification, push notification, or both.
- Chat: Matched users can exchange text messages, images, GIFs, and reactions in real-time. Messages are ephemeral by default with a 30-day TTL.
- User Profile Management: Users can create and edit their profile, upload photos, write a bio, set preferences, and connect social accounts like Instagram and Spotify.
- Geolocation Updates: The system continuously tracks user locations to serve nearby profiles. Location data is updated on app open and periodically while the app is in the foreground.
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Swipe API Latency | < 200ms p99 | Swipe interaction must feel instantaneous |
| Profile Load Latency | < 500ms p99 | Profiles should appear quickly when scrolling |
| Match Notification Latency | < 1 second end-to-end | Real-time excitement is core to the experience |
| Chat Message Delivery | < 200ms p99 | Conversations must feel real-time |
| Availability | 99.99% (52 min/year downtime) | Dating is time-sensitive; downtime means lost connections |
| Data Durability | 99.999999% (8 nines) | Messages and match data must never be lost |
| Peak Load Handling | 10x Valentine's Day spike | The biggest traffic day of the year for dating apps |
Capacity Estimation
C#
public static class TinderScaleEstimation
{
public const long MonthlyActiveUsers = 75_000_000;
public const long DailyActiveUsers = 10_000_000;
public const int ProfilesPerUserPerDay = 100;
public const double SwipeRate = 0.15;
public const double LikeRate = 0.10;
public const double MatchRate = 0.10;
public const int MessagesPerMatch = 20;
public static readonly long TotalProfileViewsPerDay = DailyActiveUsers * ProfilesPerUserPerDay;
public static readonly long TotalSwipesPerDay = (long)(TotalProfileViewsPerDay * SwipeRate);
public static readonly long TotalLikesPerDay = (long)(TotalSwipesPerDay * LikeRate);
public static readonly long TotalMatchesPerDay = (long)(TotalLikesPerDay * MatchRate);
public static readonly long TotalMessagesPerDay = TotalMatchesPerDay * MessagesPerMatch;
public static readonly double AverageQPS = TotalProfileViewsPerDay / 86400.0;
public static readonly double PeakQPSMultiplier = 4.0;
public static readonly double PeakQPS = AverageQPS * PeakQPSMultiplier;
public static readonly double ValentinePeakQPS = PeakQPS * 10;
public const long BytesPerProfileView = 200;
public static readonly long DailyDataWritten = TotalProfileViewsPerDay * BytesPerProfileView;
public static readonly long AnnualStorage = DailyDataWritten * 365;
}
QPS by Service
| Service | Average QPS | Peak QPS | Valentine's Peak |
|---|---|---|---|
| Profile Discovery | 11,500 | 46,000 | 460,000 |
| Swipe Processing | 1,740 | 7,000 | 70,000 |
| Match Notification | 1,160 | 4,600 | 46,000 |
| Chat Ingress | 23,150 | 92,600 | 926,000 |
| Photo CDN | 57,870 | 231,500 | 2,315,000 |
| Geolocation Updates | 11,500 | 46,000 | 460,000 |
2. High-Level System Architecture
The Tinder backend is a microservices architecture deployed across multiple availability zones. At the edge, a CDN serves static assets (profile photos, app bundles) and an API Gateway handles authentication, rate limiting, request routing, and TLS termination. Behind the gateway, the system decomposes into distinct bounded contexts: the Profile Service manages user profiles and preferences, the Swipe Service processes swipe events, the Recommendation Service generates personalized profile decks, the Match Service handles mutual like detection and match creation, the Notification Service manages real-time WebSocket connections and push notifications, and the Chat Service handles message delivery and persistence. Each service owns its data store and communicates asynchronously via Kafka for event-driven workflows and synchronously via gRPC for low-latency request-response patterns.
Technology Stack Summary
| Component | Technology | Reasoning |
|---|---|---|
| API Gateway | Kong / Envoy | Rate limiting, auth, traffic management |
| Service Communication | gRPC (sync) + Kafka (async) | Low-latency calls + event-driven processing |
| User / Profile DB | MySQL (Vitess sharding) | Transactional consistency for user data |
| Geo Index | Redis Cluster (GEO) | Sub-millisecond radius queries |
| Session / Cache | Redis Cluster | In-memory for hot data access |
| Message Store | Apache Cassandra | Write-optimized, TTL support, linear scale |
| Event Streaming | Apache Kafka | Durable, replayable, high-throughput event log |
| Photo Storage | Amazon S3 + CloudFront | Unlimited storage, global CDN delivery |
| Push Notifications | APNs + FCM | Native mobile push delivery |
| Container Orchestration | Kubernetes (EKS) | Auto-scaling, self-healing, rolling deploys |
| Monitoring | Prometheus + Grafana + Datadog | Metrics, dashboards, alerting |
3. Geo-Indexing and Nearby Profile Discovery
Geolocation is the backbone of Tinder's matching experience. When a user opens the app, the system must find all active users within their configured radius, filter them by preferences, remove already-swiped profiles, rank the remaining candidates by desirability, and return the top 20 profiles — all within 500ms. This is one of the hardest problems in Tinder's architecture because it requires spatial indexing that supports dynamic inserts (users come online), dynamic deletes (users go offline), and radius queries that scale to millions of concurrent users.
The solution combines two Redis data structures: Redis GEO (backed by a Sorted Set using geohash encoding) for spatial queries and Redis Sets for tracking which profiles a user has already seen. When a user opens the app, their location is written to the GEO set with a TTL that expires when the user goes offline. The GEORADIUS or GEOSEARCH command finds all users within the specified radius, returning their IDs and distances. The seen set, stored per user with a 24-hour TTL, filters out profiles that have already been swiped.
Geohash Encoding
A geohash encodes a latitude/longitude pair into a short alphanumeric string. Nearby locations share common prefixes, which makes geohash-based indexes efficient for proximity queries. Redis GEO internally uses a Sorted Set where the score is the 52-bit interleaved geohash of the coordinates. This allows GEORADIUS to prune the search space by only examining score ranges that fall within the query rectangle, achieving O(log N + M) complexity where N is the total number of points and M is the number of results.
C#
public class GeoIndexService
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<GeoIndexService> _logger;
private const string ActiveUsersGeoKey = "tinder:geo:users:active";
private const string UserSeenPrefix = "tinder:seen:";
private const int DefaultRadiusMiles = 50;
private const int LocationTtlSeconds = 300;
private const int SeenTtlSeconds = 86400;
public GeoIndexService(IConnectionMultiplexer redis, ILogger<GeoIndexService> logger)
{
_redis = redis;
_logger = logger;
}
public async Task UpdateUserLocationAsync(string userId, double latitude, double longitude)
{
var db = _redis.GetDatabase();
await db.GeoRemoveAsync(ActiveUsersGeoKey, userId);
await db.GeoAddAsync(ActiveUsersGeoKey, new GeoEntry(longitude, latitude, userId));
await db.KeyExpireAsync(ActiveUsersGeoKey, TimeSpan.FromSeconds(LocationTtlSeconds));
}
public async Task<List<NearbyProfile>> GetNearbyProfilesAsync(
string userId, double latitude, double longitude,
int radiusMiles = DefaultRadiusMiles, int maxResults = 200)
{
var db = _redis.GetDatabase();
var geoResults = await db.GeoRadiusAsync(
ActiveUsersGeoKey, longitude, latitude, radiusMiles,
Order.Nearest, true, true, false, maxResults);
var seenKey = $"{UserSeenPrefix}{userId}";
var seenMembers = await db.SetMembersAsync(seenKey);
var seenSet = new HashSet<string>(seenMembers.Select(m => m.ToString()));
var candidates = new List<NearbyProfile>();
foreach (var geoResult in geoResults)
{
if (geoResult.Member == userId) continue;
if (seenSet.Contains(geoResult.Member)) continue;
candidates.Add(new NearbyProfile
{
UserId = geoResult.Member,
Latitude = geoResult.Position?.Latitude ?? 0,
Longitude = geoResult.Position?.Longitude ?? 0,
DistanceMiles = (int)(geoResult.Distance ?? 0)
});
}
return candidates;
}
public async Task MarkProfileSeenAsync(string swiperId, string targetId)
{
var db = _redis.GetDatabase();
var seenKey = $"{UserSeenPrefix}{swiperId}";
await db.SetAddAsync(seenKey, targetId);
await db.KeyExpireAsync(seenKey, TimeSpan.FromSeconds(SeenTtlSeconds));
}
}
public class NearbyProfile
{
public string UserId { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public int DistanceMiles { get; set; }
}
Geo-Indexing Flow
Geo-Indexing Technology Comparison
| Approach | Query Latency | Write Latency | Consistency | Scalability |
|---|---|---|---|---|
| Redis GEO | < 5ms | < 1ms | Eventual (cluster) | 50M+ points |
| PostGIS | 10-50ms | 5-20ms | Strong | 100M+ points |
| Google S2 Cells | < 10ms | < 2ms | Eventual | Billions |
| Uber H3 | < 10ms | < 2ms | Eventual | Billions |
| Elasticsearch Geo | 10-100ms | 10-50ms | Near-real-time | 100M+ points |
4. Swipe Processing Pipeline
The swipe action is the most critical user interaction in Tinder. When a user swipes right or left, the system must record the event, update the seen set, and potentially trigger a mutual like check — all while returning a response in under 200ms. The architecture uses an event-driven approach: the API accepts the swipe, writes it to Kafka, updates the Redis seen set, and returns immediately. A background consumer processes the swipe asynchronously, checks for mutual likes, and triggers match creation if applicable. This decoupled design ensures the swipe API remains fast and resilient even if downstream services experience temporary slowdowns.
The Kafka topic tinder.swipe.events is partitioned by the target user ID. This ensures that all swipes targeting the same user are processed in order, which is critical for maintaining consistency in the seen set and mutual like detection. Each partition has multiple consumer instances running in a consumer group, providing parallelism while maintaining ordering guarantees within a partition.
C#
public enum SwipeDirection { Left = 0, Right = 1, SuperLike = 2 }
public class SwipeEvent
{
public string SwiperId { get; set; }
public string TargetId { get; set; }
public SwipeDirection Direction { get; set; }
public long Timestamp { get; set; }
public string CorrelationId { get; set; } = Guid.NewGuid().ToString("N");
}
public class SwipeService
{
private readonly IConnectionMultiplexer _redis;
private readonly IKafkaProducer _kafkaProducer;
private readonly ILogger<SwipeService> _logger;
private const string SeenPrefix = "tinder:seen:";
private const string DailySwipeLimit = "tinder:swipelimit:";
private const int FreeDailySwipeLimit = 100;
public SwipeService(IConnectionMultiplexer redis, IKafkaProducer kafkaProducer,
ILogger<SwipeService> logger)
{
_redis = redis;
_kafkaProducer = kafkaProducer;
_logger = logger;
}
public async Task<SwipeResult> ProcessSwipeAsync(string swiperId, string targetId, SwipeDirection direction)
{
var db = _redis.GetDatabase();
// Check daily swipe limit
var swipeCountKey = $"{DailySwipeLimit}{swiperId}:{DateTime.UtcNow:yyyyMMdd}";
var currentCount = await db.StringIncrementAsync(swipeCountKey);
if (currentCount == 1) await db.KeyExpireAsync(swipeCountKey, TimeSpan.FromHours(24));
if (currentCount > FreeDailySwipeLimit)
{
return new SwipeResult { Success = false, ErrorCode = "DAILY_LIMIT_EXCEEDED" };
}
// Check if already swiped
var seenKey = $"{SeenPrefix}{swiperId}";
if (await db.SetContainsAsync(seenKey, targetId))
{
return new SwipeResult { Success = false, ErrorCode = "ALREADY_SWIPED" };
}
// Record event to Kafka
var swipeEvent = new SwipeEvent
{
SwiperId = swiperId, TargetId = targetId,
Direction = direction,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
await _kafkaProducer.ProduceAsync("tinder.swipe.events", targetId, swipeEvent);
// Update seen set
await db.SetAddAsync(seenKey, targetId);
await db.KeyExpireAsync(seenKey, TimeSpan.FromSeconds(86400));
// Handle Super Like
if (direction == SwipeDirection.SuperLike)
{
var superLikeKey = $"tinder:superlikes:{targetId}";
await db.SortedSetAddAsync(superLikeKey, swiperId,
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
await db.KeyExpireAsync(superLikeKey, TimeSpan.FromHours(48));
}
return new SwipeResult { Success = true };
}
}
public class SwipeResult
{
public bool Success { get; set; }
public bool IsMatch { get; set; }
public string ErrorCode { get; set; }
public string Message { get; set; }
}
Kafka Consumer for Swipe Processing
C#
public class SwipeConsumer : BackgroundService
{
private readonly IConsumer<string, SwipeEvent> _consumer;
private readonly IConnectionMultiplexer _redis;
private readonly MatchService _matchService;
private readonly NotificationService _notificationService;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_consumer.Subscribe("tinder.swipe.events");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var result = _consumer.Consume(stoppingToken);
await ProcessSwipeEventAsync(result.Message.Value);
}
catch (ConsumeException ex) { /* log error */ }
}
}
private async Task ProcessSwipeEventAsync(SwipeEvent evt)
{
var db = _redis.GetDatabase();
if (evt.Direction == SwipeDirection.Right || evt.Direction == SwipeDirection.SuperLike)
{
var likesKey = $"tinder:likes:{evt.TargetId}";
var alreadyLiked = await db.SetContainsAsync(likesKey, evt.SwiperId);
if (alreadyLiked)
{
var match = await _matchService.CreateMatchAsync(evt.SwiperId, evt.TargetId);
await _notificationService.NotifyMatchAsync(evt.SwiperId, match);
await _notificationService.NotifyMatchAsync(evt.TargetId, match);
}
else
{
var likesKeyForSwiper = $"tinder:likes:{evt.SwiperId}";
await db.SetAddAsync(likesKeyForSwiper, evt.TargetId);
await db.KeyExpireAsync(likesKeyForSwiper, TimeSpan.FromDays(30));
await UpdateEloScoresAsync(evt.SwiperId, evt.TargetId, swipedRight: true);
}
}
else
{
await UpdateEloScoresAsync(evt.SwiperId, evt.TargetId, swipedRight: false);
}
}
private async Task UpdateEloScoresAsync(string swiperId, string targetId, bool swipedRight)
{
var db = _redis.GetDatabase();
var swiperScore = await db.StringGetAsync($"tinder:elo:{swiperId}");
var targetScore = await db.StringGetAsync($"tinder:elo:{targetId}");
double playerElo = swiperScore.HasValue ? (double)swiperScore : 1500;
double targetElo = targetScore.HasValue ? (double)targetScore : 1500;
double expected = 1.0 / (1.0 + Math.Pow(10, (targetElo - playerElo) / 400.0));
double actual = swipedRight ? 1.0 : 0.0;
double newElo = playerElo + 32.0 * (actual - expected);
newElo = Math.Clamp(newElo, 100, 3000);
await db.StringSetAsync($"tinder:elo:{swiperId}", newElo);
}
}
Swipe Event Schema
| Field | Type | Description |
|---|---|---|
| swiperId | string (UUID) | ID of the user performing the swipe |
| targetId | string (UUID) | ID of the profile being swiped on |
| direction | enum | LEFT (0), RIGHT (1), SUPER_LIKE (2) |
| timestamp | long (epoch ms) | Exact time of the swipe event |
| correlationId | string | For tracing and deduplication |
| clientInfo | object | Device type, app version, OS version |
5. Mutual Like Detection and Match Creation
The mutual like detection is the heart of Tinder's matching mechanism. When user A swipes right on user B, the system must determine whether user B has already swiped right on user A. If yes, it is a match and both users must be notified. If not, the system records user A's like for potential future matching. This check must be fast (sub-millisecond) and consistent (no duplicate matches, no missed matches). Redis Set operations provide exactly the semantics we need: SISMEMBER for O(1) lookup and SADD for idempotent insertion.
Match creation involves writing a record to the MySQL match table, initializing a chat channel, and publishing events to the notification and analytics pipelines. The match record stores both user IDs, the timestamp, and whether the match was initiated by a Super Like. We use a database transaction to ensure the match record and chat channel creation are atomic.
C#
public class MatchService
{
private readonly IConnectionMultiplexer _redis;
private readonly IMatchRepository _matchRepository;
private readonly IChatService _chatService;
public async Task<MatchResult> CreateMatchAsync(string userId1, string userId2)
{
var db = _redis.GetDatabase();
// Idempotency check
var matchKey = $"tinder:match:{GetMatchPairKey(userId1, userId2)}";
var existingMatch = await db.StringGetAsync(matchKey);
if (existingMatch.HasValue)
return new MatchResult { Success = false, ErrorCode = "ALREADY_MATCHED" };
// Create match record in MySQL
var match = new Match
{
Id = Guid.NewGuid().ToString("N"),
UserId1 = string.Compare(userId1, userId2) < 0 ? userId1 : userId2,
UserId2 = string.Compare(userId1, userId2) < 0 ? userId2 : userId1,
CreatedAt = DateTime.UtcNow,
IsActive = true
};
await _matchRepository.CreateAsync(match);
// Initialize chat channel
await _chatService.InitializeChatChannelAsync(match.Id, match.UserId1, match.UserId2);
// Cache in Redis
var serializedMatch = JsonSerializer.Serialize(match);
await db.StringSetAsync(matchKey, serializedMatch, TimeSpan.FromDays(30));
await db.SetAddAsync($"tinder:matches:{userId1}", match.Id);
await db.SetAddAsync($"tinder:matches:{userId2}", match.Id);
// Cleanup likes and seen sets
await db.SetRemoveAsync($"tinder:likes:{userId1}", userId2);
await db.SetRemoveAsync($"tinder:likes:{userId2}", userId1);
await db.SetRemoveAsync($"tinder:seen:{userId1}", userId2);
await db.SetRemoveAsync($"tinder:seen:{userId2}", userId1);
return new MatchResult { Success = true, MatchId = match.Id, MatchedUserId = userId2 };
}
public async Task<List<Match>> GetUserMatchesAsync(string userId, int offset = 0, int limit = 50)
{
var db = _redis.GetDatabase();
var matchIds = await db.SetMembersAsync($"tinder:matches:{userId}");
var matches = new List<Match>();
foreach (var matchId in matchIds.Skip(offset).Take(limit))
{
var matchData = await db.StringGetAsync($"tinder:match:{matchId}");
if (matchData.HasValue)
matches.Add(JsonSerializer.Deserialize<Match>(matchData));
}
return matches.OrderByDescending(m => m.CreatedAt).ToList();
}
private static string GetMatchPairKey(string userId1, string userId2)
{
return string.Compare(userId1, userId2) < 0
? $"{userId1}:{userId2}" : $"{userId2}:{userId1}";
}
}
public class Match
{
public string Id { get; set; }
public string UserId1 { get; set; }
public string UserId2 { get; set; }
public DateTime CreatedAt { get; set; }
public bool IsActive { get; set; }
public bool IsSuperLike { get; set; }
}
6. Recommendation Engine and Elo Scoring
Tinder's recommendation engine determines which profiles each user sees, and in what order. The original algorithm used an Elo rating system borrowed from chess, where users who are swiped right on by other high-Elo users gain Elo points, and users who are swiped right on by low-Elo users gain fewer points. This creates a self-reinforcing ranking where high-desirability users see other high-desirability users. Modern Tinder supplements Elo with a machine learning model that incorporates dozens of features to produce a personalized ranking score.
The recommendation pipeline operates in three phases. First, the candidate generation phase fetches all profiles within the user's configured radius using the geo-index. Second, the scoring phase applies the Elo-based and ML-based scoring models to rank each candidate. Third, the filtering phase applies business rules to produce the final deck. The entire pipeline runs every 5 minutes per active user and caches the result in Redis.
C#
public class RecommendationEngine
{
private readonly IConnectionMultiplexer _redis;
private readonly IProfileRepository _profileRepository;
private readonly IGeoIndexService _geoIndexService;
private const int DeckSize = 20;
private const int CandidatePoolSize = 200;
private const int CacheTtlSeconds = 300;
public async Task<List<ProfileCard>> GetRecommendationDeckAsync(string userId)
{
var db = _redis.GetDatabase();
var cacheKey = $"tinder:deck:{userId}";
var cached = await db.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<List<ProfileCard>>(cached);
var userProfile = await _profileRepository.GetByIdAsync(userId);
var userElo = await GetUserEloScoreAsync(userId);
// Phase 1: Candidate Generation
var candidates = await _geoIndexService.GetNearbyProfilesAsync(
userId, userProfile.Latitude, userProfile.Longitude,
userProfile.DistanceRadiusMiles, CandidatePoolSize);
// Phase 2: Scoring
var scoredCandidates = new List<ScoredCandidate>();
foreach (var candidate in candidates)
{
var candidateProfile = await _profileRepository.GetByIdAsync(candidate.UserId);
if (candidateProfile == null) continue;
if (!MatchesPreferences(userProfile, candidateProfile)) continue;
var score = await ComputeScoreAsync(userProfile, userElo, candidateProfile);
scoredCandidates.Add(score);
}
// Phase 3: Ranking
var deck = scoredCandidates
.OrderByDescending(c => c.TotalScore)
.Take(DeckSize)
.Select(c => ToProfileCard(c))
.ToList();
// Insert Super Likes at top
var superLikes = await GetSuperLikeProfilesAsync(userId);
foreach (var superLike in superLikes.Reverse())
deck.Insert(0, superLike);
await db.StringSetAsync(cacheKey, JsonSerializer.Serialize(deck),
TimeSpan.FromSeconds(CacheTtlSeconds));
return deck;
}
private async Task<ScoredCandidate> ComputeScoreAsync(
UserProfile user, double userElo, UserProfile candidate)
{
double candidateElo = await GetUserEloScoreAsync(candidate.Id);
double recencyScore = (DateTime.UtcNow - candidate.LastActiveAt).TotalMinutes < 60 ? 200
: (DateTime.UtcNow - candidate.LastActiveAt).TotalHours < 24 ? 100 : 0;
double distanceScore = Math.Max(0, (50 - candidate.DistanceMiles)) * 3;
double photoScore = candidate.PhotoQualityScore * 100;
double bioScore = (!string.IsNullOrEmpty(candidate.Bio) ? 30 : 0)
+ (candidate.InstagramConnected ? 20 : 0) + (candidate.Photos.Count >= 3 ? 20 : 0);
double interestScore = user.Interests.Intersect(candidate.Interests).Count() * 15;
double totalScore = candidateElo + recencyScore + distanceScore
+ photoScore + bioScore + interestScore;
return new ScoredCandidate
{
UserId = candidate.Id, EloScore = candidateElo,
TotalScore = totalScore, Profile = candidate
};
}
private async Task<double> GetUserEloScoreAsync(string userId)
{
var db = _redis.GetDatabase();
var score = await db.StringGetAsync($"tinder:elo:{userId}");
return score.HasValue ? (double)score : 1500;
}
public async Task UpdateEloAfterSwipeAsync(string swiperId, string targetId, bool swipedRight)
{
var db = _redis.GetDatabase();
double swiperElo = await GetUserEloScoreAsync(swiperId);
double targetElo = await GetUserEloScoreAsync(targetId);
double expected = 1.0 / (1.0 + Math.Pow(10, (targetElo - swiperElo) / 400.0));
double actual = swipedRight ? 1.0 : 0.0;
double newSwiperElo = Math.Clamp(swiperElo + 32.0 * (actual - expected), 100, 3000);
await db.StringSetAsync($"tinder:elo:{swiperId}", newSwiperElo);
}
private bool MatchesPreferences(UserProfile user, UserProfile candidate)
{
return candidate.Age >= user.PreferredAgeRange.Min
&& candidate.Age <= user.PreferredAgeRange.Max
&& (user.PreferredGender == Gender.Any || candidate.Gender == user.PreferredGender)
&& candidate.DistanceMiles <= user.DistanceRadiusMiles;
}
}
public class ScoredCandidate
{
public string UserId { get; set; }
public double EloScore { get; set; }
public double TotalScore { get; set; }
public UserProfile Profile { get; set; }
}
Recommendation Pipeline Flow
Scoring Component Weights
| Component | Weight Range | Impact | Update Frequency |
|---|---|---|---|
| Elo Score | 100 - 3000 | High — primary ranking signal | Real-time on each swipe |
| Recency Bonus | 0 - 200 | Medium — active users get priority | Computed on read |
| Distance Score | 0 - 150 | Medium — proximity matters | Computed on read |
| Photo Quality | 0 - 100 | Medium — computer vision scored | Daily batch job |
| Bio Completeness | 0 - 80 | Low-Medium — completeness reward | On profile edit |
| Common Interests | 0 - 150 | Medium — compatibility signal | Computed on read |
| Age Preference | 0 - 50 | Low — preference alignment | Computed on read |
7. Real-Time Match Notifications with WebSockets
When two users match, both must be notified immediately. Tinder uses a dual notification strategy: an in-app real-time notification delivered via WebSocket for users who are currently active, and a mobile push notification via APNs (iOS) or FCM (Android) for users who are offline. The notification service maintains a mapping of user IDs to active WebSocket connections in Redis. When a match event arrives from Kafka, the service looks up both user IDs in the connection map. If a connection exists, the match notification is sent directly over WebSocket. If not, a push notification is enqueued for delivery via the platform's push notification service.
The WebSocket server is horizontally stateless — each server maintains connections for a subset of users. The user-to-server mapping is stored in Redis so that any server can look up which server holds a user's connection. This design allows for rolling deployments and server failures without losing notifications, since unmatched notifications fall back to push delivery.
C#
public class NotificationService
{
private readonly IConnectionMultiplexer _redis;
private readonly IPushNotificationService _pushService;
private const string ConnectionsPrefix = "tinder:ws:connections";
public async Task NotifyMatchAsync(string userId, MatchResult match)
{
var db = _redis.GetDatabase();
var connectionInfo = await db.HashGetAsync(ConnectionsPrefix, userId);
if (connectionInfo.HasValue)
{
var conn = JsonSerializer.Deserialize<WebSocketConnection>(connectionInfo);
var sent = await SendWebSocketMessageAsync(conn.ServerId, userId, new MatchNotification
{
Type = "MATCH", MatchId = match.MatchId,
MatchedUserId = match.MatchedUserId,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
if (sent) return;
}
// Fallback: Push notification
await _pushService.SendPushAsync(userId, new PushNotification
{
Title = "It's a match!",
Body = "You and someone special liked each other. Start chatting now!",
Data = new Dictionary<string, string>
{
["type"] = "MATCH",
["matchId"] = match.MatchId,
["matchedUserId"] = match.MatchedUserId
}
});
}
public async Task NotifyNewMessageAsync(string recipientId, ChatMessage message)
{
var db = _redis.GetDatabase();
var connectionInfo = await db.HashGetAsync(ConnectionsPrefix, recipientId);
if (connectionInfo.HasValue)
{
var conn = JsonSerializer.Deserialize<WebSocketConnection>(connectionInfo);
await SendWebSocketMessageAsync(conn.ServerId, recipientId, new MessageNotification
{
Type = "NEW_MESSAGE", MatchId = message.MatchId,
SenderId = message.SenderId, Content = message.Content
});
return;
}
await _pushService.SendPushAsync(recipientId, new PushNotification
{
Title = "New message",
Body = message.Content.Length > 100 ? message.Content[..100] + "..." : message.Content
});
}
public async Task RegisterConnectionAsync(string userId, string serverId, string connectionId)
{
var db = _redis.GetDatabase();
var connection = new WebSocketConnection
{
ServerId = serverId, ConnectionId = connectionId,
ConnectedAt = DateTimeOffset.UtcNow
};
await db.HashSetAsync(ConnectionsPrefix, userId, JsonSerializer.Serialize(connection));
await db.KeyExpireAsync(ConnectionsPrefix, TimeSpan.FromHours(24));
}
public async Task RemoveConnectionAsync(string userId)
{
var db = _redis.GetDatabase();
await db.HashDeleteAsync(ConnectionsPrefix, userId);
}
private async Task<bool> SendWebSocketMessageAsync(string serverId, string userId, object message)
{
try
{
var channel = GrpcChannel.ForAddress($"https://{serverId}");
var client = new WebSocketRelay.WebSocketRelayClient(channel);
var response = await client.SendAsync(new SendRequest
{
UserId = userId, Payload = JsonSerializer.Serialize(message)
});
return response.Success;
}
catch { return false; }
}
}
Push Notification Delivery Latency
| Channel | Median Latency | p99 Latency | Reliability |
|---|---|---|---|
| WebSocket (in-app) | 30ms | 100ms | 99.9% |
| APNs (iOS) | 500ms | 2s | 98% |
| FCM (Android) | 300ms | 1.5s | 97% |
| In-App Polling (fallback) | Next app open | — | 100% |
8. Chat System Architecture
After matching, users can exchange messages in real-time. Tinder's chat system is designed for ephemeral messaging with a 30-day TTL by default. Messages are delivered via WebSocket for real-time performance and persisted in Apache Cassandra for durability. The chat architecture must handle 2 billion messages per day while providing sub-200ms delivery latency, read receipts, typing indicators, and media sharing. Cassandra's write-optimized LSM-tree architecture and linear horizontal scalability make it ideal for this workload.
The messages table in Cassandra is partitioned by match_id with a time-UUID clustering key. This design ensures that all messages for a conversation are stored on the same partition, enabling fast range queries for loading chat history. The 30-day TTL is enforced at the Cassandra level, automatically purging old messages without requiring a separate cleanup job.
C#
public class ChatService
{
private readonly Cassandra.ISession _cassandra;
private readonly IConnectionMultiplexer _redis;
private readonly IKafkaProducer _kafkaProducer;
private readonly PreparedStatement _insertMessageStmt;
private readonly PreparedStatement _getMessagesStmt;
public ChatService(Cassandra.ISession cassandra, IConnectionMultiplexer redis,
IKafkaProducer kafkaProducer)
{
_cassandra = cassandra;
_redis = redis;
_kafkaProducer = kafkaProducer;
_insertMessageStmt = _cassandra.Prepare(@"
INSERT INTO tinder.messages
(match_id, message_id, sender_id, content, message_type, created_at)
VALUES (?, ?, ?, ?, ?, ?)
USING TTL 2592000");
_getMessagesStmt = _cassandra.Prepare(@"
SELECT message_id, sender_id, content, message_type, created_at, read_at
FROM tinder.messages WHERE match_id = ? AND created_at > ? ORDER BY created_at DESC LIMIT ?");
}
public async Task<ChatMessage> SendMessageAsync(string matchId, string senderId,
string content, string messageType = "text")
{
if (!await IsParticipantAsync(matchId, senderId))
throw new UnauthorizedAccessException("User is not a participant");
var messageId = TimeUuid.NewId().ToString();
var createdAt = DateTimeOffset.UtcNow;
await _cassandra.ExecuteAsync(_insertMessageStmt.Bind(
matchId, messageId, senderId, content, messageType, createdAt.UtcDateTime));
var messageEvent = new MessageEvent
{
MatchId = matchId, MessageId = messageId, SenderId = senderId,
Content = content, MessageType = messageType,
CreatedAt = createdAt.ToUnixTimeMilliseconds()
};
await _kafkaProducer.ProduceAsync("tinder.chat.messages", matchId, messageEvent);
var db = _redis.GetDatabase();
await db.HashSetAsync("tinder:lastmessages", matchId,
JsonSerializer.Serialize(new { messageId, senderId, content, createdAt }));
return new ChatMessage
{
Id = messageId, MatchId = matchId, SenderId = senderId,
Content = content, MessageType = messageType, CreatedAt = createdAt
};
}
public async Task<List<ChatMessage>> GetMessagesAsync(string matchId, string userId,
DateTimeOffset? before = null, int limit = 50)
{
if (!await IsParticipantAsync(matchId, userId))
throw new UnauthorizedAccessException("User is not a participant");
var timestamp = before?.UtcDateTime ?? DateTime.UtcNow;
var rows = await _cassandra.ExecuteAsync(_getMessagesStmt.Bind(matchId, timestamp, limit));
return rows.Select(row => new ChatMessage
{
Id = row.GetValue<string>("message_id"),
MatchId = matchId,
SenderId = row.GetValue<string>("sender_id"),
Content = row.GetValue<string>("content"),
MessageType = row.GetValue<string>("message_type"),
CreatedAt = row.GetValue<DateTime>("created_at"),
ReadAt = row.GetValue<DateTime?>("read_at")
}).ToList();
}
public async Task SendTypingIndicatorAsync(string matchId, string userId, bool isTyping)
{
var db = _redis.GetDatabase();
var key = $"tinder:typing:{matchId}";
if (isTyping)
{
await db.SetAddAsync(key, userId);
await db.KeyExpireAsync(key, TimeSpan.FromSeconds(10));
}
else await db.SetRemoveAsync(key, userId);
}
private async Task<bool> IsParticipantAsync(string matchId, string userId)
{
var db = _redis.GetDatabase();
var matchData = await db.StringGetAsync($"tinder:match:{matchId}");
if (!matchData.HasValue) return false;
var match = JsonSerializer.Deserialize<Match>(matchData);
return match.UserId1 == userId || match.UserId2 == userId;
}
}
public class ChatMessage
{
public string Id { get; set; }
public string MatchId { get; set; }
public string SenderId { get; set; }
public string Content { get; set; }
public string MessageType { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ReadAt { get; set; }
}
Chat Message Flow
Chat Cassandra Schema
CQL
CREATE TABLE tinder.messages (
match_id text,
message_id timeuuid,
sender_id text,
content text,
message_type text,
created_at timestamp,
read_at timestamp,
PRIMARY KEY (match_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC)
AND default_time_to_live = 2592000;
CREATE TABLE tinder.match_participants (
match_id text PRIMARY KEY,
user_id1 text,
user_id2 text,
created_at timestamp
);
9. Photo Upload and Media Pipeline
Profile photos are the most important factor in swipe decisions, so the photo upload and delivery pipeline must be fast, reliable, and optimized for visual quality. When a user uploads a photo, the system generates pre-signed S3 URLs for direct client-to-S3 upload, avoiding the backend as a bottleneck. After upload, an asynchronous processing pipeline resizes the image into multiple resolutions (thumbnail, medium, full), applies face detection for cropping, runs content moderation to detect inappropriate images, and computes a photo quality score using a computer vision model.
C#
public class PhotoService
{
private readonly IAmazonS3 _s3Client;
private readonly IConnectionMultiplexer _redis;
private const string BucketName = "tinder-profile-photos";
private const int MaxPhotosPerProfile = 9;
private const int MaxFileSizeBytes = 10 * 1024 * 1024;
private static readonly HashSet<string> AllowedContentTypes = new() { "image/jpeg", "image/png", "image/webp" };
public async Task<UploadUrlResponse> GetUploadUrlAsync(string userId, string contentType, int fileSize)
{
if (!AllowedContentTypes.Contains(contentType))
throw new ArgumentException($"Content type {contentType} is not allowed");
if (fileSize > MaxFileSizeBytes)
throw new ArgumentException("File size exceeds maximum of 10MB");
var db = _redis.GetDatabase();
var photoCount = await db.ListLengthAsync($"tinder:photos:{userId}");
if (photoCount >= MaxPhotosPerProfile)
throw new InvalidOperationException($"Maximum {MaxPhotosPerProfile} photos allowed");
var photoId = Guid.NewGuid().ToString("N");
var extension = contentType.Split('/')[1];
var key = $"profiles/{userId}/{photoId}.{extension}";
var request = new GetPreSignedUrlRequest
{
BucketName = BucketName, Key = key, Verb = HttpVerb.PUT,
Expires = DateTime.UtcNow.AddMinutes(5), ContentType = contentType
};
var uploadUrl = await _s3Client.GetPreSignedURLAsync(request);
return new UploadUrlResponse
{
UploadUrl = uploadUrl, PhotoId = photoId, Key = key,
ExpiresAt = DateTime.UtcNow.AddMinutes(5)
};
}
public async Task ConfirmUploadAsync(string userId, string photoId, string key)
{
var db = _redis.GetDatabase();
await db.ListRightPushAsync($"tinder:photos:{userId}",
JsonSerializer.Serialize(new PhotoRecord
{
Id = photoId, Key = key, UploadedAt = DateTimeOffset.UtcNow, Status = "processing"
}));
}
public async Task<string> GetPhotoUrlAsync(string userId, string photoId, string size = "medium")
{
var db = _redis.GetDatabase();
var cacheKey = $"tinder:photourl:{userId}:{photoId}:{size}";
var cached = await db.StringGetAsync(cacheKey);
if (cached.HasValue) return cached;
var key = $"profiles/{userId}/{photoId}_{size}.jpg";
var request = new GetPreSignedUrlRequest
{
BucketName = BucketName, Key = key, Verb = HttpVerb.GET,
Expires = DateTime.UtcNow.AddHours(1)
};
var url = await _s3Client.GetPreSignedURLAsync(request);
await db.StringSetAsync(cacheKey, url, TimeSpan.FromMinutes(50));
return url;
}
}
public class PhotoRecord
{
public string Id { get; set; }
public string Key { get; set; }
public DateTimeOffset UploadedAt { get; set; }
public string Status { get; set; }
public double QualityScore { get; set; }
}
Photo Processing Pipeline
10. User Profile and Authentication
User profiles in Tinder are rich objects that include personal information (name, age, bio, gender), preferences (age range, distance radius, gender preference), authentication tokens, social connections (Instagram, Spotify), and computed fields (Elo score, photo quality scores). The profile service uses MySQL as the primary store for transactional consistency, with Redis caching for read-heavy access patterns. Authentication uses JWT tokens with short-lived access tokens (15 minutes) and long-lived refresh tokens (30 days).
C#
public class UserProfile
{
public string Id { get; set; }
public string Email { get; set; }
public string PhoneNumber { get; set; }
public string Name { get; set; }
public DateTime DateOfBirth { get; set; }
public int Age => (int)((DateTime.UtcNow - DateOfBirth).TotalDays / 365.25);
public Gender Gender { get; set; }
public string Bio { get; set; }
public string JobTitle { get; set; }
public List<string> Interests { get; set; } = new();
public List<PhotoRecord> Photos { get; set; } = new();
public UserPreferences Preferences { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public int DistanceRadiusMiles { get; set; } = 50;
public DateTime LastActiveAt { get; set; }
public bool InstagramConnected { get; set; }
public bool SpotifyConnected { get; set; }
public double PhotoQualityScore { get; set; }
public AccountType AccountType { get; set; }
public bool IsVerified { get; set; }
public bool IsBanned { get; set; }
}
public enum Gender { Male = 0, Female = 1, NonBinary = 2, Any = -1 }
public enum AccountType { Free = 0, Plus = 1, Gold = 2, Platinum = 3 }
public class UserPreferences
{
public int MinAge { get; set; } = 18;
public int MaxAge { get; set; } = 50;
public Gender PreferredGender { get; set; } = Gender.Any;
public int DistanceRadiusMiles { get; set; } = 50;
public bool GlobalMode { get; set; }
}
public class AuthenticationService
{
private readonly IConnectionMultiplexer _redis;
private readonly IProfileRepository _profileRepository;
private const string RefreshTokenPrefix = "tinder:refresh:";
private const string SessionPrefix = "tinder:session:";
public async Task<AuthResult> LoginAsync(string phoneNumber, string verificationCode)
{
var profile = await _profileRepository.GetByPhoneNumberAsync(phoneNumber);
if (profile == null)
{
profile = new UserProfile
{
Id = Guid.NewGuid().ToString("N"),
PhoneNumber = phoneNumber,
CreatedAt = DateTime.UtcNow,
Preferences = new UserPreferences()
};
await _profileRepository.CreateAsync(profile);
}
return await GenerateTokensAsync(profile);
}
public async Task<AuthResult> RefreshTokenAsync(string refreshToken)
{
var db = _redis.GetDatabase();
var userId = await db.StringGetAsync($"{RefreshTokenPrefix}{refreshToken}");
if (!userId.HasValue) throw new UnauthorizedAccessException("Invalid refresh token");
var profile = await _profileRepository.GetByIdAsync(userId);
return await GenerateTokensAsync(profile);
}
private async Task<AuthResult> GenerateTokensAsync(UserProfile profile)
{
var db = _redis.GetDatabase();
var accessToken = GenerateJwtToken(profile, TimeSpan.FromMinutes(15));
var refreshToken = Guid.NewGuid().ToString("N");
await db.StringSetAsync($"{RefreshTokenPrefix}{refreshToken}", profile.Id, TimeSpan.FromDays(30));
var session = new UserSession { UserId = profile.Id, LoginAt = DateTimeOffset.UtcNow };
await db.StringSetAsync($"{SessionPrefix}{profile.Id}",
JsonSerializer.Serialize(session), TimeSpan.FromDays(30));
return new AuthResult
{
AccessToken = accessToken, RefreshToken = refreshToken,
ExpiresIn = 900, UserProfile = profile
};
}
private string GenerateJwtToken(UserProfile profile, TimeSpan expiry)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("JWT_SECRET")));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, profile.Id),
new Claim("account_type", profile.AccountType.ToString()),
new Claim("verified", profile.IsVerified.ToString())
};
var token = new JwtSecurityToken(
issuer: "tinder-api", audience: "tinder-client",
claims: claims, expires: DateTime.UtcNow.Add(expiry),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
11. Database Schema and Storage Choices
Tinder's data layer uses a polyglot persistence strategy, selecting the optimal database for each workload. MySQL (sharded via Vitess) handles user profiles, matches, and other transactional data where ACID guarantees are essential. Redis provides sub-millisecond access for geo-indexes, session data, caching, and real-time state. Apache Cassandra stores chat messages and swipe event logs where write throughput and TTL-based expiry are critical. Amazon S3 holds profile photos and media assets.
MySQL Schema
SQL
CREATE TABLE users (
id VARCHAR(32) PRIMARY KEY,
phone_number VARCHAR(20) UNIQUE NOT NULL,
email VARCHAR(255),
name VARCHAR(100) NOT NULL,
date_of_birth DATE NOT NULL,
gender TINYINT NOT NULL,
bio TEXT,
job_title VARCHAR(200),
company VARCHAR(200),
school VARCHAR(200),
interests JSON,
latitude DOUBLE,
longitude DOUBLE,
distance_radius_miles INT DEFAULT 50,
elo_score DOUBLE DEFAULT 1500,
photo_quality_score DOUBLE DEFAULT 0,
account_type TINYINT DEFAULT 0,
instagram_connected BOOLEAN DEFAULT FALSE,
instagram_username VARCHAR(100),
spotify_connected BOOLEAN DEFAULT FALSE,
spotify_theme VARCHAR(200),
is_verified BOOLEAN DEFAULT FALSE,
is_banned BOOLEAN DEFAULT FALSE,
last_active_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_last_active (last_active_at),
INDEX idx_location (latitude, longitude),
INDEX idx_elo (elo_score)
);
CREATE TABLE photos (
id VARCHAR(32) PRIMARY KEY,
user_id VARCHAR(32) NOT NULL,
s3_key VARCHAR(500) NOT NULL,
display_order INT NOT NULL,
quality_score DOUBLE DEFAULT 0,
status ENUM('processing', 'active', 'rejected') DEFAULT 'processing',
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_user_photos (user_id, display_order)
);
CREATE TABLE matches (
id VARCHAR(32) PRIMARY KEY,
user_id1 VARCHAR(32) NOT NULL,
user_id2 VARCHAR(32) NOT NULL,
is_super_like BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user1_matches (user_id1, created_at DESC),
INDEX idx_user2_matches (user_id2, created_at DESC),
UNIQUE KEY uk_match_pair (user_id1, user_id2)
);
CREATE TABLE reports (
id VARCHAR(32) PRIMARY KEY,
reporter_id VARCHAR(32) NOT NULL,
reported_id VARCHAR(32) NOT NULL,
reason ENUM('spam', 'inappropriate', 'fake', 'harassment', 'other') NOT NULL,
description TEXT,
status ENUM('pending', 'reviewed', 'resolved') DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_reported_user (reported_id),
INDEX idx_status (status)
);
Cassandra Schema
CQL
CREATE KEYSPACE tinder WITH replication = {
'class': 'NetworkTopologyStrategy',
'us-east-1': 3,
'eu-west-1': 3
};
CREATE TABLE tinder.messages (
match_id text,
message_id timeuuid,
sender_id text,
content text,
message_type text,
created_at timestamp,
read_at timestamp,
PRIMARY KEY (match_id, message_id)
) WITH CLUSTERING ORDER BY (message_id DESC)
AND default_time_to_live = 2592000;
CREATE TABLE tinder.swipe_events (
user_id text,
event_time timeuuid,
target_id text,
direction int,
processed boolean,
PRIMARY KEY (user_id, event_time)
) WITH CLUSTERING ORDER BY (event_time DESC)
AND default_time_to_live = 7776000;
Storage Technology Comparison
| Data Type | Database | Volume | Access Pattern | Retention |
|---|---|---|---|---|
| User Profiles | MySQL (Vitess) | 75M rows | Read-heavy, transactional | Permanent |
| Photos Metadata | MySQL | 500M rows | Read-heavy | Permanent while active |
| Geo Index | Redis GEO | 10M active | Write-heavy, radius query | 5 min TTL |
| Sessions | Redis | 10M | Read-heavy | 24 hours |
| Elo Scores | Redis | 75M keys | Read/Write | Permanent |
| Messages | Cassandra | 2B/day | Write-heavy, time-series | 30 days |
| Swipe Events | Cassandra | 150M/day | Write-heavy, analytics | 90 days |
| Photo Files | S3 | 50TB+ | Read-heavy via CDN | Permanent |
12. Caching Strategy
Caching is the single most impactful optimization in Tinder's architecture. Given the 95% read-to-write ratio and the requirement for sub-200ms response times, a multi-layer caching strategy is essential. The system uses L1 caching (in-memory within each service instance) for ultra-hot data, L2 caching (Redis Cluster) for shared state across instances, and L3 caching (CDN edge caching) for static assets like profile photos.
C#
public class CachingStrategy
{
private readonly IMemoryCache _l1Cache;
private readonly IConnectionMultiplexer _redis;
public CachingStrategy(IMemoryCache l1Cache, IConnectionMultiplexer redis)
{
_l1Cache = l1Cache;
_redis = redis;
}
public async Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> factory,
TimeSpan? ttl = null, CacheLevel level = CacheLevel.L2)
{
if (level == CacheLevel.L1 || level == CacheLevel.All)
{
if (_l1Cache.TryGetValue(key, out T l1Value)) return l1Value;
}
var db = _redis.GetDatabase();
var cached = await db.StringGetAsync(key);
if (cached.HasValue)
{
var value = JsonSerializer.Deserialize<T>(cached);
if (level == CacheLevel.L1 || level == CacheLevel.All)
_l1Cache.Set(key, value, TimeSpan.FromSeconds(60));
return value;
}
var result = await factory();
var serialized = JsonSerializer.Serialize(result);
await db.StringSetAsync(key, serialized, ttl ?? TimeSpan.FromMinutes(5));
if (level == CacheLevel.L1 || level == CacheLevel.All)
_l1Cache.Set(key, result, TimeSpan.FromSeconds(60));
return result;
}
public async Task InvalidateAsync(string key)
{
_l1Cache.Remove(key);
var db = _redis.GetDatabase();
await db.KeyDeleteAsync(key);
}
}
public enum CacheLevel { L1, L2, All }
public static class CacheKeys
{
public static string ProfileDeck(string userId) => $"tinder:deck:{userId}";
public static string UserProfile(string userId) => $"tinder:profile:{userId}";
public static string EloScore(string userId) => $"tinder:elo:{userId}";
public static string MatchData(string matchId) => $"tinder:match:{matchId}";
public static string UserMatches(string userId) => $"tinder:matches:{userId}";
public static string PhotoUrl(string userId, string photoId, string size) =>
$"tinder:photourl:{userId}:{photoId}:{size}";
}
Cache Hit Rate Targets
| Cache Layer | Target Hit Rate | Eviction Policy | Max Memory |
|---|---|---|---|
| L1 (In-Memory) | 40% | LRU, 60s TTL | 512MB per instance |
| L2 (Redis) | 90% | allkeys-lru | 256GB cluster |
| L3 (CDN) | 95% | TTL-based | Unlimited (edge) |
13. Handling Peak Load and Scaling
Valentine's Day is the most challenging day for Tinder's infrastructure. Traffic can spike to 10x normal levels, with millions of concurrent users swiping, messaging, and refreshing their decks simultaneously. The scaling strategy must be proactive (pre-scaling before the spike) and reactive (auto-scaling during the spike). Beyond raw scaling, the system must implement graceful degradation to maintain availability even when individual components are overloaded.
Scaling Strategies
- Pre-scaling: 48 hours before Valentine's Day, increase all service replicas by 3x and warm up Redis caches with pre-computed recommendation decks.
- Auto-scaling: Kubernetes HPA scales services based on Kafka consumer lag (primary metric) and CPU utilization (secondary metric).
- Read replicas: MySQL read replicas handle the 95% read workload, with up to 10 replicas per shard.
- Redis Cluster: 50+ Redis nodes sharding geo-index, sessions, and cache data.
- Load shedding: Drop non-critical requests (profile edits, photo uploads, analytics) under extreme load.
- Progressive degradation: Fall back to random nearby profiles if the recommendation engine is overloaded.
C#
public class LoadSheddingMiddleware
{
private readonly RequestDelegate _next;
private static int _currentLoad;
private const int MaxConcurrentRequests = 50000;
private static readonly Dictionary<string, RequestPriority> EndpointPriorities = new()
{
["/api/v1/swipe"] = RequestPriority.Critical,
["/api/v1/discover"] = RequestPriority.Critical,
["/api/v1/match"] = RequestPriority.High,
["/api/v1/chat/send"] = RequestPriority.High,
["/api/v1/profile"] = RequestPriority.Medium,
["/api/v1/photos"] = RequestPriority.Low,
["/api/v1/analytics"] = RequestPriority.Low
};
public LoadSheddingMiddleware(RequestDelegate next) { _next = next; }
public async Task InvokeAsync(HttpContext context)
{
var current = Interlocked.Increment(ref _currentLoad);
try
{
if (current > MaxConcurrentRequests)
{
var priority = GetEndpointPriority(context.Request.Path);
double threshold = priority switch
{
RequestPriority.Critical => 1.5,
RequestPriority.High => 1.2,
RequestPriority.Medium => 0.9,
RequestPriority.Low => 0.7,
_ => 1.0
};
if (current > MaxConcurrentRequests * threshold)
{
context.Response.StatusCode = 503;
await context.Response.WriteAsJsonAsync(new
{
error = "SERVICE_UNAVAILABLE",
retryAfter = 30
});
return;
}
}
await _next(context);
}
finally { Interlocked.Decrement(ref _currentLoad); }
}
private static RequestPriority GetEndpointPriority(PathString path)
{
foreach (var kvp in EndpointPriorities)
if (path.Value.Contains(kvp.Key)) return kvp.Value;
return RequestPriority.Medium;
}
}
public enum RequestPriority { Critical, High, Medium, Low }
Graceful Degradation Levels
| Level | Trigger | Behavior | User Impact |
|---|---|---|---|
| Normal | CPU < 70% | Full recommendation engine | None |
| Level 1 | CPU 70-85% | Skip ML scoring, use Elo only | Slightly less personalized decks |
| Level 2 | CPU 85-95% | Skip Elo scoring, use recency + distance | Less relevant profiles |
| Level 3 | CPU 95-99% | Random nearby profiles | Random profiles, but app works |
| Level 4 | CPU > 99% | Drop non-critical endpoints | Cannot edit profile or upload photos |
14. Content Moderation and Safety
Content moderation is critical for a dating app where user safety is paramount. Tinder employs a multi-layered moderation strategy: automated photo analysis using computer vision for NSFW detection, AI-powered text analysis for offensive language, user reporting with priority queuing, and human review for borderline cases. The moderation service operates as an asynchronous pipeline that processes photos and text as they are uploaded, with real-time blocking for clearly inappropriate content and delayed review for borderline cases.
C#
public class ModerationService
{
private readonly IKafkaProducer _kafkaProducer;
private readonly IConnectionMultiplexer _redis;
private const int AutoRejectThreshold = 95;
private const int AutoApproveThreshold = 10;
public async Task<ModerationResult> ModeratePhotoAsync(string userId, string photoId, string s3Key)
{
var nsfwScore = await AnalyzeNsfwAsync(s3Key);
var faceCount = await DetectFacesAsync(s3Key);
var qualityScore = await ComputeQualityScoreAsync(s3Key);
if (nsfwScore > AutoRejectThreshold)
{
await RejectPhotoAsync(userId, photoId, "NSFW content detected");
return new ModerationResult { Status = "rejected", Reason = "NSFW" };
}
if (faceCount == 0)
await FlagForReviewAsync(userId, photoId, "No face detected");
if (nsfwScore < AutoApproveThreshold && faceCount > 0)
{
await ApprovePhotoAsync(userId, photoId, qualityScore);
return new ModerationResult { Status = "approved", QualityScore = qualityScore };
}
await FlagForReviewAsync(userId, photoId, $"NSFW score: {nsfwScore}%, faces: {faceCount}");
return new ModerationResult { Status = "pending_review" };
}
public async Task HandleReportAsync(string reporterId, string reportedId,
string reason, string description)
{
var db = _redis.GetDatabase();
var reportCount = await db.StringIncrementAsync($"tinder:reports:{reportedId}");
await db.KeyExpireAsync($"tinder:reports:{reportedId}", TimeSpan.FromDays(90));
var report = new Report
{
Id = Guid.NewGuid().ToString("N"), ReporterId = reporterId,
ReportedId = reportedId, Reason = reason,
Description = description, CreatedAt = DateTime.UtcNow
};
if (reportCount >= 3)
{
await _kafkaProducer.ProduceAsync("tinder.moderation.escalated", reportedId, report);
if (reportCount >= 5) await AutoBanUserAsync(reportedId, "Excessive reports");
}
else
await _kafkaProducer.ProduceAsync("tinder.moderation.reports", reportedId, report);
}
private async Task<double> AnalyzeNsfwAsync(string s3Key) { return 0.0; }
private async Task<int> DetectFacesAsync(string s3Key) { return 1; }
private async Task<double> ComputeQualityScoreAsync(string s3Key) { return 0.85; }
private Task ApprovePhotoAsync(string userId, string photoId, double score) { return Task.CompletedTask; }
private Task RejectPhotoAsync(string userId, string photoId, string reason) { return Task.CompletedTask; }
private Task FlagForReviewAsync(string userId, string photoId, string reason) { return Task.CompletedTask; }
private Task AutoBanUserAsync(string userId, string reason) { return Task.CompletedTask; }
}
public class ModerationResult
{
public string Status { get; set; }
public string Reason { get; set; }
public double QualityScore { get; set; }
}
public class Report
{
public string Id { get; set; }
public string ReporterId { get; set; }
public string ReportedId { get; set; }
public string Reason { get; set; }
public string Description { get; set; }
public DateTime CreatedAt { get; set; }
}
Moderation Pipeline
15. Monetization and Premium Features
Tinder's revenue model is built on subscription tiers that unlock enhanced features. Understanding the technical implementation of premium features is important for system design because they create asymmetric access patterns and must not degrade the free user experience. The three main paid tiers are Tinder Plus (unlimited swipes, passport to any location, rewind), Tinder Gold (see who liked you, curated picks), and Tinder Platinum (priority likes, message before matching). Each tier requires specific backend logic to enforce entitlements while maintaining the same API contracts.
C#
public class PremiumFeatureService
{
private readonly IConnectionMultiplexer _redis;
private readonly IProfileRepository _profileRepository;
// Tinder Gold: See who liked you
public async Task<List<ProfileCard>> GetWhoLikedYouAsync(string userId)
{
var profile = await _profileRepository.GetByIdAsync(userId);
if (profile.AccountType < AccountType.Gold)
throw new UnauthorizedAccessException("Requires Tinder Gold");
var db = _redis.GetDatabase();
var likerIds = await db.SetMembersAsync($"tinder:likes:{userId}");
var profiles = new List<ProfileCard>();
foreach (var likerId in likerIds)
{
var likerProfile = await _profileRepository.GetByIdAsync(likerId.ToString());
if (likerProfile != null)
profiles.Add(new ProfileCard
{
UserId = likerProfile.Id, Name = likerProfile.Name,
Age = likerProfile.Age, Photos = likerProfile.Photos
});
}
return profiles;
}
// Tinder Plus: Passport
public async Task SetPassportLocationAsync(string userId, double lat, double lng)
{
var profile = await _profileRepository.GetByIdAsync(userId);
if (profile.AccountType < AccountType.Plus)
throw new UnauthorizedAccessException("Passport requires Tinder Plus");
var db = _redis.GetDatabase();
await db.StringSetAsync($"tinder:passport:{userId}",
JsonSerializer.Serialize(new { lat, lng }), TimeSpan.FromHours(24));
}
// Tinder Plus: Rewind
public async Task<SwipeResult> RewindLastSwipeAsync(string userId)
{
var profile = await _profileRepository.GetByIdAsync(userId);
if (profile.AccountType < AccountType.Plus)
throw new UnauthorizedAccessException("Rewind requires Tinder Plus");
var db = _redis.GetDatabase();
var lastSwipe = await db.StringGetAsync($"tinder:lastswipe:{userId}");
if (!lastSwipe.HasValue)
return new SwipeResult { Success = false, ErrorCode = "NO_SWIPES_TO_REWIND" };
var swipe = JsonSerializer.Deserialize<SwipeEvent>(lastSwipe);
await db.SetRemoveAsync($"tinder:seen:{userId}", swipe.TargetId);
if (swipe.Direction == SwipeDirection.Right)
await db.SetRemoveAsync($"tinder:likes:{swipe.TargetId}", userId);
return new SwipeResult { Success = true };
}
// Tinder Platinum: Priority Likes
public async Task PriorityLikeAsync(string swiperId, string targetId)
{
var profile = await _profileRepository.GetByIdAsync(swiperId);
if (profile.AccountType < AccountType.Platinum)
throw new UnauthorizedAccessException("Priority Likes require Platinum");
var db = _redis.GetDatabase();
await db.SortedSetAddAsync($"tinder:priority:{targetId}", swiperId,
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}
// Boost: 10x visibility for 30 minutes
public async Task ActivateBoostAsync(string userId)
{
var db = _redis.GetDatabase();
await db.StringSetAsync($"tinder:boost:{userId}", "10", TimeSpan.FromMinutes(30));
var currentElo = await db.StringGetAsync($"tinder:elo:{userId}");
if (currentElo.HasValue)
await db.StringSetAsync($"tinder:elo:boosted:{userId}",
(double)currentElo * 10, TimeSpan.FromMinutes(30));
}
}
Feature Matrix by Tier
| Feature | Free | Plus | Gold | Platinum |
|---|---|---|---|---|
| Daily Swipes | 100 | Unlimited | Unlimited | Unlimited |
| Rewind (Undo) | No | Yes | Yes | Yes |
| Passport (Location) | No | Yes | Yes | Yes |
| See Who Liked You | No | No | Yes | Yes |
| Priority Likes | No | No | No | Yes |
| Message Before Match | No | No | No | Yes |
| Boost (10x visibility) | 1/month | 1/month | 1/month | 1/month |
| Super Likes | 5/day | 5/day | 5/day | 5/day |
| Ad-Free | No | Yes | Yes | Yes |
16. Observability and Monitoring
A system serving 75 million users requires comprehensive observability across all layers. Tinder's monitoring stack uses the three pillars of observability: metrics (Prometheus + Grafana), logs (ELK Stack), and traces (Jaeger). Business-level metrics like swipe rate, match rate, and messages per match are tracked alongside infrastructure metrics like latency percentiles, error rates, and queue depths. Alerting is tiered: page-on-call for P1 incidents (service down, data loss), Slack alerts for P2 (elevated error rates), and dashboard-only for P3 (performance degradation).
C#
public class MetricsService
{
private readonly Counter _swipeCounter;
private readonly Counter _matchCounter;
private readonly Histogram _swipeLatency;
private readonly Histogram _profileLoadLatency;
private readonly Gauge _activeConnections;
private readonly Counter _messageCounter;
public MetricsService()
{
_swipeCounter = Metrics.CreateCounter("tinder_swipes_total",
"Total number of swipes", new[] { "direction", "account_type" });
_matchCounter = Metrics.CreateCounter("tinder_matches_total",
"Total matches created", new[] { "is_super_like" });
_swipeLatency = Metrics.CreateHistogram("tinder_swipe_latency_seconds",
"Swipe API latency", new[] { "endpoint" },
new HistogramConfiguration { Buckets = new[] { 0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1.0 } });
_profileLoadLatency = Metrics.CreateHistogram("tinder_profile_load_latency_seconds",
"Profile discovery latency", new[] { "cache_hit" });
_activeConnections = Metrics.CreateGauge("tinder_websocket_connections",
"Active WebSocket connections");
_messageCounter = Metrics.CreateCounter("tinder_messages_total",
"Total chat messages", new[] { "message_type" });
}
public void RecordSwipe(string direction, string accountType)
=> _swipeCounter.WithLabels(direction, accountType).Inc();
public void RecordMatch(bool isSuperLike)
=> _matchCounter.WithLabels(isSuperLike.ToString()).Inc();
public IDisposable TrackSwipeLatency(string endpoint)
=> _swipeLatency.WithLabels(endpoint).NewTimer();
public void SetActiveConnections(int count)
=> _activeConnections.Set(count);
public void RecordMessage(string messageType)
=> _messageCounter.WithLabels(messageType).Inc();
}
Key SLIs and SLOs
| SLI | SLO Target | Error Budget (30 days) |
|---|---|---|
| Swipe API Availability | 99.99% | 4.32 minutes |
| Swipe API Latency (p99) | < 200ms | — |
| Profile Load Latency (p99) | < 500ms | — |
| Match Notification Latency (p99) | < 1s | — |
| Chat Message Delivery (p99) | < 200ms | — |
| Message Durability | 99.999999% | — |
| Kafka Consumer Lag | < 1000 messages | — |
| Redis Cache Hit Rate | > 90% | — |
17. Conclusion
Designing Tinder's backend is a masterclass in distributed systems engineering. The core challenges — real-time geolocation indexing, low-latency swipe processing, intelligent recommendation ranking, and instant match notifications — require a carefully orchestrated combination of Redis for sub-millisecond state management, Kafka for event-driven decoupling, Cassandra for write-optimized message storage, MySQL for transactional consistency, and Kubernetes for elastic scaling. Each technology choice is driven by specific workload characteristics: Redis GEO for spatial queries, Kafka for at-least-once swipe processing, Cassandra for time-series message storage with TTL, and MySQL for authoritative user and match data.
The key architectural principles that emerge from this design are: decouple the critical path from downstream processing using event streaming, cache aggressively at multiple layers to maintain low latency, use polyglot persistence to match database technology to workload characteristics, design for graceful degradation before you need it, and build observability into every layer from day one. These principles apply far beyond dating apps to any real-time social or location-based system.
The Valentine's Day scaling challenge illustrates why proactive capacity planning and graceful degradation are not optional luxuries but essential architectural requirements. By pre-scaling infrastructure, implementing tiered load shedding, and building progressive degradation paths, Tinder can maintain its core swiping and matching functionality even under 10x normal traffic. This resilience comes from deliberately choosing which features to sacrifice in order to protect the critical user experience.
For system design interviews, the Tinder problem is an excellent vehicle for demonstrating your ability to reason about geospatial indexing, event-driven architecture, real-time systems, ML-powered ranking, and large-scale data management. The questions below will help you practice articulating these concepts clearly and confidently.
18. Interview Questions and Answers
Q1: How does Tinder find nearby users so quickly?
Tinder stores active user locations in a Redis GEO set, which is backed by a Sorted Set using geohash encoding. The GEORADIUS (or GEOSEARCH in Redis 6.2+) command finds all users within a configurable radius in under 10 milliseconds by leveraging the geohash prefix tree for spatial pruning. The results are filtered against a Redis Set per user that tracks already-swiped profiles (24-hour TTL), and the remaining candidates are scored and ranked by the recommendation engine before returning the top 20 profiles to the client. The key optimization is that the Redis GEO set only contains active users (5-minute TTL), so the working set stays small even as total registrations grow to 75 million.
Q2: How does Tinder handle the mutual like detection?
When user A swipes right on user B, the swipe consumer checks if user B has already liked user A using a Redis SISMEMBER operation on the set tinder:likes:{targetId}. This is an O(1) operation with sub-millisecond latency. If the result is true, a match record is written to MySQL, both users' Redis match lists are updated, a match_created event is published to Redis Pub/Sub, and both users receive a real-time notification via WebSocket (if online) or push notification via APNs/FCM (if offline). The entire flow from mutual like detection to notification delivery completes in under 1 second. The idempotency guarantee is maintained by checking for an existing match record before creating a new one, preventing duplicate matches even if the Kafka consumer processes the same event twice.
Q3: How are profiles ranked in the swipe deck?
Tinder uses an Elo-like desirability scoring system augmented by machine learning features. Each user has a base Elo score (starting at 1500) that increases when high-score users swipe right on them and decreases when they swipe right on low-score profiles. The K-factor of 32 determines how much each swipe affects the score. On top of the Elo baseline, the recommendation engine adds features like recency (active within 1 hour gets a 200-point bonus), distance (closer profiles score higher), photo quality (computer vision scored), bio completeness, common interests, and age preference alignment. Premium subscribers get additional boosts: Tinder Gold users see profiles that already liked them first, and Tinder Platinum users' likes are placed in a priority queue ahead of free users.
Q4: How does Tinder handle Valentine's Day traffic spikes?
Valentine's Day sees 10x normal traffic, requiring a multi-pronged approach. Proactively, the team pre-scales all Kubernetes deployments to 3x normal capacity 48 hours before the event. Reactively, the Horizontal Pod Autoscaler (HPA) monitors Kafka consumer lag as the primary scaling metric and CPU utilization as a secondary metric, scaling from 20 to 200 pod replicas as needed. Load shedding drops non-critical requests (profile edits, photo uploads, analytics) through a priority-based middleware. MySQL read replicas absorb the 95% read-heavy workload with up to 10 replicas per shard. Redis Cluster distributes geo-index and session data across 50+ nodes. Progressive degradation falls back through four levels: skip ML scoring, skip Elo scoring, random nearby profiles, and finally reject non-critical endpoints entirely — always preserving the core swipe and match functionality.
Q5: What database does Tinder use for messages and why?
Tinder uses Apache Cassandra for message storage. The choice is driven by three factors. First, Cassandra's LSM-tree architecture provides write-optimized performance, handling 2 billion messages per day without write amplification issues. Second, the messages table is partitioned by match_id with a time-UUID clustering key, ensuring all messages for a conversation live on the same partition for fast range queries. Third, Cassandra's built-in TTL mechanism (set to 2592000 seconds or 30 days) automatically purges old messages without requiring a separate cleanup job or increasing storage costs. The replication factor of 3 across two availability zones provides durability while the tunable consistency level (QUORUM for writes, ONE for reads) balances consistency with latency. For comparison, using MySQL for this workload would require horizontal sharding and would not natively support TTL-based expiry.
Q6: How does the Super Like feature work technically?
A Super Like bypasses the normal Elo-based matching queue through a dedicated Redis Sorted Set. When user A sends a Super Like to user B, the swiper's ID is inserted into a Redis Sorted Set at key tinder:superlikes:{targetId} with the timestamp as the score. The client polls this set every time it loads a new profile deck, and any profiles found in this set are inserted at the top of the swipe deck with a special visual indicator (blue star). The Super Like entry has a 48-hour TTL, after which it expires if the target user has not viewed the deck. From a scoring perspective, receiving a Super Like provides a larger Elo boost than a regular right-swipe (K-factor of 50 instead of 32), reflecting the higher signal of interest.
Q7: How would you design the swipe processing pipeline to handle exactly-once semantics?
True exactly-once semantics are impossible in a distributed system, but we can achieve effectively-once processing through three mechanisms. First, the swipe API uses Redis SADD for the seen set, which is naturally idempotent — adding the same member twice has no effect. Second, the Kafka producer uses idempotent writes with a correlation ID, and the consumer uses the target user ID as the partition key to ensure ordering. Third, the match creation in MySQL uses a unique constraint on the (user_id1, user_id2) pair, preventing duplicate matches even if the consumer processes the same event twice. The combination of these three idempotency boundaries — Redis SADD for seen set, Kafka partitioning for ordering, and MySQL unique constraints for matches — provides effectively-once processing without the overhead of distributed transactions.
Q8: How would you scale the chat system to handle 2 billion messages per day?
Scaling chat to 2 billion messages per day requires horizontal scalability at every layer. Cassandra handles the persistence layer with linear horizontal scaling — adding nodes increases both storage capacity and throughput proportionally. The messages table is partitioned by match_id, distributing load evenly across the cluster. For the delivery layer, multiple WebSocket server instances each handle a subset of connections, with the user-to-server mapping stored in Redis. Kafka provides the asynchronous delivery backbone, with the chat topic partitioned by match_id to ensure message ordering within a conversation. The system can handle this volume because each message is an independent unit of work — there are no cross-partition transactions or global indexes needed. The 30-day TTL on messages prevents unbounded storage growth, keeping the active working set manageable even at 2 billion messages per day.
Q9: How do you prevent spam and fake profiles on the platform?
Spam and fake profile prevention uses a multi-layered approach. At registration, phone number verification (via SMS OTP) prevents mass account creation. Profile photos are analyzed by a computer vision pipeline that detects face count (rejecting photos with no faces), runs NSFW classification, and computes a photo quality score — low-quality or stock-photo-like images receive lower scores and reduced visibility. Bio text is scanned by an NLP model for promotional content, contact information, and offensive language. User reports are accumulated in Redis with the reported user's ID as the key; at 3 reports the case is escalated to human review, and at 5 reports the account is auto-banned pending review. Behavioral signals like extremely high swipe-right rates (bot behavior), rapid-fire messaging patterns, and link sharing trigger automatic flagging. The Elo scoring system also naturally suppresses fake profiles — they receive low scores from left-swipes and become invisible to real users.
Q10: How would you design the recommendation engine to avoid filter bubbles and ensure fairness?
Avoiding filter bubbles requires deliberate design choices in the recommendation pipeline. First, the candidate pool is always generated from the full geo-index without any prior filtering by the Elo score, ensuring that every user has a chance to appear in someone's deck. Second, we introduce an "exploration factor" — approximately 10% of each deck is reserved for profiles outside the user's normal score range, exposing users to a broader set of potential matches. Third, new users receive a temporary Elo boost (starting at 1800 instead of 1500) to ensure they get initial exposure and feedback. Fourth, the scoring weights are periodically recalibrated to prevent any single feature (like photo quality) from dominating the ranking. The fairness metric tracked by the monitoring system is the Gini coefficient of match rates across the user population — if it exceeds 0.7, the recommendation weights are adjusted to redistribute visibility more evenly. This approach balances personalization with diversity, ensuring the platform remains useful for all users regardless of their position in the desirability distribution.
Originally published on Ayodhyyya. Last updated July 10, 2026.