Skip to main content
Leaderboards are a perfect use case for the Aggregate component. Track player scores, calculate rankings, and compute user statistics—all in O(log(n)) time.

Overview

This example demonstrates a game leaderboard that supports:
  • Counting total scores
  • Finding scores at specific ranks
  • Calculating user average and high scores
  • Determining rankings for specific scores
  • Efficient pagination through the leaderboard

Schema Definition

First, define your leaderboard table:
convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  leaderboard: defineTable({
    name: v.string(),
    score: v.number(),
  }),
});

Setting Up Aggregates

Create two aggregates to support different query patterns:
convex/leaderboard.ts
import { TableAggregate } from "@convex-dev/aggregate";
import { components } from "./_generated/api";
import { DataModel } from "./_generated/dataModel";

// Aggregate sorted by score (for global rankings)
const aggregateByScore = new TableAggregate<{
  Key: number;
  DataModel: DataModel;
  TableName: "leaderboard";
}>(components.aggregateByScore, {
  sortKey: (doc) => -doc.score, // Negative for descending order
});

// Aggregate grouped by user (for per-user statistics)
const aggregateScoreByUser = new TableAggregate<{
  Key: [string, number];
  DataModel: DataModel;
  TableName: "leaderboard";
}>(components.aggregateScoreByUser, {
  sortKey: (doc) => [doc.name, doc.score],
  sumValue: (doc) => doc.score,
});
The aggregateByScore uses a negative score (-doc.score) to sort in descending order, placing the highest scores first.

Configure the Component

In convex.config.ts, install both aggregate instances:
convex/convex.config.ts
import { defineApp } from "convex/server";
import aggregate from "@convex-dev/aggregate/convex.config.js";

const app = defineApp();
app.use(aggregate, { name: "aggregateByScore" });
app.use(aggregate, { name: "aggregateScoreByUser" });
export default app;

Automatic Updates with Triggers

Use triggers to automatically keep aggregates in sync:
convex/leaderboard.ts
import { Triggers } from "convex-helpers/server/triggers";
import { customCtx, customMutation } from "convex-helpers/server/customFunctions";
import { mutation } from "./_generated/server";

const triggers = new Triggers<DataModel>();
triggers.register("leaderboard", aggregateByScore.trigger());
triggers.register("leaderboard", aggregateScoreByUser.trigger());

const mutationWithTriggers = customMutation(
  mutation,
  customCtx(triggers.wrapDB),
);

Core Operations

1

Add Scores

Insert scores into the leaderboard. The triggers automatically update both aggregates:
export const addScore = mutationWithTriggers({
  args: {
    name: v.string(),
    score: v.number(),
  },
  handler: async (ctx, args) => {
    const id = await ctx.db.insert("leaderboard", {
      name: args.name,
      score: args.score,
    });
    return id;
  },
});
2

Count Total Scores

Get the total number of scores in O(log(n)) time:
export const countScores = query({
  handler: async (ctx) => {
    return await aggregateByScore.count(ctx);
  },
});
3

Find Score at Rank

Look up the score at any position in the leaderboard:
export const scoreAtRank = query({
  args: { rank: v.number() },
  handler: async (ctx, { rank }) => {
    const score = await aggregateByScore.at(ctx, rank);
    return await ctx.db.get(score.id);
  },
});
4

Get Rank of Score

Find where a specific score ranks:
export const rankOfScore = query({
  args: { score: v.number() },
  handler: async (ctx, { score }) => {
    // Use negative score to match our sorting
    return await aggregateByScore.indexOf(ctx, -score);
  },
});

User Statistics

Calculate per-user statistics using the aggregateScoreByUser aggregate:
export const userAverageScore = query({
  args: { name: v.string() },
  handler: async (ctx, { name }) => {
    const count = await aggregateScoreByUser.count(ctx, {
      bounds: { prefix: [name] },
    });
    if (!count) return null;
    
    const sum = await aggregateScoreByUser.sum(ctx, {
      bounds: { prefix: [name] },
    });
    return sum / count;
  },
});

Paginated Leaderboard

Display the leaderboard in pages:
export const pageOfScores = query({
  args: {
    offset: v.number(),
    numItems: v.number(),
  },
  handler: async (ctx, { offset, numItems }) => {
    // Jump to the offset position in O(log(n)) time
    const firstInPage = await aggregateByScore.at(ctx, offset);

    // Get the page of results
    const page = await aggregateByScore.paginate(ctx, {
      bounds: {
        lower: {
          key: firstInPage.key,
          id: firstInPage.id,
          inclusive: true,
        },
      },
      pageSize: numItems,
    });

    // Fetch the actual documents
    const scores = await Promise.all(
      page.page.map((doc) => ctx.db.get(doc.id)),
    );

    return scores.filter((d) => d != null);
  },
});

Backfilling Existing Data

If you’re adding aggregates to an existing leaderboard:
import { Migrations } from "@convex-dev/migrations";
import { components, internal } from "./_generated/api";

export const migrations = new Migrations<DataModel>(components.migrations);
export const run = migrations.runner();

export const backfillAggregatesMigration = migrations.define({
  table: "leaderboard",
  migrateOne: async (ctx, doc) => {
    await aggregateByScore.insertIfDoesNotExist(ctx, doc);
    await aggregateScoreByUser.insertIfDoesNotExist(ctx, doc);
  },
});

export const runAggregateBackfill = migrations.runner(
  internal.leaderboard.backfillAggregatesMigration,
);
Run the backfill from the Convex dashboard or CLI:
npx convex run leaderboard:runAggregateBackfill

Example Queries

// Get the top 10 scores
await pageOfScores(ctx, { offset: 0, numItems: 10 });

Performance Characteristics

OperationComplexityDescription
Count total scoresO(log(n))Fast count across all scores
Find score at rankO(log(n))Jump to any position instantly
Get user averageO(log(n))Sum and count for a user
Find rank of scoreO(log(n))Determine position in leaderboard
Paginate resultsO(log(n) + page size)Efficient pagination
All operations are O(log(n)) instead of the O(n) that would result from scanning the entire table.

Try It Out

See the full working example at: https://aggregate-component-example.netlify.app/

Build docs developers (and LLMs) love