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

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

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.

Stay Updated

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

No spam, unsubscribe anytime.

Enjoyed this article?

Share it with your team or try our quiz platform.