Flint Dating App’s Recommendation Algorithm
A production case study of reciprocal scoring, Elo, cold start, behavioral signals, and reranking solutions in Flint’s dating recommendation system.
Flint is a dating app I’m building. The core backend is NestJS + GraphQL + MongoDB, the mobile client is built with Expo/React Native, and alongside all of this is a separate FastAPI microservice — the recommendation algorithm microservice.
In this post, I go into detail about what happens inside that microservice, why I built it that way, the actual code, and the real problems I encountered. This is not "docs" — it’s a case study. So, in addition to explaining "how it works," I also explain why I made those decisions.
What's Inside
- Problem definition — why dating recommendations are not the same as YouTube/Netflix
- Architecture — why a separate microservice, why 3 databases
- Pipeline — the four stages
- Cold start — the hardest part
- Scoring engine — 6 signals, tier-based weights
- Elo desirability rating system
- Behavioral affinity + Reciprocity (ALS factor vectors)
- Reranker — business rules
- Edge-case fixes made to ensure smooth operation
- What was not implemented, and next steps
I personally reworked the technical design presented in this post after studying 90+ sources, including engineering blogs, academic papers, and ML architecture documents from apps such as Tinder, Hinge, Bumble, OkCupid, and Coffee Meets Bagel, adapting it to Flint's own circumstances.
1. Problem Definition
First, let’s state the most important point clearly: A dating app’s recommendation system is nothing like YouTube’s “videos you might like.”
Here’s why:
| YouTube / Netflix | Dating app |
|---|---|
| User → Content (one-sided) | User ↔ User (two-sided) |
| Only P(user wants content) | P(A likes B) × P(B likes A) |
| Skipping is neutral (It neither increases nor decreases the score) | Rejection affects the score |
| Content is unlimited (1 video can be watched by a million people) | Attention budget is limited (1 person has limited time) |
In other words, on a dating app, the algorithm must predict mutual interest. It is not enough to understand only that “A likes B” — it must also predict what B would do upon seeing A.
This is called the reciprocal recommendation problem. According to research, the RECON framework has demonstrated that reciprocal scoring generates +27–37% more matches than one-sided scoring.
Cold start — doubly difficult
In a conventional recommendation system, when a new user joins, it is unclear which movie/video to recommend (User cold start). In a dating app, this problem is compounded:
- User cold start — a newly joined user has 0 swipes
- Two-sided cold start — we do not know how many times the newly joined user’s profile has appeared in other users’ feeds
- Item cold start — the same applies to new profiles (calling a person an "item" in the dating market feels strange)
If new users are shown only the most popular profiles — a phenomenon called a "popularity spiral" may emerge: the top 10% of profiles receive all the likes, while the rest get buried in the noise.
Gender imbalance
According to statistics published by Tinder, men swipe right on 46% of profiles, while women do so on only 14%. Even before applying any discounting or arithmetic adjustment, this is an enormous imbalance. If the algorithm is based solely on the “number of likes,” women’s likes become extremely valuable — and the top women have already disappeared from view.
2. Architecture
The entry point is the NestJS GraphQL backend. The mobile client sends all requests through it. However, the recommendation logic is:
- Heavy on ML/scoring mathematics
- Best implemented using numerical computation tools such as Pandas/NumPy
- Not allowed to block the main backend's event loop
- Expected to support A/B testing and model swaps with at least reasonable ease
- Dependent in some cases on supporting data that requires OLAP-like queries (percentiles, partitioned event tables)
Therefore, I separated it into a standalone FastAPI microservice.
┌──────────────────┐ ┌──────────────────────────┐
│ NestJS Backend │ │ Flint Algorithm │
│ (GraphQL) │ │ (FastAPI) │
│ │ │ │
│ Auth │ │ GET /candidates │
│ Users CRUD │ HTTP │ POST /swipes │
│ Chat │──────>│ POST /events │
│ Payments │ │ GET /leaderboard │
│ Match creation │ │ GET /metrics/* │
└──────┬───────────┘ └────────┬─────────────────┘
│ │
▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────────┐
│ MongoDB │ │ Redis │ │ PostgreSQL │
│(shared) │ │ (shared) │ │ (algo-only) │
└─────────┘ └──────────┘ └──────────────┘
Why 3 databases?
This was not driven by the dogma that "a microservice must always have its own database." The main reason is that each database has its own strengths:
- MongoDB (shared, read-only) — User profiles are already stored here. Because the algorithm only needs to fetch nearby people using a
$geoNearaggregation, reading directly from it is the least expensive option. I do not want the algorithm to own the schema — NestJS owns it. - PostgreSQL (algo-owned) — Swipe history, Elo ratings, behavioral events. This data involves:
- Analytical queries (percentiles, GROUP BY user_id, COUNT)
- Range partitioning (the
behavioral_eventstable creates a partition every month) - Transactional updates (write-through for Elo ratings) All of this can be done in Mongo, but it is more natural in Postgres.
- Redis (shared) — Cooldown set (so we do not show the same person twice), feature hash, Elo cache, ALS factor vectors, pair interaction hash. This is the data store for the hot path.
How to share JWTs
Internal auth: both services use the same JWT_SECRET. A token issued by NestJS is validated directly by FastAPI. But there is one notable detail: NestJS's sub claim is not just a string, but an object { userId: "<id>" }. Therefore, FastAPI's auth handler supports both formats (app/core/auth.py). Reconciling these kinds of "small differences between us" accounts for 30% of the convenience of a microservice setup.
3. Recommendation Pipeline
The algorithm has 4 stages:
Stage 1 Stage 2 Stage 3 Stage 4
Hard Filters → Candidate Gen → Scoring → Re-Ranking
(instant) (~100ms) (~50ms) (business)
1M → 10K 10K → 500 500 → 50 Final feed
It is crucial to preserve this exact sequence. You may be wondering, "why not just run scoring directly on everyone?" If so, the answer is:
- In a database with 100k users, calculating 100k scores for every user takes ~10 seconds.
- However, 90% of users are immediately eliminated by hard filters such as "age range, gender, and geography".
- Applying hard filters in Mongo using indexes takes less than 50ms.
In other words, we perform the cheap step before the expensive one.
Stage 1 — Hard filters
The following filters are applied within MongoDB's $geoNear aggregation:
gender IN (user.preferences)— the user's preferred genderdob BETWEEN min AND max— age range_id NOT IN (cooldown set)— previously viewed profiles (Redis)_id NOT IN (blocked users)— bidirectional blocks (blockscollection)_id NOT IN (interacted users)— people who received a right swipe or super-like (interactionscollection)distance < radius_km—$geoNearsearch radius
The output of this stage is the IDs of ~10,000 users. It filters a large volume of data cheaply.
Stage 2 — Candidate generation
In LLM systems, this stage is called "retrieval". We need to reduce the number of users from 10,000 to ~500. The reason is that the scoring engine in the next stage calculates 6 signals for each user, so processing more than 10k candidates would impose too much load.
Flint's current implementation keeps a buffer sorted by $geoNear distance — 500 entries.
We plan to add ANN (Approximate Nearest Neighbors) embedding search to this stage in the future. For now, however, the benefits of embeddings do not justify their cost, because the dataset is still relatively small.
Stage 3 — Scoring
This stage uses 6 signals to score the remaining 500 users between 0 and 1.0, then returns the scored users.
The scoring engine is discussed in detail in section 5.
Stage 4 — Reranking
This stage is the most "business-heavy" part:
- New user visibility boost — Increase the visibility of a new user's profile. This is the other side of cold start — "showing a new user's profile to others".
- Premium boost — A small multiplier (1.2x) for Premium profiles.
- Profile boost — A 5x multiplier while a user-purchased "30-minute boost" is active.
- Golden swipes — If only the last swipe remains, show the person with the highest score.
- Active boost decay — The boost multiplier decreases over time.
These rules are kept in reranker.py, separate from the scoring engine. The reasons are:
- Business rules change very frequently (the premium plan price, boost magnitude, and so on)
- Implementing these changes separately from the ML scoring engine enables A/B testing
- Each rule has its own "degree of volatility". For example, the golden swipes rule is intended to motivate users to purchase premium — it has nothing to do with pure ML scoring.
4. Cold start strategy — the hardest part
In a dating app, cold start is about ensuring that users do not immediately abandon the app when they first open it. If a new user dislikes all of the first 10 users they see — they will never open the app again.
Therefore, I had to solve cold start from 2 sides:
- User cold start: how to show good profiles to a new user
- Visibility cold start: how to make a new user's profile visible to others
User cold start path
We count the user's swipes from the swipe_history table (get_user_swipe_count), and if the count is below the threshold (50), we consider that user to be in "cold start".
During cold start, we select from the "popular pool". The popular pool consists of:
- A gender-normalized percentile based on every user's Elo rating (the key step that makes comparisons across genders possible)
- Users between the 40th and 80th percentiles are selected
Why the 40-80 range? Because:
- 0-40 percentile — We do not want to show new users profiles we already know will not work for them (it ruins the first impression).
- 80-100 percentile — Showing the "Top 20%" to every new user creates a popularity spiral.
- 40-80 percentile — "Moderately good" users. They have good engagement, but are not already popular.
async def get_popular_user_ids(
session: AsyncSession,
percentile_min: int = 40,
percentile_max: int = 80,
min_candidates: int = 10,
redis_client=None,
) -> list[str]:
# Redis cache first (1 hour TTL)
if redis_client:
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
scores = await get_desirability_scores(session)
result = _select_in_percentile(percentile_min, percentile_max)
# Fallback chain: P40-P80 too small? P20-P90. Still too small? Full range.
if len(result) < min_candidates:
result = _select_in_percentile(20, 90)
if len(result) < min_candidates:
result = list(scores.keys())
# Cap to 5000 to prevent memory bloat at 100k+ users
if len(result) > max_size:
result = random.sample(result, max_size)
random.shuffle(result)
await redis_client.setex(cache_key, ttl, json.dumps(result))
return result
This function uses my preferred fallback chain pattern: if there are not enough people within the initial range — expand it. If the entire set is too small, return the full list of scores. The algorithm never "returns 0 candidates".
Expanded pool for premium users
If a user is premium during cold start — I give them a wider range (P30-P95). This is because we promised premium users access to the full pool. The union of these ranges is selected based on auth.
Visibility boost — how a new user's profile becomes visible
This is the other side of cold start. When a new profile is created, that person must appear in other users' feeds, not just see their own feed. Otherwise, they will never receive their initial likes, will not be able to increase their Elo rating, and will remain stuck in cold start forever.
Therefore, after scoring finishes, I apply a visibility boost. It works like this:
def compute_visibility_boost(swipe_count, threshold, magnitude):
if swipe_count >= threshold:
return 0.0
if swipe_count <= 0:
return magnitude # Full boost
return magnitude * (1.0 - swipe_count / threshold) # Linear decay
Linear decay. Users with 0 swipes receive the maximum boost (magnitude), which falls to 0 when they reach the threshold (50). Users above the threshold receive no boost.
This boost is added to their score, after which the results are re-ranked.
5. Scoring engine — 6 signals, tier-based weights
This is the heart of the algorithm. I spent the most time on this section.
The scoring engine consists of 6 signals:
| Signal | Meaning | Range |
|---|---|---|
preference_overlap | Jaccard similarity of interests + geographic proximity | [0, 1] |
activity_recency | How recently they were online | [0.1, 1] |
freshness | How new the profile is (14-day half-life decay) | [0, 1] |
elo_proximity | Proximity of desirability percentiles | [0, 1] |
behavioral_affinity | Weighted sum of interactions between the two | [0, 1] |
reciprocity | P(A likes B) × P(B likes A) | [0, 1] |
Final score:
score = (
w1 * preference_overlap +
w2 * activity_recency +
w3 * freshness +
w4 * elo_proximity +
w5 * behavioral_affinity +
w6 * reciprocity
)
Where do the Ws come from? The key observation: scoring weights depend on the user's tier.
Tier-based weight resolution
Using the "swipe count" in MongoDB, users are divided into 3 tiers:
- Cold (< 50 swipes) — New user. We have very little behavioral data about them. Therefore, we rely more heavily on explicit signals (preference_overlap, freshness).
- Growing (50–200 swipes) — Behavioral patterns begin to emerge. The affinity signal starts to be incorporated.
- Mature (200+ swipes) — Fully use all signals. More weight on reciprocity and behavioral affinity.
def resolve_weights_for_tier(swipe_count, behavioral_settings):
if swipe_count < 50:
return ScoringWeights(
preference_overlap=0.35,
activity_recency=0.20,
freshness=0.20,
elo_proximity=0.20,
behavioral_affinity=0.05, # almost no signal
reciprocity=0.0, # disabled
)
elif swipe_count < 200:
return ScoringWeights(
preference_overlap=0.25,
activity_recency=0.15,
freshness=0.10,
elo_proximity=0.20,
behavioral_affinity=0.20,
reciprocity=0.10,
)
else: # mature
return ScoringWeights(
preference_overlap=0.15,
activity_recency=0.10,
freshness=0.10,
elo_proximity=0.15,
behavioral_affinity=0.30, # heavy behavioral
reciprocity=0.20, # full reciprocity
)
This tier-based scheme directly encodes the key finding from dating research that "behavioral > stated." Predicting and "understanding" a person from how they swipe rather than from the information they provide is 3-5 times more accurate. But that data cannot be accumulated on the first day — so we start with explicit signals, then transition.
Behavioral data beats stated preferences 3-5x. Users say one thing, swipe another.
Example: preference_overlap
This is the most "traditional" content-based signal. It combines the user's interests and proximity by distance:
def _preference_overlap(user_profile, candidate):
score_parts = []
# Interest overlap (Jaccard similarity)
user_interests = set(user_profile.get("interests", []))
candidate_interests = set(candidate.get("interests", []))
if user_interests and candidate_interests:
intersection = len(user_interests & candidate_interests)
union = len(user_interests | candidate_interests)
score_parts.append(intersection / union)
# Distance closeness
distance_m = candidate.get("distance_meters", 0)
max_distance_m = user_profile.get("maxDistance", 10) * 1000
if max_distance_m > 0:
closeness = 1.0 - (distance_m / max_distance_m)
score_parts.append(max(0.0, closeness))
if not score_parts:
return 0.5 # neutral
return sum(score_parts) / len(score_parts)
Jaccard similarity — simple set theory. |A ∩ B| / |A ∪ B|. It calculates how many interests two users share relative to how many they have between them. Initially, I planned to use cosine similarity, but Jaccard was sufficient for the current data volume.
Example: freshness
def _freshness(candidate):
created_at = candidate.get("createdAt")
if created_at is None:
return _FRESHNESS_DEFAULT # 0.3
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
days_since_creation = (now - created_at).total_seconds() / 86400
if days_since_creation <= 0:
return 1.0
return math.pow(2, -days_since_creation / 14) # 14-day half-life
This is exponential decay. 14 days is the half-life — in other words, a new profile's score drops to 0.5 after 14 days and to 0.25 after 28 days. This means we do not push older profiles out; instead, we bring new profiles into the main feed early.
6. Elo desirability rating system
This section is substantial enough to be a case study in its own right. But I will explain it at length.
Elo is a rating system that originated in chess tournaments. "Bat's probability of beating Tin is calculated from his score". In a dating app, this means:
- A user receiving a right swipe = win (meaning they are popular)
- Receiving a left swipe = loss
- Weight it with a moderate
K-factor— in other words, keep the impact of a single match relatively small
K-factor decay
Elo's K-factor controls how much the rating can change in a single match. If K is too high, the rating is highly unstable (it rises sharply due to noise); if it is too low, the rating stabilizes very slowly.
In Flint, I use a decaying K-factor:
def k_factor(interaction_count, k_max=64.0, k_min=16.0, tau=50.0):
return k_min + (k_max - k_min) * math.exp(-interaction_count / tau)
interaction_countis the number of swipes the user has received (not made themselves, but received from others)- At
n=0, K = 64 (the highest volatility — a new user should find the correct rating very quickly) - At
n=50, K ≈ 33.7 (halved) - At
n=200, K ≈ 16.3 (almost at the floor) - As
n→∞, K → 16 (asymptote)
This is a simplified version of the Glicko-2 rating system's "rating deviation" concept. A new user's rating should stabilize quickly, but once it has stabilized, it should change very little.
compute_elo_delta
When someone swipes on you, your rating changes. But the rating of the person who swiped also affects your change:
def compute_elo_delta(
candidate_rating, swiper_rating, action, interaction_count,
left_swipe_weight=0.5, super_like_weight=2.0,
):
k = k_factor(interaction_count)
expected = expected_score(candidate_rating, swiper_rating)
if action == "right":
actual = 1.0
elif action == "super_like":
actual = 1.0
k *= super_like_weight
elif action == "left":
actual = 0.0
k *= left_swipe_weight # left swipe doesn't hurt as much
return k * (actual - expected)
Why did I reduce the left-swipe weight to 0.5? Because:
- Users right-swipe because they like someone (a positive event), whereas they make left swipes as a routine action (the default action is simply to "continue swiping")
- The semantic signal of a right swipe is cleaner
- If someone's rating drops quickly because of left swipes, it creates the opposite effect of a popularity spiral
The super-like weight is 2x — because super-likes are purchased (on Tinder), so they should provide a stronger signal.
Gender-normalized percentile
I consider ranking Elo ratings on only one scale to be one of Tinder/Bumble's mistakes. Because men and women receive different average numbers of likes, Elo ratings will have a systematic offset.
Therefore, I use a gender-normalized percentile. The get_gender_percentiles(session, user_ids) function:
- Collects all Elo ratings for each gender (MALE, FEMALE, ...)
- Calculates percentiles separately for each gender
- Returns a number between
[0.0, 1.0]for each user
This is what gives the elo_proximity signal its meaning. We are matching users with similar desirability — in particular, users in the middle percentiles are matched with one another more often.
Write-through cache
Every time a swipe occurs:
- A swipe record is added to the
swipe_historytable - The recipient's Elo rating is updated in the
elo_ratingstable - It is also updated in the Redis cache (write-through). The key is
elo_rating:{user_id}.
Write-through makes it possible to retrieve a user's rating from Redis at read time without reading it from the database. This reduced the time the scoring engine took to read Elo percentiles for 500 users from 200ms to 20ms.
7. Behavioral affinity + Reciprocity
These two signals are where I put in the most creative engineering work.
Pair interaction hash
A Redis hash containing the entire history of interactions between two users:
pair:{userA}:{userB} (sorted alphabetically by ID for uniqueness)
├── messages_count: "24"
├── matched: "1"
├── right_swipe: "1"
├── left_swipe: "0"
├── a_viewed_b: "1"
├── b_viewed_a: "1"
├── last_message_at: "2026-02-15T14:30:00Z"
└── ...
The key uses IDs in alphabetically sorted order to create a canonical form: pair:abc:xyz, not pair:xyz:abc. Otherwise, you are guaranteed to end up with two keys.
When an interaction occurs on the client side, it is posted to the event endpoint:
view— How many milliseconds the profile was viewedmessage— A message was sentmatch— A mutual right-swipe occurredsession— Information about the length of that session
The events.py service that receives events must write them to the pair hash and retain a week's worth of traffic in the behavioral_events table (Postgres partitioned).
Behavioral affinity computation
The scoring engine calculates a weighted sum of interactions from the pair hash:
INTERACTION_WEIGHTS = {
"messages_count": 1.0, # Highest — direct conversation
"matched": 0.8, # Mutual interest event
"right_swipe": 0.6, # Strong positive
"view": 0.3, # Weak positive
"left_swipe": -0.2, # Negative (small)
}
The order of the weights reflects the signal hierarchy. Message has the highest weight (1.0) — because direct conversation is the most important indicator of emotional engagement. View has the lowest (0.3) — because it may result from a random tap or swipe.
A left swipe has a negative value (-0.2). But it is not excessively negative — because a user might previously have swiped left and later want to see that person again ("undo" feature). We do not forget that person entirely.
def compute_behavioral_affinity(
pair_data, user_id, candidate_id,
als_cf_score=None, alpha=0.5,
):
a, b = sorted([user_id, candidate_id])
is_a = user_id == a
direct = 0.0
if int(pair_data.get("messages_count", "0")) > 0:
direct += 1.0
if pair_data.get("matched") == "1":
direct += 0.8
if pair_data.get("right_swipe") == "1":
direct += 0.6
view_key = "a_viewed_b" if is_a else "b_viewed_a"
if pair_data.get(view_key) == "1":
direct += 0.3
if pair_data.get("left_swipe") == "1":
direct -= 0.2
direct = max(0.0, min(1.0, direct)) # clamp
# Optional ALS CF blend
if als_cf_score is not None:
return alpha * direct + (1.0 - alpha) * als_cf_score
return direct
Reciprocity — ALS factor vectors
The next signal: P(A likes B) × P(B likes A).
This section is based on matrix factorization. The ALS (Alternating Least Squares) algorithm works very well with implicit feedback (we only observe "likes," not "dislikes").
Here is what happens under the hood:
- Each user has two vectors:
user_vec(the vector representing whom they seek) anditem_vec(the vector representing them as someone others may seek). These are ~16-32 dimensions in size. - These vectors are retrained by a Celery worker every 4 hours (using the entire swipe history).
- They are converted into probabilities through a sigmoid function.
def compute_reciprocity(user_factors, candidate_factors):
if user_factors is None or candidate_factors is None:
return 0.5 # neutral fallback
n = len(user_factors) // 2
a_user_vec, a_item_vec = user_factors[:n], user_factors[n:]
b_user_vec, b_item_vec = candidate_factors[:n], candidate_factors[n:]
# P(A likes B) = sigmoid(dot(A_user_vec, B_item_vec))
p_ab = _sigmoid(float(a_user_vec @ b_item_vec))
# P(B likes A) = sigmoid(dot(B_user_vec, A_item_vec))
p_ba = _sigmoid(float(b_user_vec @ a_item_vec))
return p_ab * p_ba
This is the mathematical core of reciprocal recommendation. The two probabilities are multiplied, not added. Therefore, when one side is strong and the other is weak, it correctly pulls the final score down (low product, even if sum is high).
For example, if P(A→B) = 0.9 and P(B→A) = 0.1:
- Aggregation (sum) → 1.0 / 2 = 0.5 (looks balanced)
- Reciprocity (product) → 0.09 (revealed asymmetry)
We might mistakenly assume this pair is strong — so the product accurately represents its reciprocal nature.
Excluding reciprocity in the cold tier
As you can see, the reciprocity weight was 0.0 in the cold tier. The reasons are:
- New users have very little swipe history. The ALS factor vector is not sufficiently trained.
- The resulting probabilities are all noise.
- Assigning a weight of 0.0 completely removes that noise from the final score.
Reciprocity = 0.10 in the growing tier and 0.20 in the mature tier. It increases gradually.
8. Reranker — business rules
The scoring engine's job is to answer the question, "how well should one person be matched with another?" But on the business side of the application:
- Show Premium users slightly more often (they are paying)
- Actively show users who have purchased a profile boost for 20 minutes
- Show new users slightly earlier (visibility boost)
- Show users on their last remaining swipe (free tier) the best profile ("golden swipe")
These 6 reranking passes are kept in the reranker.py module, separate from scoring.
Golden swipes
Free-tier users get 10 swipes per day (the Tinder/Hinge model). When they have fewer than 5 swipes remaining, their feed shows the highest-scoring profiles up to the number of swipes remaining.
Why? Because if they "waste" their remaining swipes on trash — they will abandon the app. If we give them the opportunity to use their last swipe on the best profile — the probability of a match increases.
def _apply_golden_swipes(scored, settings, swipes_remaining):
if swipes_remaining is None or swipes_remaining > settings.golden_threshold:
return scored
# Top N candidates (where N = swipes_remaining) get a multiplier
for i in range(min(swipes_remaining, len(scored))):
scored[i].score *= settings.golden_multiplier # e.g., 1.3x
return scored
This is not a "hard rule" — it is blended into scoring via a multiplier.
Profile boost (paid)
When a user purchases a "30-minute boost," is_boosted=True. The Reranker applies a 5x score multiplier when that user appears in other users' feeds.
Active boost decay
After a profile boost is purchased, it is recorded in the active_boosts table in Postgres:
+----------+-----------+--------------+----------------+
| user_id | started_at| expires_at | initial_multiplier|
+----------+-----------+--------------+----------------+
| user_123 | 14:30:00 | 15:00:00 | 5.0 |
+----------+-----------+--------------+----------------+
The fetch_active_boost_multipliers function reads from the Redis cache (5-minute TTL) or Postgres and retrieves the multipliers for all active boosts. The multiplier then decays over time:
- 0–10 minutes — 5.0x
- 10–20 minutes — 3.5x
- 20–30 minutes — 2.0x
- 30+ minutes — boost expired, 0 multiplier
This means the system does not "show the boost all at once and exhaust all demand within a minute" — the user gets sustained value from their boost over the course of a week.
Premium visibility boost
Premium users (1.2x multiplier) — but not excessively high. Because:
- If it is too high (2x+), free-tier users will stop seeing premium users altogether (popularity spiral)
- If it is too low (1.05x), paying for premium will provide no added value
- 1.2x is the optimal choice
9. Edge Case Fixes
This section involved a lot of work — the most time-consuming part of writing an algorithm is not the math, but the edge cases.
Cooldown — Giving Redis a Backup
Once a user has seen a profile, it should not be shown again. This is stored in a Redis sorted set:
cooldown:{user_id} (sorted set with score = expiry timestamp)
But Redis is a cache — it is volatile across restarts. If Redis comes under excessive load and exceeds its memory allocation, older keys may be evicted. That is why I also added a backup query in MongoDB:
seen_ids = await get_excluded_ids(redis_client, user_id) # Redis primary
interacted_ids = await get_interacted_ids(db, user_id) # Mongo backup
block_ids = await get_blocked_ids(db, user_id) # Mongo permanent
all_excluded = list(seen_ids | interacted_ids | block_ids)
The interactions collection is owned by the NestJS backend — it stores every profile on which users have swiped right or sent a super-like (it does not store left swipes — because those are only a "7-day temporary exclusion"). Therefore, all "hard rejections" remain in Mongo.
This means a Redis cache failure does not create the nightmare scenario of "what if we show every profile to someone again."
When $geoNear Returns 0 — Geo Fallback
If the $geoNear aggregation returns 0 candidates — we fall back to a query without geo filtering:
if not raw_candidates:
logger.info("Geo-based query returned 0 candidates, falling back to non-geo query")
raw_candidates = await fetch_candidates_no_geo(
collection=users_collection,
target_gender=target_gender,
min_birth_date=min_birth_date,
max_birth_date=max_birth_date,
excluded_ids=all_excluded + [user_id],
limit=limit,
)
The most common reason this path is triggered is that older users' profiles do not have a GeoJSON location field (I added GeoJSON later). "Not showing" these profiles is risky on a small platform — so we drop the distance requirement and return them through the fallback instead.
The Important Empty Popular Pool Case
When selecting from the popular pool during cold start:
- Popular pool is empty (Elo ratings have not been calculated yet) → geo-only fallback
- Everyone in the popular pool is excluded (the user has already seen all of them) → geo-only fallback
- Popular pool is small (
effective_pool < limit) → geo-only fallback
All three cases use the same geo-only fallback, with separate logging for each one.
Correct Timezone Handling
MongoDB sometimes stores naive datetime values (without a timezone). Values from Postgres, on the other hand, use tzinfo=timezone.utc. But the scoring engine includes:
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
This is a tiny edge case — but without this defensive check, now - created_at raises TypeError: can't subtract offset-naive and offset-aware datetimes. One such bug ran in production for half a day.
Behavioral Events Need Partitions
The behavioral event table is range partitioned by the created_at column. A partition must be created every month:
CREATE TABLE behavioral_events_2026_03
PARTITION OF behavioral_events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
If a new partition has not been created before the start of a month, event INSERTs will fail. Therefore, worker/partition_maintainer.py checks every day whether partitions exist for the current month and the next month.
10. What Wasn't Done and Next Steps
This is an MVP. It has been running for the past 4 months. However, many things remain undone:
Not Implemented (Planned)
- Embedding-based candidate generation — Currently, results are sorted by $geoNear distance. A smarter option would be a user embedding ANN query.
- Two-tower neural network — Train a 200-dim user embedding, like Tinder's TinVec, using Word2Vec Skip-gram on swipe sequences. This is a long-term plan.
- Photo CNN scoring — In addition to a photo's basic quality score, use a CNN to calculate composition and expression scores.
photo_scoring.pycurrently exists as a skeleton. - Photo deep-learning algorithms — Automatically assess underexposed photos, group photos, and photos in which no face is visible.
- Online learning — Currently, ALS retraining runs every 4 hours. A strategy for incrementally updating the model immediately with each new swipe.
- Multi-armed bandit exploration — Use Thompson sampling to manage the trade-off between "30% algorithm selection, 70% the user's previously liked pattern."
Implemented but Needs Improvement
- A/B testing framework — Currently, there are only feature flags. A proper experiment framework (e.g. ramped rollout, statistical significance check) is needed.
- Real-time metrics dashboard — APIs such as
/metrics/elo-healthand/metrics/match-ratesexist. However, they need to be integrated with PostHog to create a real dashboard. - Fairness constraints — The algorithm should automatically detect when "people from one group are shown slightly more often."
Decided Not to Implement
- Federated learning — Too complex. The platform is too small.
- Identity provider integration — Phone + Email is sufficient. The ROI from Apple/Google OAuth here is too low.
- Voice-based prompts — An interesting Hinge feature, but audio storage is expensive.
Epilogue — What We Learned
This is my first time building an end-to-end recommendation system for production. No matter how much of the article I tried to capture in the conclusions below, there are limits.
I hope this case study has provided useful information to the engineers reading it. A recommendation system is not a topic one person can cover in a short post — I acknowledge that this article merely scratches the surface. Yet it firmly instilled in me the understanding that "ML/AI is a small asymmetric advantage over ordinary SaaS."
In my next post, I will write about what happens inside the Celery worker for ALS training, how I tuned the maintenance schedule for the partitioned table, and how to identify edge cases occurring in production.
All code fragments mentioned in this post come from the Flint algorithm's own codebase. I have not published the sources because they are in a private repository.