Systems

Designing Tinder: how two swipes become one safe conversation

A concrete Tinder architecture that separates geo discovery from swipe consistency, turns two likes into one match, and delivers messages safely.

SSScott SantinhoAugust 01, 202623 min read
Two abstract profile cards connect across a luminous blue bridge into one amber match node.

Alice's 300-millisecond moment

Alice opens the app near Lyon, chooses a 25 km radius, and asks for people in an age range who share a few interests. The first screen is a stack, not a database query she can inspect. A candidate may have moved, changed a preference, been blocked, or already appeared on another device since the stack was prepared.

The system still needs to feel immediate. Alice sees Bob, swipes right, and keeps moving. Days later Bob swipes right on Alice. That second swipe must turn two independent writes into one match, create a conversation that only the pair can access, and make the match discoverable even when a push provider is slow.

The boundary we will protect

Discovery can be approximate; consent cannot. We will use fast, rebuildable read models for profiles and feed generation, then cross a narrow correctness boundary for the pair decision. While the match row is the authority for conversation access, notifications and caches are projections that may lag or disappear without changing who is matched.

  • Feed reads are allowed to be eventually consistent, but block/report filters are applied again at decision boundaries.

  • A canonical sorted pair has at most one active match row, even if both right-swipes arrive together or are retried.

  • Every message is authorized against the match and passes a safety policy before delivery.

  • A durable outbox makes push delivery retryable; the in-app match remains the source of truth.

Alice's mobile client sends location and preference filters through an API gateway to a geo profile index and short-lived feed cache, while swipe history excludes seen profiles.
The feed is a privacy-aware read model: it filters candidates quickly, but it never grants a match or exposes exact coordinates.

1. Profile discovery is a read model

The public request is small: GET /feed?lat={lat}&lon={lon}&radius={km}. The authenticated account supplies the viewer identity; the client does not get to choose whose swipe history or blocks are used. The feed service loads Alice's preferences, queries a denormalized profile index, removes disallowed IDs, and returns a small stack with a server-generated cursor.

PostgreSQL remains the canonical profile store. An asynchronous change event updates an Elasticsearch document containing searchable interests, age-range fields, a coarse location cell, and visibility flags. Elasticsearch's Open source: geo-distance query can filter profiles around a point, while the application owns the policy that rounds distance, hides exact coordinates, and excludes blocked or hidden accounts.

A short-lived feed cache makes the first screen fast, and a background warmer can refill it when Alice has only a few cards left. The cache is an optimization with a freshness budget, not a permission system. Before returning a card, the service checks a recent swipe set and block set; after a stale card reaches the client, the swipe endpoint checks those rules again.

The primary source describes the same tension between a low-latency stack and stale candidates. Our design adds a privacy rule: location is used for filtering, then reduced to a distance bucket such as within 5 km. Exact latitude and longitude stay behind the profile service and are never embedded in the card payload.

The service owns the allowlisted query and returns a safe projection, not the raw profile record.

JSON

GET /profiles/_search
{
  "size": 40,
  "query": {
    "bool": {
      "filter": [
        { "term": { "visibility": "discoverable" } },
        { "range": { "age": { "gte": 25, "lte": 35 } } },
        { "geo_distance": {
            "distance": "25km",
            "location": { "lat": 45.7640, "lon": 4.8357 }
        } }
      ],
      "must_not": [
        { "terms": { "profile_id": ["already-swiped-id-1", "blocked-id-9"] } }
      ]
    }
  },
  "sort": ["_score", { "last_active_at": "desc" }]
}

TYPESCRIPT

const card = {
  id: profile.id,
  firstName: profile.firstName,
  age: profile.age,
  distanceLabel: distanceBucket(profile.distanceMeters),
  photos: signedPhotoUrls(profile.photoIds),
};

// Exact coordinates and moderation metadata never cross this boundary.
return card;
Elasticsearch profile indexSearchable profile projection + geo cellSeconds; rebuilt from canonical profilesNo
Feed cacheCandidate IDs and safe card fieldsTens of seconds; refreshed on depletionNo
Swipe serviceAuthenticated target + decision + idempotency keyCurrent request plus durable historyOnly through pair decision
Match databaseCanonical pair, status, participantsCommitted transactionYes
Discovery optimizes for speed and relevance; the match path re-checks the rules before accepting consent.

2. Swipes are durable history plus a pair-local decision

The endpoint is POST /swipes/:targetId with a decision of right or left. The authenticated subject comes from the session token. The service records the actor's swipe in a write-optimized history table keyed by actor ID so “who has Alice seen?” remains cheap even as the history grows.

Match detection has a different access pattern: it needs the two directions for one pair at the same time. We derive a stable pair key by sorting the two opaque user IDs. A short Redis hash stores the recent directions for that pair, and a Lua script writes the current direction and reads the inverse in one atomic server-side operation. Redis's Open source: Lua scripting guarantee makes that local decision fast without pretending that Redis is the durable match ledger.

When both directions are right, the service upserts a match row with a unique pair constraint. The insert is idempotent: the first request creates the row and an outbox event; a duplicate returns the existing match and does not send a second “new match” event. If Redis is unavailable, the service fails closed and retries rather than falling back to a check-then-set race.

This is deliberately a hybrid. Durable history can be partitioned by actor for write throughput; the pair key is a tiny coordination surface for immediate reciprocity; PostgreSQL or another transactional store owns the durable relationship. A repair worker can replay recent swipes and reconcile a Redis miss, while the unique pair constraint prevents duplicate matches during recovery.

Alice and Bob swipe right at nearly the same time; an atomic pair-local decision creates one match row and an outbox fans notifications to both devices.
A canonical sorted pair gives concurrent swipes one decision point and one durable match identity.
One atomic pair-key operation detects reciprocity; the durable match insert remains idempotent and unique.

Lua

local current = ARGV[1]
local direction = ARGV[2]
local inverse = ARGV[3]

redis.call("HSET", KEYS[1], current, direction)
local other = redis.call("HGET", KEYS[1], inverse)

if direction == "right" and other == "right" then
  return "MATCH"
end
return "RECORDED"

TYPESCRIPT

const pairKey = [actorId, targetId].sort().join(":");
const result = await redis.evalsha(pairDecisionSha, {
  keys: ["swipe:" + pairKey],
  arguments: [actorId, decision, targetId],
});

if (result === "MATCH") {
  await matchStore.upsert({
    pairKey,
    participants: [actorId, targetId].sort(),
    unique: true,
  });
}

3. A match opens a guarded conversation

A successful match creates a conversation ID that is unrelated to the users' public IDs. The conversation service checks the match row on every send, read, and attachment operation. A stale mobile client may know that a match once existed, but a block, report, safety action, or account deletion can change whether a message is accepted now.

Messages are append-only records keyed by conversation ID and assigned a monotonically increasing sequence. The client may retry with the same idempotency key after a timeout; the service returns the original message rather than appending a second copy. A per-conversation sequence lets both devices resume from a cursor and makes ordering explicit without requiring global ordering across all conversations.

Before the append is acknowledged, a safety policy evaluates the sender, recipient relationship, rate, language, and recent reports. A message can be delivered, held for review, or rejected. The policy engine is intentionally advisory for ranking but authoritative for the delivery decision: a queued message does not leak to Bob until the gate marks it deliverable.

Privacy is a data-shaping rule, not a sentence in the terms of service. Profile cards expose only the fields the discovery contract needs; photos use signed, short-lived URLs; exact coordinates, device tokens, moderation notes, and report details remain server-side. Blocking is checked both when generating the feed and when processing a swipe or message, so stale cards cannot bypass a new boundary.

A mutual match enables a conversation ID; Alice's message passes an authenticated API and moderation gate before entering an ordered message log and delivery queue for Bob.
The match authorizes the conversation, while moderation and per-conversation ordering govern what Bob can receive.
ProfileProfile databaseSafe card + coarse distanceHide or refresh; never expose exact location
MatchUnique pair rowMatch for either participantRetry or reconcile; do not create a second row
MessageAppend-only conversation storeAuthorized sequence rangeRetry idempotently or hold for review
Report / blockSafety policy storeOutcome, not reporter detailsFail closed for delivery and discovery
The same match relationship is projected into different surfaces with different privacy and consistency rules.

4. Notifications are durable fan-out, not match creation

The second right-swipe creates a match inside a transaction that also writes match.created to an outbox. A worker reads that event and updates in-app unread state, then attempts push delivery through APNs or FCM. The source of truth is the match row; the push is only a hint that brings the user back to it. Firebase describes FCM as a trusted server-to-device messaging path, which fits this projection model.

Each attempt carries an event ID, user ID, device token, and a minimal payload such as “You have a new match.” It does not put private profile fields or message text into a notification that can appear on a locked screen. The provider response is stored with the attempt, and transient failures retry with backoff. Invalid device tokens are retired; exhausted work goes to a dead-letter queue for inspection.

Duplicate delivery is normal. The worker claims an outbox row, sends with a provider-specific idempotency strategy where available, and records the attempt key. The client treats the push as a wake-up and fetches current matches. If Alice misses every push while her phone is offline, the next authenticated GET /matches still returns the durable relationship.

A committed match writes a durable outbox event; a worker fans it out to APNs and FCM, retries failed delivery, and sends exhausted work to a dead-letter queue.
Push delivery is a retryable projection of the match row, so a provider outage cannot erase the relationship.
The outbox worker retries transport failures while keeping in-app state independent from provider availability.

SQL

BEGIN;

INSERT INTO match_outbox (event_id, match_id, kind, payload)
VALUES (:event_id, :match_id, 'match.created', :payload)
ON CONFLICT (event_id) DO NOTHING;

COMMIT;

TYPESCRIPT

for (const delivery of claimBatch("match.created")) {
  try {
    await pushProvider.send({
      token: delivery.deviceToken,
      data: { eventId: delivery.eventId, type: "match.created" },
    });
    await markDelivered(delivery.eventId);
  } catch (error) {
    await rescheduleOrDeadLetter(delivery, error);
  }
}
A dry-run trace makes the race and the retry boundary visible without claiming provider timing is universal.

5. Consistency and failure behavior

The design is not “strongly consistent everywhere.” It is explicit about where staleness is acceptable and where a retry must stop rather than guess. A stale search result can be refreshed. A missing push can be recovered by fetching matches. A concurrent pair decision must serialize, and an unauthorized message must fail closed.

When Redis loses a pair key, the swipe service does not assume that the inverse swipe is absent. It returns a retryable error, persists the durable swipe with its idempotency key, and lets a reconciler rebuild recent pair state. When Elasticsearch lags, the feed may miss a new profile; it must not bypass a block because an old document still says “discoverable.” When a message provider fails, the append-only store and unread counter remain durable while delivery retries.

Alice and Bob can therefore see different intermediate states without violating the product promise. Alice may see a local optimistic heart before the server confirms. Bob may receive the match push after opening the app and discovering it through GET /matches. What cannot differ is the committed pair identity, the participant set authorized to read the conversation, and the sequence of accepted messages.

Feed index lagsA new candidate appears late or a stale card is refreshedCDC retry, index rebuild, decision-time policy checkBlocked or already-swiped profiles are not intentionally reintroduced
Both right-swipes raceOne request says matched; the other reads the same matchPair-local atomic decision + unique upsertOne canonical match row
Redis pair key unavailableSwipe retries instead of guessingDurable swipe replay and pair-state rebuildNo duplicate or phantom match
Push provider unavailableNo push yet; match remains in-appOutbox retry, token cleanup, dead-letter inspectionNotification cannot erase the relationship
Message retry or moderation holdOne message or a review status, never two appendsIdempotency key, sequence cursor, policy decisionOnly authorized, deliverable messages reach the pair
Failures are handled according to the durable fact that already exists, not according to the last UI state.

Whole-system summary

The detailed figures isolate the boundaries: geo discovery, atomic reciprocity, conversation safety, and notification recovery. The Nodefall canvas below reconnects them. Start at Alice and Bob's mobile app, follow the feed through the profile index and cache, then trace the two swipe paths into the pair decision and match database. From there, one match-authorized conversation passes through moderation and message storage, while the outbox fans durable events to the push worker.

The graph is an explanatory snapshot, not a claim about Tinder's internal implementation. Its value is the composition: every fast projection has a named freshness or privacy boundary, and every correctness decision has a durable recovery path.

Tinder system: discovery, matching, messaging, and safety

The full request map separates geo discovery, swipe history, pair-local match creation, match-authorized messaging, safety policy, and durable push fan-out.

Open canvas

Sources and further reading

These references support specific concepts. The Alice/Bob narrative, architecture composition, privacy boundaries, failure matrix, code, diagram, and visual assets are original Nodefall work.