Back to Blog
Tutorial

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.

Bobby Iliev2026-04-088 min read
Share:

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:

1CREATE TABLE quiz_attempts ( 2 id SERIAL PRIMARY KEY, 3 user_id VARCHAR(255) NOT NULL, 4 quiz_id VARCHAR(255) NOT NULL, 5 score INTEGER NOT NULL, 6 max_score INTEGER NOT NULL, 7 time_spent_seconds INTEGER NOT NULL, 8 completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 9); 10 11CREATE INDEX idx_attempts_quiz_completed 12 ON quiz_attempts(quiz_id, completed_at); 13CREATE INDEX idx_attempts_user 14 ON quiz_attempts(user_id); 15 16CREATE TABLE user_profiles ( 17 user_id VARCHAR(255) PRIMARY KEY, 18 display_name VARCHAR(100) NOT NULL, 19 avatar_url TEXT, 20 total_xp INTEGER DEFAULT 0 21);

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:

1SELECT 2 u.display_name, 3 u.avatar_url, 4 a.score, 5 a.max_score, 6 a.time_spent_seconds, 7 RANK() OVER (ORDER BY a.score DESC, a.time_spent_seconds ASC) as rank 8FROM quiz_attempts a 9JOIN user_profiles u ON u.user_id = a.user_id 10WHERE a.quiz_id = $1 11 AND a.completed_at > NOW() - INTERVAL '24 hours' 12ORDER BY rank 13LIMIT 50;

Cumulative XP - rewards repeated engagement. Each quiz completion earns XP based on score and difficulty:

1function calculateXP( 2 score: number, 3 maxScore: number, 4 difficulty: "easy" | "medium" | "hard", 5 timeSeconds: number 6): number { 7 const accuracy = score / maxScore; 8 const difficultyMultiplier = { easy: 1, medium: 1.5, hard: 2.5 }; 9 10 // Base XP from accuracy 11 let xp = Math.round(accuracy * 100 * difficultyMultiplier[difficulty]); 12 13 // Speed bonus: up to 20% extra for fast completions 14 const expectedTime = maxScore * 30; // 30 seconds per question baseline 15 if (timeSeconds < expectedTime) { 16 const speedRatio = timeSeconds / expectedTime; 17 xp = Math.round(xp * (1 + 0.2 * (1 - speedRatio))); 18 } 19 20 return xp; 21}

ELO-style rating - works well for competitive quiz formats where players face questions of varying difficulty:

1function updateRating( 2 currentRating: number, 3 questionDifficulty: number, 4 correct: boolean 5): number { 6 const K = 32; // sensitivity factor 7 const expected = 1 / (1 + Math.pow(10, (questionDifficulty - currentRating) / 400)); 8 const actual = correct ? 1 : 0; 9 10 return Math.round(currentRating + K * (actual - expected)); 11}

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:

1import Redis from "ioredis"; 2 3const redis = new Redis(process.env.REDIS_URL); 4 5async function updateLeaderboard( 6 quizId: string, 7 userId: string, 8 score: number 9): Promise<void> { 10 const key = `leaderboard:${quizId}`; 11 12 // ZADD with GT flag - only update if new score is greater 13 await redis.zadd(key, "GT", score, userId); 14 15 // Set TTL to auto-cleanup old leaderboards 16 await redis.expire(key, 86400); // 24 hours 17} 18 19async function getLeaderboard( 20 quizId: string, 21 offset = 0, 22 limit = 50 23): Promise<Array<{ userId: string; score: number; rank: number }>> { 24 const key = `leaderboard:${quizId}`; 25 26 // ZREVRANGE returns highest scores first 27 const results = await redis.zrevrange( 28 key, 29 offset, 30 offset + limit - 1, 31 "WITHSCORES" 32 ); 33 34 const entries: Array<{ userId: string; score: number; rank: number }> = []; 35 for (let i = 0; i < results.length; i += 2) { 36 entries.push({ 37 userId: results[i], 38 score: parseFloat(results[i + 1]), 39 rank: offset + i / 2 + 1, 40 }); 41 } 42 43 return entries; 44} 45 46async function getUserRank( 47 quizId: string, 48 userId: string 49): Promise<{ rank: number; score: number } | null> { 50 const key = `leaderboard:${quizId}`; 51 52 const rank = await redis.zrevrank(key, userId); 53 if (rank === null) return null; 54 55 const score = await redis.zscore(key, userId); 56 57 return { 58 rank: rank + 1, 59 score: parseFloat(score!), 60 }; 61}

Real-Time Delivery: Three Approaches

Option 1: Polling

The simplest approach. The client fetches the leaderboard every few seconds:

1// Client-side 2function useLeaderboardPolling(quizId: string, intervalMs = 3000) { 3 const [entries, setEntries] = useState([]); 4 5 useEffect(() => { 6 const poll = async () => { 7 const res = await fetch(`/api/leaderboard/${quizId}`); 8 const data = await res.json(); 9 setEntries(data.entries); 10 }; 11 12 poll(); 13 const timer = setInterval(poll, intervalMs); 14 return () => clearInterval(timer); 15 }, [quizId, intervalMs]); 16 17 return entries; 18}

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:

1import express from "express"; 2 3const app = express(); 4 5// Store active SSE connections per quiz 6const sseClients = new Map<string, Set<express.Response>>(); 7 8app.get("/api/leaderboard/:quizId/stream", async (req, res) => { 9 const { quizId } = req.params; 10 11 res.setHeader("Content-Type", "text/event-stream"); 12 res.setHeader("Cache-Control", "no-cache"); 13 res.setHeader("Connection", "keep-alive"); 14 15 // Send initial leaderboard 16 const entries = await getLeaderboard(quizId); 17 res.write(`data: ${JSON.stringify({ type: "full", entries })}\n\n`); 18 19 // Register client 20 if (!sseClients.has(quizId)) { 21 sseClients.set(quizId, new Set()); 22 } 23 sseClients.get(quizId)!.add(res); 24 25 req.on("close", () => { 26 sseClients.get(quizId)?.delete(res); 27 }); 28}); 29 30// Call this after a score update 31async function broadcastLeaderboardUpdate(quizId: string) { 32 const clients = sseClients.get(quizId); 33 if (!clients || clients.size === 0) return; 34 35 const entries = await getLeaderboard(quizId); 36 const payload = `data: ${JSON.stringify({ type: "full", entries })}\n\n`; 37 38 for (const client of clients) { 39 client.write(payload); 40 } 41}

Option 3: WebSockets

WebSockets give you bidirectional communication. Use them when you need the client to send data too, like live answer submissions:

1import { WebSocketServer, WebSocket } from "ws"; 2import http from "http"; 3 4const server = http.createServer(app); 5const wss = new WebSocketServer({ server, path: "/ws/leaderboard" }); 6 7const quizRooms = new Map<string, Set<WebSocket>>(); 8 9wss.on("connection", (ws, req) => { 10 const url = new URL(req.url!, `http://${req.headers.host}`); 11 const quizId = url.searchParams.get("quizId"); 12 13 if (!quizId) { 14 ws.close(1008, "Missing quizId parameter"); 15 return; 16 } 17 18 // Join quiz room 19 if (!quizRooms.has(quizId)) { 20 quizRooms.set(quizId, new Set()); 21 } 22 quizRooms.get(quizId)!.add(ws); 23 24 // Send current leaderboard 25 getLeaderboard(quizId).then((entries) => { 26 ws.send(JSON.stringify({ type: "leaderboard", entries })); 27 }); 28 29 ws.on("message", async (raw) => { 30 const message = JSON.parse(raw.toString()); 31 32 if (message.type === "score_update") { 33 await updateLeaderboard(quizId, message.userId, message.score); 34 await broadcastToRoom(quizId); 35 } 36 }); 37 38 ws.on("close", () => { 39 quizRooms.get(quizId)?.delete(ws); 40 }); 41}); 42 43async function broadcastToRoom(quizId: string) { 44 const room = quizRooms.get(quizId); 45 if (!room) return; 46 47 const entries = await getLeaderboard(quizId); 48 const payload = JSON.stringify({ type: "leaderboard", entries }); 49 50 for (const ws of room) { 51 if (ws.readyState === WebSocket.OPEN) { 52 ws.send(payload); 53 } 54 } 55}

Choosing the Right Approach

ApproachBest ForDrawback
PollingSmall audiences (< 100)Wastes bandwidth, delayed updates
SSEMedium audiences, read-heavyUnidirectional only
WebSocketsLarge audiences, bidirectionalMore 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.

Test Your Knowledge

Think you understand Tutorial? Put your skills to the test with hands-on quiz questions.

Tutorial
Start Practicing

Enjoyed this article?

Share it with your team or try our quiz platform.

Stay Updated

Get the latest tutorials and API tips delivered to your inbox.

No spam, unsubscribe anytime.