Implementing User Leaderboards for Mobile App Gamification
A leaderboard seems simple at first — sort users by score. But when you have thousands of users, a basic ORDER BY score DESC can overwhelm your database. And when you need real-time ranking for a single user among millions, you need Redis and specialized data structures.
Architecture for Different Scales
Up to 10,000 users: PostgreSQL with RANK() / DENSE_RANK() window functions. Paginated queries perform well even without special indexes. Cache the top 100 in Redis with a 5-minute TTL.
10,000 – 1,000,000 users: Redis Sorted Sets (ZADD, ZRANK, ZREVRANK, ZREVRANGE). To add points: ZADD leaderboard:global NX <score> <user_id> or ZINCRBY leaderboard:global <delta> <user_id>. To get a user's rank: ZREVRANK leaderboard:global <user_id> — O(log N). Top 100: ZREVRANGE leaderboard:global 0 99 WITHSCORES — O(log N + 100). This scales without degradation even to millions of users.
Millions of users: Shard Redis Sorted Sets across time periods and regions. A global all-time leaderboard becomes an unreachable goal for most users, which is demotivating. Segmentation works better.
Leaderboard Time Periods
All-time leaderboards for top players, weekly and monthly for everyone else. Reset at the start of each period: don't delete data, archive it. Users should see their historical results.
Implementation: use separate keys for each period — leaderboard:weekly:2025-W13, leaderboard:monthly:2025-03. When a period expires, create a new key; the old one remains for history. Set TTL on old keys: 30 days for weekly, 90 for monthly.
"Around Me" Ranking
The most motivating leaderboard type for average users. Not "top 100," but "you're ranked 4573rd, here are 5 people above and 5 below." This motivates users who'll never reach the top.
Implementation on Redis: ZREVRANK to get the user's rank, then ZREVRANGE(rank-5, rank+5) for neighboring positions. Make an additional PostgreSQL query to fetch display name and avatar by user_id from the results.
Social Leaderboards
Friend leaderboards often motivate more than global ones. Implementation is more complex: a global sorted set doesn't make sense for a list of 50 friends.
Option 1: On screen load, query PostgreSQL: SELECT user_id, score FROM user_scores WHERE user_id IN (:friends_list) ORDER BY score DESC. Works well for a small number of friends.
Option 2: Maintain a separate sorted set for each user — leaderboard:user:<user_id>:friends — updated whenever any friend's score changes. Higher memory cost in Redis, but instant reads.
Display and UX
Highlighting the current user's position is mandatory. Animated rank updates when points are earned (scroll + highlight). Show growth/decline arrows next to the position — "you moved up 12 places today." Display a historical chart of the user's ranking over the period.
On Flutter: use AnimatedList for smooth updates. On iOS: UITableView with performBatchUpdates. Don't reload the entire list on update — only refresh changed cells.
Aggressively cache avatars in leaderboards. Use SDWebImage (iOS) / Glide (Android) / cached_network_image (Flutter) with disk caching. Don't show the leaderboard without names and avatars — display a skeleton loader while fetching.
Timeline Estimates
A basic leaderboard with weekly/monthly periods and user ranking — 2–3 days (client) + 2–3 days (backend on Redis). With social leaderboards, "around me," ranking history, and real-time updates — 1–2 weeks. Pricing is calculated individually.







