Building a Quiz Leaderboard with Real-Time Updates
Build a live quiz leaderboard with ranking algorithms, efficient data models, and real-time delivery using SSE and WebSockets.
Leaderboards Drive Engagement
A leaderboard transforms a solo quiz experience into a competitive one. When learners see their name climbing the ranks, they take more quizzes and study harder. But building a leaderboard that updates in real time and scales to thousands of concurrent players takes some thought.
This tutorial covers the data model, ranking algorithms, and three approaches to real-time delivery: polling, Server-Sent Events, and WebSockets.
Prerequisites
- Node.js 20+
- PostgreSQL 15+
- Redis 7+
- Basic understanding of SQL window functions
Data Model
Start with the database schema. You need to store quiz attempts and derive rankings from them:
CREATE TABLE quiz_attempts (
id SERIAL PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
quiz_id VARCHAR(255) NOT NULL,
score INTEGER NOT NULL,
max_score INTEGER NOT NULL,
time_spent_seconds INTEGER NOT NULL,
completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_attempts_quiz_completed
ON quiz_attempts(quiz_id, completed_at);
CREATE INDEX idx_attempts_user
ON quiz_attempts(user_id);
CREATE TABLE user_profiles (
user_id VARCHAR(255) PRIMARY KEY,
display_name VARCHAR(100) NOT NULL,
avatar_url TEXT,
total_xp INTEGER DEFAULT 0
);
Ranking Algorithms
There are several ways to rank players. The right choice depends on what behavior you want to encourage.
Highest Score - simple and clear, but discourages retaking quizzes once you have a perfect score:
SELECT
u.display_name,
u.avatar_url,
a.score,
a.max_score,
a.time_spent_seconds,
RANK() OVER (ORDER BY a.score DESC, a.time_spent_seconds ASC) as rank
FROM quiz_attempts a
JOIN user_profiles u ON u.user_id = a.user_id
WHERE a.quiz_id = $1
AND a.completed_at > NOW() - INTERVAL '24 hours'
ORDER BY rank
LIMIT 50;
Cumulative XP - rewards repeated engagement. Each quiz completion earns XP based on score and difficulty:
function calculateXP(
score: number,
maxScore: number,
difficulty: "easy" | "medium" | "hard",
timeSeconds: number
): number {
const accuracy = score / maxScore;
const difficultyMultiplier = { easy: 1, medium: 1.5, hard: 2.5 };
// Base XP from accuracy
let xp = Math.round(accuracy * 100 * difficultyMultiplier[difficulty]);
// Speed bonus: up to 20% extra for fast completions
const expectedTime = maxScore * 30; // 30 seconds per question baseline
if (timeSeconds < expectedTime) {
const speedRatio = timeSeconds / expectedTime;
xp = Math.round(xp * (1 + 0.2 * (1 - speedRatio)));
}
return xp;
}
ELO-style rating - works well for competitive quiz formats where players face questions of varying difficulty:
function updateRating(
currentRating: number,
questionDifficulty: number,
correct: boolean
): number {
const K = 32; // sensitivity factor
const expected = 1 / (1 + Math.pow(10, (questionDifficulty - currentRating) / 400));
const actual = correct ? 1 : 0;
return Math.round(currentRating + K * (actual - expected));
}
Redis Sorted Sets for Live Rankings
PostgreSQL handles historical queries well, but for a live leaderboard during an active quiz session, Redis sorted sets are faster:
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function updateLeaderboard(
quizId: string,
userId: string,
score: number
): Promise<void> {
const key = `leaderboard:${quizId}`;
// ZADD with GT flag - only update if new score is greater
await redis.zadd(key, "GT", score, userId);
// Set TTL to auto-cleanup old leaderboards
await redis.expire(key, 86400); // 24 hours
}
async function getLeaderboard(
quizId: string,
offset = 0,
limit = 50
): Promise<Array<{ userId: string; score: number; rank: number }>> {
const key = `leaderboard:${quizId}`;
// ZREVRANGE returns highest scores first
const results = await redis.zrevrange(
key,
offset,
offset + limit - 1,
"WITHSCORES"
);
const entries: Array<{ userId: string; score: number; rank: number }> = [];
for (let i = 0; i < results.length; i += 2) {
entries.push({
userId: results[i],
score: parseFloat(results[i + 1]),
rank: offset + i / 2 + 1,
});
}
return entries;
}
async function getUserRank(
quizId: string,
userId: string
): Promise<{ rank: number; score: number } | null> {
const key = `leaderboard:${quizId}`;
const rank = await redis.zrevrank(key, userId);
if (rank === null) return null;
const score = await redis.zscore(key, userId);
return {
rank: rank + 1,
score: parseFloat(score!),
};
}
Real-Time Delivery: Three Approaches
Option 1: Polling
The simplest approach. The client fetches the leaderboard every few seconds:
// Client-side
function useLeaderboardPolling(quizId: string, intervalMs = 3000) {
const [entries, setEntries] = useState([]);
useEffect(() => {
const poll = async () => {
const res = await fetch(`/api/leaderboard/${quizId}`);
const data = await res.json();
setEntries(data.entries);
};
poll();
const timer = setInterval(poll, intervalMs);
return () => clearInterval(timer);
}, [quizId, intervalMs]);
return entries;
}
Polling works fine for leaderboards with fewer than 100 concurrent viewers. Beyond that, you are making thousands of identical requests per second.
Option 2: Server-Sent Events (SSE)
SSE is a good middle ground. The server pushes updates when the leaderboard changes:
import express from "express";
const app = express();
// Store active SSE connections per quiz
const sseClients = new Map<string, Set<express.Response>>();
app.get("/api/leaderboard/:quizId/stream", async (req, res) => {
const { quizId } = req.params;
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Send initial leaderboard
const entries = await getLeaderboard(quizId);
res.write(`data: ${JSON.stringify({ type: "full", entries })}\n\n`);
// Register client
if (!sseClients.has(quizId)) {
sseClients.set(quizId, new Set());
}
sseClients.get(quizId)!.add(res);
req.on("close", () => {
sseClients.get(quizId)?.delete(res);
});
});
// Call this after a score update
async function broadcastLeaderboardUpdate(quizId: string) {
const clients = sseClients.get(quizId);
if (!clients || clients.size === 0) return;
const entries = await getLeaderboard(quizId);
const payload = `data: ${JSON.stringify({ type: "full", entries })}\n\n`;
for (const client of clients) {
client.write(payload);
}
}
Option 3: WebSockets
WebSockets give you bidirectional communication. Use them when you need the client to send data too, like live answer submissions:
import { WebSocketServer, WebSocket } from "ws";
import http from "http";
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: "/ws/leaderboard" });
const quizRooms = new Map<string, Set<WebSocket>>();
wss.on("connection", (ws, req) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
const quizId = url.searchParams.get("quizId");
if (!quizId) {
ws.close(1008, "Missing quizId parameter");
return;
}
// Join quiz room
if (!quizRooms.has(quizId)) {
quizRooms.set(quizId, new Set());
}
quizRooms.get(quizId)!.add(ws);
// Send current leaderboard
getLeaderboard(quizId).then((entries) => {
ws.send(JSON.stringify({ type: "leaderboard", entries }));
});
ws.on("message", async (raw) => {
const message = JSON.parse(raw.toString());
if (message.type === "score_update") {
await updateLeaderboard(quizId, message.userId, message.score);
await broadcastToRoom(quizId);
}
});
ws.on("close", () => {
quizRooms.get(quizId)?.delete(ws);
});
});
async function broadcastToRoom(quizId: string) {
const room = quizRooms.get(quizId);
if (!room) return;
const entries = await getLeaderboard(quizId);
const payload = JSON.stringify({ type: "leaderboard", entries });
for (const ws of room) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
}
}
Choosing the Right Approach
| Approach | Best For | Drawback |
|---|---|---|
| Polling | Small audiences (< 100) | Wastes bandwidth, delayed updates |
| SSE | Medium audiences, read-heavy | Unidirectional only |
| WebSockets | Large audiences, bidirectional | More complex to scale |
For most quiz leaderboards, SSE hits the sweet spot. It is simpler than WebSockets and more efficient than polling.
Summary
A good leaderboard needs three things: a ranking algorithm that rewards the behavior you want, a fast data store for live lookups, and a real-time delivery mechanism that scales with your audience.
Start with PostgreSQL for historical rankings and Redis sorted sets for live sessions. Use SSE for real-time delivery unless you need bidirectional communication. And pick a ranking algorithm that matches your product goals - highest score for competition, cumulative XP for engagement, or ELO for skill-based matching.
Stay Updated
Get the latest tutorials and API tips delivered to your inbox.
No spam, unsubscribe anytime.
Related Articles
How to Build a Quiz App with Django and QuizAPI
Step-by-step guide to building a quiz application with Django using the QuizAPI REST API. Fetch questions, render a quiz UI, and submit scores.
Building a Quiz Component in React with QuizAPI
Build a reusable React quiz component that fetches questions from QuizAPI, manages quiz state, and displays scores. Full TypeScript implementation included.
Add Quizzes to Your Laravel App with QuizAPI
Integrate QuizAPI into a Laravel application with the HTTP client, Blade templates, and proper form handling for a complete quiz experience.
Enjoyed this article?
Share it with your team or try our quiz platform.