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.

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.
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 index | Searchable profile projection + geo cell | Seconds; rebuilt from canonical profiles | No |
| Feed cache | Candidate IDs and safe card fields | Tens of seconds; refreshed on depletion | No |
| Swipe service | Authenticated target + decision + idempotency key | Current request plus durable history | Only through pair decision |
| Match database | Canonical pair, status, participants | Committed transaction | Yes |
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.

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.

| Profile | Profile database | Safe card + coarse distance | Hide or refresh; never expose exact location |
| Match | Unique pair row | Match for either participant | Retry or reconcile; do not create a second row |
| Message | Append-only conversation store | Authorized sequence range | Retry idempotently or hold for review |
| Report / block | Safety policy store | Outcome, not reporter details | Fail closed for delivery and discovery |
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.

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);
}
}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 lags | A new candidate appears late or a stale card is refreshed | CDC retry, index rebuild, decision-time policy check | Blocked or already-swiped profiles are not intentionally reintroduced |
| Both right-swipes race | One request says matched; the other reads the same match | Pair-local atomic decision + unique upsert | One canonical match row |
| Redis pair key unavailable | Swipe retries instead of guessing | Durable swipe replay and pair-state rebuild | No duplicate or phantom match |
| Push provider unavailable | No push yet; match remains in-app | Outbox retry, token cleanup, dead-letter inspection | Notification cannot erase the relationship |
| Message retry or moderation hold | One message or a review status, never two appends | Idempotency key, sequence cursor, policy decision | Only authorized, deliverable messages reach the pair |
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.
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.
Primary inspiration: Open source: Hello Interview's Tinder system-design breakdown for the problem framing, API shape, feed concerns, mutual-match flow, consistency questions, and notification sketch.
Geo filtering: Open source: Elasticsearch geo-distance query and Open source: geo_point mapping for the search mechanics.
Pair-local atomicity: Open source: Redis Lua scripting documentation for the atomic decision operation.
Push delivery: Open source: Firebase Cloud Messaging documentation for the transport boundary and retryable projection model.
