Interview Setup
Interview Prompt
Design Tinder, a geospatial dating and matchmaking platform where 25M DAU swipe through nearby profiles. Mutual right swipes create immediate matches across 2B swipes per day, requiring precomputed swipe decks that load in under 200ms.
Clarifying Questions (ask before designing)
| Question | Why it matters |
|---|---|
| How large is the discovery radius, and do we show exact distance or a range? | A 50 km radius in a dense metropolitan area returns approximately 10K profiles. For user privacy, display approximate rounded distances such as '3 km away' rather than raw latitude and longitude coordinates. |
| Must mutual matches be exactly-once, or is eventual consistency acceptable? | One-sided matches damage user trust. The system requires an atomic check-and-set evaluation rather than asynchronous eventual reconciliation. |
| Should the system pre-compute the swipe deck or generate candidates on each request? | Executing on-demand geospatial queries at 100K peak swipes per second would exhaust database resources. Maintaining a precomputed queue in Redis trades slight cache freshness for sub-millisecond read latency. |
| Do we rank candidates using scoring models such as Elo or Glicko, or show pure distance proximity? | An Elo baseline can reduce popularity concentration while decaying scores for inactive accounts, which requires a dedicated scoring stage in deck generation. |
Scope
In scope
- Candidate recommendation and geospatial proximity filtering
- Swipe queue pre-computation and background refresh
- Mutual match detection with atomic consistency
- Elo and Glicko dynamic profile scoring
- Capacity estimation and storage sizing with explicit math
Out of scope (state explicitly)
- Full advertising auction and monetization exchange stack
- Content moderation at scale (covered in Content Moderation System)
- Direct real time messaging and chat infrastructure (covered in Real-Time Chat)
Functional Requirements
Scope the discussion primarily to discovery matchmaking and candidate generation. Key requirements include profile persistence, swipe interactions, mutual match verification, and precomputed recommendation decks, while confirming that direct messaging and moderation pipelines are handled by dedicated subsystems.
In the room: mutual match detection is a great place to highlight bidirectional indexes, distributed set intersections, and atomic check-and-set operations.
- Profile Management: Users create and edit profiles with photos, bio, age, gender, and discovery preferences including age brackets, distance radius, and target gender.
- Discovery Deck: Users browse nearby candidate profiles sequentially, swiping right to like or left to pass.
- Mutual Match Detection: When two users mutually swipe right on each other, the system atomically records a match and enables direct messaging permissions.
- Geospatial Discovery: Filter candidates strictly within a user-configurable distance radius, defaulting to 50 km.
- Matched Chat: Matched pairs can exchange text messages and view mutual connection status.
- Super Like: A high intent interaction that immediately highlights the sender's profile card in the recipient's discovery deck and issues a push notification.
- Undo Interaction: A premium action allowing users to reverse their most recent swipe decision.
- Profile Boost: A premium mechanism that temporarily elevates profile visibility across nearby candidate decks for 30 minutes.
- Safety and Moderation Controls: Users can block abusive accounts or submit violation reports to halt matching interactions.
Non-Functional Requirements
Discovery decks must load with sub-200ms latency, while mutual match detection and push notifications execute in near real time. Geospatial accuracy and data privacy remain core operational constraints.
- Low Latency: Swipe decks load in under 200ms at the 99th percentile, with swipe action registrations completing in under 50ms.
- Geospatial Accuracy: Distance estimations remain accurate within 1 km using geodesic approximations across partitioned coordinates.
- Strict Consistency: Mutual match detection requires exact atomic consistency to eliminate duplicate records or one-sided false matches.
- High Throughput Scalability: Support 25M daily active users, 75M monthly active users, and over 2B swipes per day with peak loads reaching 100K swipes per second.
- Location Privacy: Exact geographic coordinates are never transmitted to other clients, exposing only approximate rounded distance offsets. Stored coordinates should be encrypted at rest and access controlled separately from public profile data.
- Discovery Freshness: Profile updates, newly active users, and location changes reflect in candidate recommendation decks within minutes.
Capacity Estimations
| Metric | Calculation | Value |
|---|---|---|
| DAU | Given | 25M |
| Swipes / day | Given | 2B |
| Swipes / sec | Derived from daily volume ÷ 86400 (+ peak factor) | ~23K (peak 100K) |
| Matches / day | Given | 30M |
| Avg profiles in deck | Given | 200/session |
| Profile size | Given | 5 KB (metadata) + 5 MB (photos) |
| Geo-query fan-out | Given | ~10K profiles per 50 km radius in dense city |
Architecture Diagram
High-Level Topology
The matchmaking platform separates candidate deck generation from real time swipe processing. Geospatial discovery, preference filtering, and ranking run when the user opens the app or when the local deck drops below 20 cards. The swipe service first records the durable swipe event idempotently, then uses a pair-scoped Redis Lua script for immediate reciprocal-like detection. A confirmed match is returned only after the canonical relational match row and durable outbox event have committed, while profile media streams directly from CDN edge caches and structured profile metadata resides in relational database shards.
Component Deep Dives
Mutual Match Detection with Atomic Lua Scripts
Concurrent right swipes represent a classic concurrency hazard where race conditions can produce duplicate matches. Without strict atomicity, both users can evaluate mutual interest simultaneously and trigger dual match events. Durable swipe history is the source of recovery truth. A Redis Lua script executes the pair-scoped check and write in one atomic round trip for the low latency path, while MySQL stores the canonical match row using an idempotent insert on the ordered user ID pair and a durable outbox event. A reconciliation worker repairs missed matches after Redis loss or a process crash.
Race Condition Hazard in Concurrent Swipes
When user A and user B swipe right on each other within milliseconds, uncoordinated read-then-write checks allow both application servers to conclude they are the first to complete the match:
Timeline of a Concurrent Mutual Swipe Race Condition: T=0.000s: User A swipes right on User B. Worker A reads User B's right-swipe set (empty). T=0.001s: User B swipes right on User A. Worker B reads User A's right-swipe set (empty). T=0.002s: Worker A records User A's right-swipe and evaluates no match. T=0.003s: Worker B records User B's right-swipe and evaluates no match. Result: Both users liked each other, but the system missed the mutual match. Alternative Race Condition (Duplicate Match Events): Both workers read after writes finish, both detect a mutual match, and both dispatch duplicate push notifications. A relational unique constraint is still required to prevent duplicate durable match rows.
Durable Swipe Persistence Before Match Evaluation
Every swipe is persisted idempotently before Redis performs fast reciprocal-like evaluation. This closes the failure gap where Redis state could be updated successfully but the process could crash before a durable swipe record exists. If pair-scoped Redis state is missing, the service rehydrates the relevant right-swipe state from durable history before evaluation. A reconciliation worker can replay durable right swipes after Redis loss or a process crash to repair missed matches.
-- Persist every swipe before relying on Redis match state
-- requestHash is SHA-256 over the canonical request payload
START TRANSACTION;
INSERT INTO swipe_history (request_id, request_hash, user_id, target_user_id, action, created_at)
VALUES (:requestId, :requestHash, :userId, :targetUserId, :action, NOW())
ON DUPLICATE KEY UPDATE request_id = request_id;
-- Read the existing row for this user and request ID.
SELECT request_hash, swipe_id, target_user_id, action, response_status, match_id, matched_at, created_at, undone_at
FROM swipe_history
WHERE user_id = :userId AND request_id = :requestId
FOR UPDATE;
-- If the request ID already exists, the stored request_hash must equal :requestHash.
-- Return the persisted response fields for an identical finalized retry and HTTP 409 Conflict for a payload mismatch.
-- A newly detected match fills response_status=1, match_id, and matched_at in the same relational transaction as the canonical match and outbox event.
-- A completed non-match fills response_status=0 so a retry cannot change its historical result merely because a later swipe created a match.
-- After the Redis script returns 0, finalize the request result atomically:
UPDATE swipe_history
SET response_status = 0
WHERE user_id = :userId AND request_id = :requestId AND response_status IS NULL;
COMMIT;Atomic Mutual Match Lua Script
Redis executes Lua scripts atomically in a single-threaded execution context, guaranteeing that checking the reciprocal like and adding the current swipe occur without interleaved operations:
-- KEYS[1]: match_pair:{min_user_id:max_user_id}
-- ARGV: [1] current_field, [2] request_id
-- The full normalized pair is the Redis Cluster hash tag, so all state for the pair stays on one slot.
-- Durable swipe history is persisted before this fast-path cache mutation.
local pair_key = KEYS[1]
local current_field = ARGV[1]
local request_id = ARGV[2]
local request_field = 'request:' .. request_id
-- Reject an already-processed request without changing state
if redis.call('HEXISTS', pair_key, request_field) == 1 then
return 2
end
local other_field = 'right_a'
if current_field == 'right_a' then
other_field = 'right_b'
end
-- Check whether the other side has already liked this pair
local already_liked = redis.call('HGET', pair_key, other_field)
-- Record the current user's right swipe
redis.call('HSET', pair_key, current_field, '1')
redis.call('HSET', pair_key, request_field, 'processed')
redis.call('EXPIRE', pair_key, 2592000)
if already_liked == '1' then
-- Mutual match confirmed atomically. Durable match persistence follows idempotently.
return 1
end
-- Recorded right swipe, but no mutual match yet
return 0Eligibility and Safety Revalidation
Candidate filtering is not sufficient for correctness because a block, account deletion, moderation action, or preference change can occur after deck generation. The swipe service revalidates that both accounts are active, discoverable, and not blocked before recording the swipe or creating a match. Blocking an account also invalidates affected cached deck entries and suppresses future match notifications for that pair.
Canonical Idempotent Match Persistence
To prevent duplicate records in relational storage, the match service normalizes user IDs by storing the smaller ID in user_id_1 and the larger ID in user_id_2. The same transaction writes a durable outbox event so a match notification is not lost if the process fails after the database commit:
-- Idempotent match insertion using normalized pair IDs
-- Application code guarantees user_id_1 < user_id_2 and the database CHECK enforces it
START TRANSACTION;
INSERT INTO matches (user_id_1, user_id_2, matched_at)
VALUES (LEAST(:userA, :userB), GREATEST(:userA, :userB), NOW())
ON DUPLICATE KEY UPDATE matched_at = matched_at;
UPDATE swipe_history
SET response_status = 1,
match_id = (SELECT match_id FROM matches WHERE user_id_1 = LEAST(:userA, :userB) AND user_id_2 = GREATEST(:userA, :userB)),
matched_at = (SELECT matched_at FROM matches WHERE user_id_1 = LEAST(:userA, :userB) AND user_id_2 = GREATEST(:userA, :userB))
WHERE user_id = :currentUserId AND request_id = :requestId;
INSERT INTO match_outbox (match_id, event_type, event_key, created_at)
SELECT match_id, 'MATCH_CREATED', CONCAT(LEAST(:userA, :userB), ':', GREATEST(:userA, :userB)), NOW()
FROM matches
WHERE user_id_1 = LEAST(:userA, :userB)
AND user_id_2 = GREATEST(:userA, :userB)
ON DUPLICATE KEY UPDATE event_type = event_type;
COMMIT;Already-Swiped Tracking with Bloom Filters
Active members swipe across tens of thousands of profiles over several months. Storing every evaluated user ID in an uncompressed Redis set demands hundreds of kilobytes per profile, amounting to dozens of terabytes across 25M daily active users. Bloom filters accept a 0.1% false-positive probability. A false positive causes a candidate that has not actually been swiped to be treated as already seen, so the candidate is omitted from the deck. This trades a small amount of recall for substantially lower Redis memory consumption. The 50,000-item sizing below applies to each bounded filter. When a user exceeds that capacity, rotate or chain additional filters so the target false-positive rate remains controlled. Persistent MySQL swipe history retains the exact durable swipe history with an undo marker for profile undo actions and audit logs.
Memory Sizing Analysis and Trade-offs
Comparing raw Redis set storage against space-efficient probabilistic bit arrays for swipe deduplication across 25M daily active members and 200M total accounts:
Raw Redis Set Storage (Baseline): Average member history: 50,000 swiped profiles Per-member memory in Redis SET: 50,000 * 16 bytes = 800 KB per active user Fleet memory for 25M active users: 25,000,000 * 800 KB = 20 TB Fleet memory for 200M total accounts: 200,000,000 * 800 KB = 160 TB (prohibitively expensive) Bloom Filter Optimization: Target capacity: 50,000 items with 0.1% false-positive rate Memory required per filter: ~90 KB per active user (for 50,000 items at 0.1% false-positive rate, before implementation overhead) Fleet memory for 25M active users: 25,000,000 * 90 KB = ~2.25 TB (roughly 9x memory reduction vs the simplified raw-set model) Fleet memory for 200M total accounts: 200,000,000 * 90 KB = ~18 TB Impact of False Positives: A false positive indicates that a candidate is treated as already seen even though the user may not have swiped on them. At a 0.1% false-positive rate, approximately 1 in 1,000 membership checks can produce this omission. The system should tolerate this as a recall trade-off rather than treating the Bloom filter as authoritative history, because exact swipe history remains the source of truth for undo and audit behavior.
Redis Bloom Filter Operations
Discovery workers query the member's Bloom filter during candidate generation as a fast exclusion hint before assembling the deck queue. Because Bloom filters can return false positives, the exact swipe history remains authoritative whenever the business action requires certainty, such as undoing the most recent swipe:
# Add a target profile to the current bounded Bloom filter bucket
BF.ADD swiped_bf:{user_id}:{bucket} {target_user_id}
# Check the active Bloom filter bucket before adding a candidate to the discovery deck
BF.EXISTS swiped_bf:{user_id}:{bucket} {candidate_id}
# Bulk membership check for a candidate batch within one bucket
BF.MEXISTS swiped_bf:{user_id}:{bucket} {candidate_id_1} {candidate_id_2} {candidate_id_3}
# Rotate or chain additional buckets as history grows so each filter stays near its design capacity.Profile Boost and Temporary Visibility Mechanics
Profile Boost allows members to temporarily amplify their exposure within nearby discovery decks. To prevent boosted accounts from degrading organic recommendations, the matchmaking engine inserts boosted profiles into reserved priority slots rather than replacing entire queues. Capping boosted placements to 2 profiles per 200-card deck prevents dense metropolitan feeds from degrading into pay-to-win environments.
Super Like Priority Queue
Active Super Likes are kept in a recipient-scoped sorted set so deck generation can surface eligible senders ahead of ordinary candidates. Expired or consumed entries are removed during queue assembly.
# Record an active Super Like for the recipient
ZADD super_likes:{target_user_id} {expiry_timestamp} {sender_user_id}
# Remove expired or consumed Super Likes during deck generation
ZREMRANGEBYSCORE super_likes:{target_user_id} -inf {current_timestamp}
# Fetch active Super Likes whose expiry has not passed
ZRANGEBYSCORE super_likes:{target_user_id} {current_timestamp} +inf LIMIT 0 10Boost Activation and Geospatial Indexing
When a user purchases a 30-minute boost, the service records expiration metadata and indexes the account within the local geohash cell:
# Set active boost TTL flag for 30 minutes (1800 seconds)
SET boost:{user_id} {expiry_timestamp} EX 1800
# Store boost expiry as the sorted-set score so expired boosts can be removed
ZADD boosted_users:{geohash_prefix} {expiry_timestamp} {user_id}
# Remove expired boosts before selecting active candidates
ZREMRANGEBYSCORE boosted_users:{geohash_prefix} -inf {current_timestamp}
# Retrieve currently active boosted candidates in the target geohash cell
ZRANGEBYSCORE boosted_users:{geohash_prefix} {current_timestamp} +inf LIMIT 0 10Candidate Mixing and Anti-Abuse Safeguards
Deck generation interleaves boosted candidates with organic candidates while enforcing rate limits and saturation protections:
boost_delivery_pipeline:
candidate_assembly:
step_1: "Remove expired boosts, then fetch active boosted users in the local geohash cell via ZRANGEBYSCORE"
step_2: "Retrieve normal ranked candidate pool via geospatial filter and Elo scoring"
step_3: "Prefer roughly 1 boosted profile per 5 organic profiles when eligible candidates are available"
step_4: "Cap total boosted insertions at maximum 2 profiles per 200-card session deck"
anti_abuse_controls:
cooldown: "Maximum 1 boost per user every 12 hours with stacking disallowed"
saturation_handling: "If active boosts exceed regional thresholds, dilute view delivery evenly"
monetization_metrics:
pricing: "Approximately $5 per 30-minute boost"
projected_volume: "10M monthly boosts generating approximately $50M per month in revenue"API Design
Client API Type Definitions
RESTful contracts and client interface definitions for discovery deck consumption, swipe decision recording with idempotency keys, and cursor-paginated match synchronization.
TypeScript domain interfaces defining card payloads, swipe mutation contracts, match representations, and client methods:
// Tinder Discovery, Swiping, and Matchmaking Domain Signatures
export type SwipeAction = "left" | "right" | "super_like";
export interface ProfileCard {
userId: string;
name: string;
age: number;
photos: string[];
bio: string;
distanceKm: number;
commonInterests: string[];
}
export interface DiscoveryResponse {
profiles: ProfileCard[];
}
export interface SwipeRequest {
targetUserId: string;
action: SwipeAction;
}
export type IdempotencyKey = string;
export interface UndoResponse {
restoredTargetUserId: string;
restoredAction: SwipeAction;
undoneAt: string;
}
export interface SwipeResponse {
match: boolean;
matchId?: string;
matchedAt?: string;
}
export interface MatchSummary {
matchId: string;
user: {
userId: string;
name: string;
photos: string[];
};
matchedAt: string;
}
export interface MatchesResponse {
matches: MatchSummary[];
nextCursor?: string;
}
export interface TinderClient {
getDiscoveryDeck(count?: number): Promise<DiscoveryResponse>;
submitSwipe(request: SwipeRequest, idempotencyKey: IdempotencyKey): Promise<SwipeResponse>;
undoLastSwipe(idempotencyKey: IdempotencyKey): Promise<UndoResponse>;
getMatches(cursor?: string, limit?: number): Promise<MatchesResponse>;
}Core Dating and Discovery Endpoints
RESTful contracts for precomputed discovery deck fetching, immediate swipe decisions, and cursor-paginated match histories:
Get Discovery Deck
Fetches the next batch of precomputed candidate profile cards for the authenticated user session:
GET /api/v1/discovery?count=20 HTTP/1.1
Host: api.tinder.com
Authorization: Bearer <user_token>
HTTP/1.1 200 OK
Content-Type: application/json
{
"profiles": [
{
"user_id": "u-uuid-101",
"name": "Alice",
"age": 28,
"photos": [
"https://cdn.tinder.com/photos/alice_1.webp",
"https://cdn.tinder.com/photos/alice_2.webp"
],
"bio": "Love hiking, specialty coffee, and urban architecture.",
"distance_km": 5.2,
"common_interests": ["hiking", "photography"]
}
]
}Submit Swipe Action
Submits a left pass, right like, or Super Like decision and returns whether an immediate mutual match occurred:
POST /api/v1/swipes HTTP/1.1
Host: api.tinder.com
Authorization: Bearer <user_token>
Content-Type: application/json
Idempotency-Key: swp-req-7f31
{
"target_user_id": "u-uuid-101",
"action": "right"
}
HTTP/1.1 200 OK
Content-Type: application/json
{
"match": true,
"match_id": "m-uuid-5002",
"matched_at": "2026-03-15T18:42:00Z"
}Undo Last Swipe
Reverses the most recent eligible swipe using exact persisted history rather than the Bloom filter. The request uses an idempotency key stored by the API layer so retries return the same durable result. If the swipe has already created a durable match, use a separate unmatch flow rather than silently undoing the match:
POST /api/v1/swipes/undo HTTP/1.1
Host: api.tinder.com
Authorization: Bearer <user_token>
Content-Type: application/json
Idempotency-Key: undo-req-21fa
HTTP/1.1 200 OK
Content-Type: application/json
{
"restored_target_user_id": "u-uuid-101",
"restored_action": "right",
"undone_at": "2026-03-15T18:50:00Z"
}Get Matches
Retrieves a cursor-paginated list of mutual matches for the authenticated member:
GET /api/v1/matches?cursor=m-uuid-5001&limit=20 HTTP/1.1
Host: api.tinder.com
Authorization: Bearer <user_token>
HTTP/1.1 200 OK
Content-Type: application/json
{
"matches": [
{
"match_id": "m-uuid-5001",
"user": {
"user_id": "u-uuid-102",
"name": "Bob",
"photos": ["https://cdn.tinder.com/photos/bob_1.webp"]
},
"matched_at": "2026-03-15T18:30:00Z"
}
],
"next_cursor": "m-uuid-4980"
}Common Error Responses
Standardized HTTP error envelopes returned across discovery, swipe submission, undo, and match retrieval endpoints:
400 Bad Request: invalid input, missing required fields, or malformed JSON payload 401 Unauthorized: missing or invalid authentication token or API key 403 Forbidden: authenticated caller lacks required permissions for this resource 404 Not Found: requested resource ID does not exist 409 Conflict: duplicate write or version conflict, retry with a unique idempotency key 422 Unprocessable Entity: syntactically valid request failed semantic business validation 429 Too Many Requests: rate limit quota exceeded, client should honor Retry-After header 500 Internal Error: unexpected server failure, retry safely with an idempotency key 503 Service Unavailable: downstream dependency is unavailable or overloaded, retry with exponential backoff
Data Model
Relational Storage: Profiles and Matches
MySQL relational schema storing core member profiles, discovery matching preferences, and canonical mutual match records with uniqueness constraints:
CREATE TABLE users (
user_id CHAR(36) PRIMARY KEY,
name VARCHAR(100),
birth_date DATE,
gender ENUM('M','F','NB'),
bio TEXT,
photo_urls JSON,
last_active TIMESTAMP,
latitude DECIMAL(10,7),
longitude DECIMAL(10,7),
elo_score FLOAT DEFAULT 1000,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE preferences (
user_id CHAR(36) PRIMARY KEY,
gender_pref SET('M','F','NB'),
age_min TINYINT,
age_max TINYINT,
distance_km SMALLINT DEFAULT 50,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);
CREATE TABLE matches (
match_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id_1 CHAR(36) NOT NULL,
user_id_2 CHAR(36) NOT NULL,
matched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CHECK (user_id_1 < user_id_2),
UNIQUE KEY (user_id_1, user_id_2),
INDEX idx_user1 (user_id_1, matched_at DESC),
INDEX idx_user2 (user_id_2, matched_at DESC)
);
CREATE TABLE match_outbox (
event_id BIGINT PRIMARY KEY AUTO_INCREMENT,
match_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
event_key VARCHAR(200) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP NULL,
UNIQUE KEY (event_type, event_key)
);
CREATE TABLE swipe_history (
swipe_id BIGINT PRIMARY KEY AUTO_INCREMENT,
request_id VARCHAR(100) NOT NULL,
request_hash CHAR(64) NOT NULL,
user_id CHAR(36) NOT NULL,
target_user_id CHAR(36) NOT NULL,
action ENUM('left','right','super_like') NOT NULL,
response_status TINYINT NULL,
match_id BIGINT NULL,
matched_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
undone_at TIMESTAMP NULL,
UNIQUE KEY uq_swipe_request (user_id, request_id),
INDEX idx_user_time (user_id, created_at DESC, swipe_id DESC)
);In-Memory Storage: Geospatial and Swipe State
Redis data structure definitions for geospatial proximity indexing, fast swipe history filtering, and precomputed discovery deck queues:
# Geospatial index for proximity queries
geospatial_index:
key: "users:geo:{geohash_prefix}"
type: "geospatial index per spatial cell"
operation: "GEOADD users:geo:{geohash_prefix} {lng} {lat} {user_id}"
query: "Run GEOSEARCH in parallel against the user's cell and enough neighboring cells to cover the requested radius, merge the bounded candidate sets, and apply exact distance filtering (GEORADIUS for legacy clients)"
note: "COUNT 10000 applies per cell query. Apply exact distance filtering with stored coordinates after the merged cell results"
# Swipe history to filter already-seen profiles
swipe_history:
key: "swiped:{user_id}:{bucket}"
type: "SET"
purpose: "Optional recent exact exclusion cache for the discovery deck, while the persistent swipe_history table remains authoritative for undo and audit operations"
retention: "Bound recent buckets with TTL rather than retaining the full multi-month history in one Redis set"
# Right-swipe tracking for mutual match detection
right_swipe_history:
key: "swiped_right:{user_id}:{bucket}"
type: "SET"
purpose: "Stores right-swiped target IDs in bucketed keys for scalable history lookups, while pair-scoped state handles atomic mutual-match evaluation"
super_like_inbox:
key: "super_likes:{target_user_id}"
type: "ZSET"
purpose: "Queues active Super Likes for recipient discovery-deck prioritization, with expiry timestamp as the sorted-set score"
retention: "Bound by the Super Like visibility window and remove expired entries during deck generation"
match_pair_state:
key: "match_pair:{min_user_id:max_user_id}"
type: "HASH"
purpose: "Stores both users' right-swipe state on one Redis Cluster hash slot so reciprocal-like evaluation remains atomic"
ttl_policy: "Retain for the maximum matchability window or rehydrate from durable right-swipe history before evaluating a cache miss"
# Pre-computed discovery deck queue
precomputed_deck:
key: "deck:{user_id}"
type: "LIST"
purpose: "Ordered candidate user IDs ready for immediate consumption during the next swipe session"
ttl_seconds: 3600
deck_generation_lock:
key: "deck_gen:{user_id}"
type: "SET NX EX"
purpose: "Per-user single-flight lease preventing concurrent workers from rebuilding the same deck"
ttl_seconds: 30
renewal: "Extend while generation is active, releasing only when the owner token still matches"Fault Tolerance
Resilience and Failure Recovery Matrix
Mitigation strategies for match consistency hazards, index invalidation, memory footprint expansion, and ranking fairness:
| Concern | Solution |
|---|---|
| Match consistency | Pair-scoped Redis Lua performs atomic reciprocal-like evaluation, while the relational unique constraint, transaction, and durable outbox make the canonical match and downstream notification event idempotent |
| Location staleness | On foreground location updates, remove the user from the previous regional geohash cell, add the new coordinates to the current cell, invalidate the deck, and apply a 24-hour stale-location cleanup policy |
| Swipe history too large | Bloom filter provides a memory-efficient exclusion hint, while exact persistent swipe history remains authoritative for undo and audit operations |
| Redis geospatial index loss | Rebuild geospatial index from persistent MySQL user location coordinates upon cold startup |
| Unfair ranking | Apply the illustrative Elo decay or, in a modern model, monitor ranking exposure and recalibrate periodically to keep feeds fresh and reduce popularity feedback loops |
Stale Location Recovery: Relocation Handling
When a member travels to a new city, cached discovery decks and regional geospatial entries must be refreshed automatically:
Relocation Handling Workflow: 1. Member resides in New York and receives recommendation decks consisting of New York profiles. 2. Member travels to London. Without active invalidation, cached decks continue serving New York profiles. 3. On application launch in London, the location update service receives fresh GPS coordinates. 4. The service removes the user from the previous regional cell, adds the new coordinates, and invalidates the cached deck so generation moves to London.
# Remove the member from the previous regional geohash cell
ZREM users:geo:{old_geohash_prefix} {user_id}
# Add the member to the new regional geohash cell
GEOADD users:geo:{new_geohash_prefix} {new_lng} {new_lat} {user_id}
# Remove the member from the previous boost index if a boost is active
ZREM boosted_users:{old_geohash_prefix} {user_id}
# Re-index only if the boost is still active (application-side condition)
if expiry_timestamp > current_timestamp:
ZADD boosted_users:{new_geohash_prefix} {expiry_timestamp} {user_id}
# Invalidate the existing precomputed deck queue immediately
DEL deck:{user_id}
# Subsequent deck generation queries the new cell and neighboring cells using London coordinatesAdditional Considerations
Interview Walkthrough
Structuring the discussion to balance geospatial candidate generation, low latency deck delivery, and atomic match consistency within the target interview timeframe:
- 25-minute cut
Focus on the primary discovery and matching loop before delving into advanced multi region sharding or machine learning ranking models.
- Define the core loop across deck retrieval, swiping, mutual match evaluation, and push notifications (5 min)
- Architect geospatial discovery using Redis GEO commands within a configurable radius filter (6 min)
- Precompute and cache each user's swipe deck in Redis to isolate asynchronous deck generation from synchronous swiping (5 min)
- Implement atomic mutual match detection using Redis Lua scripts and normalized relational pair constraints (5 min)
- Explore staff-level extensions including dynamic ranking signals, Super Like queues, and velocity-based anti-spam limits (4 min)
- Scope the core interaction loop early by separating discovery deck retrieval, swipe mutations, and mutual match verification using System Design Interview Patterns, noting that each step exhibits distinct read and write latency profiles.
- Anchor geospatial discovery in Redis GEO commands such as
GEOADDandGEOSEARCHwith a configurable distance boundary, similar to the spatial partitioning principles covered in Design a Proximity Server. - Precompute and cache each user's candidate swipe deck in Redis, applying strategies from Caching Patterns and Invalidation because regenerating candidate pools on every swipe request degrades latency under high concurrency.
- Persist each swipe idempotently, detect mutual matches using pair-scoped Redis Lua scripts, and commit the canonical match row with a relational uniqueness constraint and durable outbox. Reconcile durable right swipes after Redis loss or process failure.
- Examine the transition from historical Elo scoring to modern machine learning models that estimate match probabilities, referencing the candidate generation and scoring techniques detailed in Design a Recommendation System while addressing cold-start strategies for new users.
- Distribute profile photographs through object storage paired with CDN and Edge Delivery, ensuring heavy media bytes never touch the primary application tier while structured profile metadata resides in relational database shards.
- Invalidate cached discovery decks when a user changes locations, ensuring stale geospatial indices do not present profiles from previously visited cities.
- Avoid the common pitfall of executing unfiltered geospatial radius queries on every swipe, instead decoupling candidate generation into background workers that maintain precomputed queues and Bloom exclusion filters.
Account Deletion and Data Erasure
Account deletion must remove the member from regional geospatial indexes, invalidate outstanding discovery decks, revoke active boosts and Super Likes, tombstone or delete durable profile and swipe data according to the retention policy, and purge media from object storage through an asynchronous deletion workflow. Because Bloom filters do not support ordinary per-member deletion, they remain a temporary recall optimization and should expire through bounded bucket rotation while canonical profile eligibility prevents deleted accounts from being served before the filter is retired. Deletion events should be idempotent so retries across regional workers converge on the same erased state.
Related Problems and Concepts
Explore how proximity indexes, real time messaging, recommendation pipelines, and distributed caching integrate across dating app architectures:
- Design a Proximity Server : Deep dive into Geohash prefixes, QuadTrees, and spatial radius search optimizations.
- Design a Real-Time Chat System : WebSocket connection clusters, message sequencing, and inbox synchronization between matched pairs.
- Design a Content Moderation System : Automated NSFW photo scanning, profile text safety classifiers, and human review escalation queues.
- Design a Recommendation System : Two-tower embedding generation, approximate nearest neighbor search, and multi-factor ranking pipelines.
- Consistent Hashing : Bounded-load hash rings for sharding in-memory deck queues and regional geospatial indices.
- Caching Patterns and Invalidation : Cache-aside patterns, proactive invalidation on travel events, and TTL strategies for profile decks.
- SQL vs NoSQL : Strict ACID guarantees for mutual match pairs vs wide-column append only stores for swipe activity logs.
- System Design Interview Patterns : Structuring scoping discussions, balancing read vs write workloads, and communicating engineering trade-offs.
Engineering Trade-offs
Elo Rating vs Machine Learning Recommendation
Trade-offs between single-dimensional competitive scoring and multi-feature predictive models for candidate generation:
elo_scoring_approach:
mechanism: "Calculates a single scalar rating updated when users swipe on each other"
update_rule: "Receiving a like from a high-score account increases rating substantially, while left passes apply downward adjustments"
strengths:
- "Computationally inexpensive with minimal storage overhead"
- "Can be maintained directly in Redis with hourly database persistence"
drawbacks:
- "Collapses multi-faceted compatibility into a single attractiveness rank"
- "Suffers from severe cold-start limitations for new accounts"
- "Fails to capture personalized niche preferences or reciprocal compatibility"
status: "Historical baseline. Tinder states that its current recommendation system no longer relies on Elo"
ml_predictive_scoring:
mechanism: "Predicts the probability of a mutual match P(mutual match) using multi-modal candidate features"
feature_inputs:
- "Profile completeness, bio length, and verified photo attributes"
- "Shared interests, lifestyle tags, and common social connections"
- "Chat response rate, messaging reciprocity, and active app sessions"
strengths:
- "Optimizes for reciprocal conversational engagement rather than superficial popularity"
- "Solves cold-start issues by leveraging content-based attributes before interaction data accumulates"
drawbacks:
- "Substantially higher computational cost requiring two-stage retrieval and re-ranking"
- "Susceptible to historical feedback loops that may reinforce algorithmic bias"Geospatial Indexing: Redis Geohash, Uber H3, and PostGIS GiST
Comparing coordinate indexing models across latency, spatial distortion, query flexibility, and operational overhead:
redis_geoset_geohash:
mechanism: "Encodes latitude and longitude into 52-bit integer Geohashes mapped into a Redis sorted set"
query_command: "GEORADIUS or GEOSEARCH within specified kilometer radius"
strengths:
- "Native Redis geospatial commands with low operational overhead"
- "Can provide low latency retrieval for bounded candidate sets, with actual complexity and latency depending on index size and query shape"
drawbacks:
- "Geohash cell shape and physical size vary with latitude, so fixed prefix lengths do not represent uniform ground area"
- "Limited to radius or bounding-box lookups with no arbitrary polygon boundary support"
uber_h3_hexagonal_grid:
mechanism: "Partitions the planetary surface into hierarchical hexagonal grid cells"
strengths:
- "Hexagonal cells provide more uniform neighborhood topology than rectangular grids"
- "Hierarchical nesting across 16 resolution levels with consistent cell-based spatial indexing"
drawbacks:
- "Requires application-level conversion libraries because H3 is not built natively into Redis"
- "More complex neighbor traversal logic compared to standard range scans"
postgis_gist_spatial:
mechanism: "PostGIS GiST index using bounding boxes for spatial candidate pruning inside PostgreSQL"
strengths:
- "Supports arbitrary polygonal geo-fencing, political boundaries, and complex spatial joins"
- "Supports exact geometry predicates and full PostGIS GIS capabilities when relational spatial querying is required"
drawbacks:
- "Significantly higher query latency (5ms to 50ms) compared to in-memory Redis queries"
- "Horizontal sharding of spatial indexes is complex and requires specialized distributed spatial clusters"Review
How helpful was this walkthrough?
Click a star to rate. We actively use this feedback to refine and update our system design content.
Discussion
Share your thoughts, ask questions, or help others.