System Design Problem

Design Tinder (Matching System)

Commonly Asked By:Match GroupTinderBumbleHinge

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)

QuestionWhy 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

MetricCalculationValue
DAUGiven25M
Swipes / dayGiven2B
Swipes / secDerived from daily volume ÷ 86400 (+ peak factor)~23K (peak 100K)
Matches / dayGiven30M
Avg profiles in deckGiven200/session
Profile sizeGiven5 KB (metadata) + 5 MB (photos)
Geo-query fan-outGiven~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.

Loading...

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.

SQL
-- 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:

LUA
-- 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 0

Eligibility 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:

SQL
-- 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:

REDIS
# 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.

REDIS
# 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 10

Boost 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:

REDIS
# 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 10

Candidate Mixing and Anti-Abuse Safeguards

Deck generation interleaves boosted candidates with organic candidates while enforcing rate limits and saturation protections:

YAML
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:

TYPESCRIPT
// 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:

HTTP
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:

HTTP
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:

HTTP
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:

HTTP
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:

SQL
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:

YAML
# 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:

ConcernSolution
Match consistencyPair-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 stalenessOn 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 largeBloom filter provides a memory-efficient exclusion hint, while exact persistent swipe history remains authoritative for undo and audit operations
Redis geospatial index lossRebuild geospatial index from persistent MySQL user location coordinates upon cold startup
Unfair rankingApply 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.
REDIS
# 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 coordinates

Additional 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 GEOADD and GEOSEARCH with 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:

Engineering Trade-offs

Elo Rating vs Machine Learning Recommendation

Trade-offs between single-dimensional competitive scoring and multi-feature predictive models for candidate generation:

YAML
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:

YAML
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

Help Us Improve

How helpful was this walkthrough?

Click a star to rate. We actively use this feedback to refine and update our system design content.

Placeholder
Optional but highly appreciated!

Discussion

Share your thoughts, ask questions, or help others.

Loading comments...