Use case: "We had to build a live ranking system for a 7-day user challenge based on daily logs, with 10,000+ participants. Here's how we achieved accurate dense ranking efficiently."
๐ง The Problem
Our product team designed a 7-day streak challenge where users earn coins by logging their daily routines. We wanted to:
- Show users their current rank
- Show how many people theyโre ahead of or behind
- Update this every day, based on how many days theyโve logged
- Support dense ranking (equal scores share the same rank)
What is Dense Ranking?
In dense ranking, if multiple users have the same score, they share the same rank, and the next rank increments by one โ not by the count.
Example:
Days Logged | User ID | Rank |
---|---|---|
5 | U1 | 1 |
5 | U2 | 1 |
4 | U3 | 2 |
4 | U4 | 2 |
3 | U5 | 3 |
๐งฑ System Overview
๐ MongoDB
- We store logging data in a
streak_logs
collection:
{
user_id: "abc123",
first_date_of_log: "2025-07-06T00:00:00Z",
last_date_of_log: "2025-07-10T00:00:00Z"
}
โก Redis
-
Every midnight, a cron job:
- Computes the ranks
- Caches results as:
BAH!CHALLENGE!RANK!MAP!YYYY-MM-DD
BAH!CHALLENGE!RANK!SUMMARY!YYYY-MM-DD
โ๏ธ How We Computed Dense Ranks in JavaScript
Step 1: Fetch all eligible users in batches
async function* fetchEligibleUserIdsInBatches() {
const cursor = UserTaskDetail.find({ is_active: true, task_id: CHALLENGE_TASK_ID }).select('user_id -_id').cursor();
let batch = [];
for await (const doc of cursor) {
batch.push(doc.user_id);
if (batch.length === 1000) {
yield batch;
batch = [];
}
}
if (batch.length) yield batch;
}
Step 2: Fetch each userโs logged days
for (const log of allLogs) {
const effectiveStart = moment.max(challengeStart, moment(log.first_date_of_log));
const effectiveEnd = moment.min(challengeEnd, moment(log.last_date_of_log));
const daysLogged = Math.max(effectiveEnd.diff(effectiveStart, 'days') + 1, 0);
userDayMap[log.user_id] = daysLogged;
}
Step 3: Group users by days logged
const scoreGroups = {};
for (const { userId, daysLogged } of usersWithDays) {
if (!scoreGroups[daysLogged]) scoreGroups[daysLogged] = [];
scoreGroups[daysLogged].push(userId);
}
Step 4: Assign Dense Ranks
const sortedScores = Object.keys(scoreGroups).map(Number).sort((a, b) => b - a);
let rank = 1;
for (const score of sortedScores) {
for (const userId of scoreGroups[score]) {
userRankMap[userId] = rank;
}
rank += 1;
}
Step 5: Cache the Results
const rankMapKey = `BAH!CHALLENGE!RANK!MAP!${istDateKey}`;
const rankSummaryKey = `BAH!CHALLENGE!RANK!SUMMARY!${istDateKey}`;
await Promise.all([
cache.setAsync(rankMapKey, JSON.stringify(userRankMap), 'EX', 86400),
cache.setAsync(rankSummaryKey, JSON.stringify(rankSummary), 'EX', 86400)
]);
Step 6: Generate User Message on Frontend
const getRankStatus = (rankNumber, rankSummary) => {
const totalParticipants = Object.values(rankSummary).reduce((sum, val) => sum + val, 0);
if (rankNumber === 1) {
const topUsers = rankSummary['1'] || 1;
return `You're ahead of ${totalParticipants - topUsers} participants`;
} else {
return `You're behind ${rankNumber - 1} users`;
}
};
๐งช Results
- Efficient computation of dense ranks for 10K+ users
- Consistent Redis-based caching for real-time API usage
- Easily extendable for longer challenges or other metrics
๐ก Learnings
-
Batching Mongo queries avoids
$in
overloads. - Dense ranking is perfect for fair user gamification.
- Precomputing + caching is the key to fast API response times.
๐ Final Thoughts
Dense ranking can be tricky at scale โ but with a bit of precomputation and smart caching, it becomes highly performant. If you're building habit-based features or gamified tasks, this pattern works beautifully.
Top comments (0)