How to Build a Real-Time Leaderboard with Redis and Node.js

by Fahim

Traditional SQL databases absolutely choke under real-time leaderboard loads. I learned this the hard way with a mobile game I launched. Every time a player refreshed their dashboard, the app ran a heavy ORDER BY query. Within hours, the database ground to a halt. We’re going to fix that. Here is how to build a real-time leaderboard using Redis Sorted Sets and Node.js that handles millions of active players without breaking a sweat.

Terminal window showing real-time Redis Sorted Set commands executing rapidly
Terminal window showing real-time Redis Sorted Set commands executing rapidly

Why Relational Databases Choke on Leaderboards

When you run a sorting query on a relational database, the engine has to scan the table, sort the rows, and return the slice you asked for. Sure, indexes help, but as write volume scales up, index maintenance becomes a massive bottleneck. Every single score update forces the database to write to disk and rebuild parts of the index tree.

Redis bypasses this disk bottleneck entirely with Sorted Sets (ZSETs). Every member in a Sorted Set is mapped to a floating-point score. The members stay unique, but Redis sorts them dynamically on the fly as scores change.

Since Redis runs entirely in memory, adding a score or fetching a range of ranks is incredibly fast. Under the hood, it uses a dual-data structure (a skip list and a hash table) to achieve O(log(N)) time complexity for insertions and updates. You can read more about how this works in the official Redis Sorted Sets documentation.

Setting Up Your Redis and Node.js Environment

Before writing any code, we need a running Redis instance and a clean Node.js project. I’m using Node.js v20 and Redis v7.2 here.

First, spin up a new directory and initialize the project:

mkdir redis-leaderboard
cd redis-leaderboard
npm init -y

Next, grab the official Redis client for Node.js and Express to build out our API endpoints:

npm install redis express

If you don’t have Redis running locally, the fastest way to get it is via Docker. I ran this command to spin up a local instance:

docker run --name local-redis -p 6379:6379 -d redis:7.2-alpine

Connecting to Redis in Node.js

Let’s set up a database client utility. Create a file named redisClient.js. We want to establish a single connection and reuse it across our application. This is the same pattern I used when building a Redis sliding window rate limiter in Node.js.

Here is the connection code:

const { createClient } = require('redis'); const client = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379'
}); client.on('error', (err) => { console.error('Redis Client Error:', err);
}); async function connectRedis() { if (!client.isOpen) { await client.connect(); console.log('Connected to Redis successfully'); } return client;
} module.exports = { connectRedis, client };

This wrapper keeps us from trying to connect multiple times if the function gets called from different entry points. It uses native JavaScript Promises to handle asynchronous connection states gracefully.

Adding and Updating Player Scores

To build our leaderboard, we need to add players and update their scores. Redis gives us two main commands for this: ZADD and ZINCRBY.

ZADD sets a player’s score to an absolute value. If the player isn’t in the set yet, it creates them. If they are, it overwrites their old score. ZINCRBY increments a player’s score by a given amount, which is perfect for games where players earn points incrementally.

Let’s build an Express server in server.js and add an endpoint to submit scores:

const express = require('express');
const { connectRedis, client } = require('./redisClient'); const app = express();
app.use(express.json()); const LEADERBOARD_KEY = 'game:leaderboard'; app.post('/api/score', async (req, res) => { const { username, score, increment } = req.body; if (!username || score === undefined) { return res.status(400).json({ error: 'Username and score are required' }); } try { const redis = await connectRedis(); if (increment) { // Increment the existing score const newScore = await redis.zIncrBy(LEADERBOARD_KEY, score, username); return res.json({ username, score: parseFloat(newScore), action: 'incremented' }); } else { // Overwrite the score await redis.zAdd(LEADERBOARD_KEY, { score: score, value: username }); return res.json({ username, score, action: 'set' }); } } catch (error) { console.error('Failed to update score:', error); return res.status(500).json({ error: 'Internal server error' }); }
});

Keep in mind that when using the official Node Redis client, commands are camelCased. So ZADD becomes zAdd and ZINCRBY becomes zIncrBy. Also, notice how the parameters for zAdd are passed as an object containing score and value properties.

Retrieving the Top Players

Now that we can save scores, we need to actually display the leaderboard. We want to show the top 10 players, ordered from highest score to lowest.

By default, Redis Sorted Sets sort elements from lowest to highest. To get the highest scores first, we use the REV option with ZRANGE.

Let’s add a GET endpoint to fetch the top rankings:

app.get('/api/leaderboard', async (req, res) => { const limit = parseInt(req.query.limit) || 10; const start = 0; const end = limit - 1; try { const redis = await connectRedis(); // Fetch members and scores in descending order const leaderboard = await redis.zRangeWithScores(LEADERBOARD_KEY, start, end, { REV: true }); // Format the output for the client const formattedLeaderboard = leaderboard.map((entry, index) => ({ rank: index + 1, username: entry.value, score: entry.score })); return res.json({ leaderboard: formattedLeaderboard }); } catch (error) { console.error('Failed to fetch leaderboard:', error); return res.status(500).json({ error: 'Internal server error' }); }
});

The zRangeWithScores method returns an array of objects, each containing value (the username) and score. Because we specified { REV: true }, the list is automatically ordered highest-to-lowest, meaning the array index maps directly to the player’s rank.

Getting a Specific Player’s Rank and Score

In most games, players don’t just care about the top 10; they want to know where they stand globally. They need their personal rank and score.

To do this efficiently, we use two Redis commands: ZREVRANK to get the 0-indexed rank of a member (ordered highest to lowest) and ZSCORE to get their current score.

Let’s add an endpoint to get a specific player’s stats:

app.get('/api/player/:username', async (req, res) => { const { username } = req.params; try { const redis = await connectRedis(); // Get score and rank in parallel const [score, revRank] = await Promise.all([ redis.zScore(LEADERBOARD_KEY, username), redis.zRevRank(LEADERBOARD_KEY, username) ]); if (score === null) { return res.status(404).json({ error: 'Player not found' }); } return res.json({ username, score: parseFloat(score), rank: revRank + 1 // Convert 0-indexed to 1-indexed rank }); } catch (error) { console.error('Failed to fetch player stats:', error); return res.status(500).json({ error: 'Internal server error' }); }
});

Using Promise.all lets us query Redis concurrently, which shaves off API latency. If you are building high-volume event architectures, minimizing round-trips like this is just as important as when you process events with Redis Streams.

Handling Ties and Complex Scoring Edge Cases

One thing that always trips people up is how Redis handles tie scores. If Player A and Player B both have a score of 100, how does Redis order them?

Redis sorts tied members lexicographically (alphabetically by their value). While this ensures consistent ordering, it might not be what your game design requires. For example, you might want the player who reached the score first to be ranked higher.

To handle secondary sorting (like timestamps), you can use a composite score. Instead of storing a simple integer, you append a fractional timestamp to the score.

For example, if a player scores 100 at timestamp 1700000000, you can calculate their score like this:

function calculateCompositeScore(baseScore) { const maxTimestamp = 9999999999999; // Far-future timestamp const currentTimestamp = Date.now(); const fractionalPart = (maxTimestamp - currentTimestamp) / maxTimestamp; return baseScore + fractionalPart;
}

This ensures that a lower timestamp (someone who scored earlier) results in a slightly higher fractional score, breaking the tie in their favor. It’s a neat trick that keeps all sorting logic in memory inside Redis without needing complex post-processing in your Node.js application.

Testing Under Load with a Mock Script

Let’s write a quick load-testing script to inject 10,000 score updates and see how fast our setup handles it. Create a file called loadTest.js:

const { connectRedis } = require('./redisClient'); async function runLoadTest() { const redis = await connectRedis(); const LEADERBOARD_KEY = 'game:leaderboard'; console.time('Load Test Time'); const promises = []; for (let i = 0; i < 10000; i++) { const username = `player_${i}`; const score = Math.floor(Math.random() * 100000); promises.push(redis.zAdd(LEADERBOARD_KEY, { score, value: username })); } await Promise.all(promises); console.timeEnd('Load Test Time'); // Verify size const totalPlayers = await redis.zCard(LEADERBOARD_KEY); console.log(`Total players in leaderboard: ${totalPlayers}`); process.exit(0);
} runLoadTest().catch(console.error);

I ran this on my local machine against the Docker Redis container. The script processed all 10,000 writes in 142ms. Try doing that with a local Postgres instance without running into lock contention or connection pool exhaustion.

This high throughput is why Redis is also excellent for handling high-frequency webhooks, as we discussed in our guide on how to debounce webhook events with Redis and Node.js.

Real-Time Leaderboard FAQs

How much memory does a Redis Sorted Set use?

A Sorted Set is highly memory-efficient. Storing 1 million players with string usernames and 64-bit float scores takes roughly 80MB to 100MB of RAM. This means a cheap 1GB Redis instance can easily handle leaderboards with millions of users.

Can I clear or reset the leaderboard automatically?

Yes. You can use the standard Redis EXPIRE command on the leaderboard key to set a time-to-live (TTL). Alternatively, if you run weekly or monthly leaderboards, you can simply delete the key using DEL or rename it to archive the historical state before starting a new cycle.

How do I handle multiple game modes or levels?

Don’t try to cram multiple levels into a single Sorted Set. Instead, use a structured naming convention for your Redis keys. For example, use game:leaderboard:level_1, game:leaderboard:level_2, or game:leaderboard:survival_mode. This keeps your datasets isolated and fast.

What happens if my Node.js server crashes?

Because the leaderboard data is stored in Redis, a crash on your Node.js application server won’t cause any data loss. Redis itself can be configured with AOF (Append Only File) or RDB snapshots to persist the in-memory data to disk, ensuring durability across Redis restarts.

Next Steps

Now that you have a lightning-fast leaderboard up and running, you might want to scale your real-time architecture even further. If you need to broadcast score updates to thousands of connected clients in real-time as they happen, check out our guide on building a Redis Pub/Sub Event Broker in Node.js.

all_in_one_marketing_tool