Just-Use-Convex Docs
Advanced Patterns

Aggregates

Real-time statistics with @convex-dev/aggregate

Aggregates

The @convex-dev/aggregate library provides efficient real-time statistics without expensive queries. Instead of counting records on every request, aggregates maintain running totals that update automatically.

Why Aggregates?

Without Aggregates (Expensive)

// This scans potentially thousands of records
export const getStats = query({
  handler: async (ctx) => {
    const todos = await ctx.db.query("todos").collect();
    return {
      total: todos.length,
      done: todos.filter((t) => t.status === "done").length,
      highPriority: todos.filter((t) => t.priority === "high").length,
    };
  },
});

With Aggregates (Efficient)

// Returns instantly regardless of data size
export const getStats = query({
  handler: async (ctx) => {
    return {
      total: await todosByOrg.count(ctx, { namespace: orgId }),
      done: await todosByOrgStatus.count(ctx, {
        namespace: orgId,
        bounds: { key: "done" }
      }),
      highPriority: await todosByOrgPriority.count(ctx, {
        namespace: orgId,
        bounds: { key: "high" }
      }),
    };
  },
});

Setting Up Aggregates

1. Configure the Plugin

// convex.config.ts
import { defineApp } from "convex/server";
import aggregate from "@convex-dev/aggregate/convex.config";

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

export default app;

2. Define Aggregates

// todos/aggregates.ts
import { TableAggregate } from "@convex-dev/aggregate";
import { components } from "../_generated/api";
import { DataModel } from "../_generated/dataModel";

// Count todos by organization
export const todosByOrg = new TableAggregate<DataModel, "todos">(
  components.aggregate,
  {
    namespace: (doc) => doc.organizationId,
    sortKey: (doc) => doc._creationTime,
  }
);

// Count by organization + status
export const todosByOrgStatus = new TableAggregate<DataModel, "todos">(
  components.aggregate,
  {
    namespace: (doc) => doc.organizationId,
    sortKey: (doc) => doc.status,
  }
);

// Count by organization + priority
export const todosByOrgPriority = new TableAggregate<DataModel, "todos">(
  components.aggregate,
  {
    namespace: (doc) => doc.organizationId,
    sortKey: (doc) => doc.priority,
  }
);

// Count by team
export const todosByTeam = new TableAggregate<DataModel, "todos">(
  components.aggregate,
  {
    namespace: (doc) => `${doc.organizationId}:${doc.teamId ?? "none"}`,
    sortKey: (doc) => doc._creationTime,
  }
);

// Count by creator
export const todosByCreator = new TableAggregate<DataModel, "todos">(
  components.aggregate,
  {
    namespace: (doc) => doc.userId,
    sortKey: (doc) => doc._creationTime,
  }
);

3. Register Triggers

Triggers automatically update aggregates when data changes:

// todos/aggregates.ts
import { Triggers } from "convex-helpers/server/triggers";
import { internal } from "../_generated/api";
import { DataModel } from "../_generated/dataModel";

export const triggers = new Triggers<DataModel>();

// Register all aggregates as triggers on the todos table
triggers.register("todos", todosByOrg.trigger());
triggers.register("todos", todosByOrgStatus.trigger());
triggers.register("todos", todosByOrgPriority.trigger());
triggers.register("todos", todosByTeam.trigger());
triggers.register("todos", todosByCreator.trigger());

4. Wrap Mutations with Triggers

// functions.ts
import { triggers } from "./todos/aggregates";
import { wrapDatabaseWriter } from "convex-helpers/server/triggers";

export const zMutation = zCustomMutation(baseMutation, {
  input: async (ctx) => {
    // Wrap db to fire triggers
    const wrappedDb = wrapDatabaseWriter(ctx, ctx.db, triggers);

    return {
      ctx: {
        ...ctx,
        db: wrappedDb,
        table: entsTableFactory({ ...ctx, db: wrappedDb }, entDefinitions),
        identity: parsed,
      },
    };
  },
});

Querying Aggregates

Basic Count

const totalTodos = await todosByOrg.count(ctx, {
  namespace: ctx.identity.activeOrganizationId,
});

Count with Key Filter

// Count todos with status "done"
const doneTodos = await todosByOrgStatus.count(ctx, {
  namespace: ctx.identity.activeOrganizationId,
  bounds: { key: "done" },
});

Multiple Stats at Once

export const getOrgStats = zQuery({
  args: z.object({}),
  handler: async (ctx) => {
    const orgId = ctx.identity.activeOrganizationId;

    const [total, todo, inProgress, done, low, medium, high] =
      await Promise.all([
        todosByOrg.count(ctx, { namespace: orgId }),
        todosByOrgStatus.count(ctx, { namespace: orgId, bounds: { key: "todo" } }),
        todosByOrgStatus.count(ctx, { namespace: orgId, bounds: { key: "in_progress" } }),
        todosByOrgStatus.count(ctx, { namespace: orgId, bounds: { key: "done" } }),
        todosByOrgPriority.count(ctx, { namespace: orgId, bounds: { key: "low" } }),
        todosByOrgPriority.count(ctx, { namespace: orgId, bounds: { key: "medium" } }),
        todosByOrgPriority.count(ctx, { namespace: orgId, bounds: { key: "high" } }),
      ]);

    return {
      total,
      byStatus: { todo, inProgress, done },
      byPriority: { low, medium, high },
    };
  },
});

Team Stats

export const getTeamStats = zQuery({
  args: z.object({ teamId: z.string() }),
  handler: async (ctx, args) => {
    const namespace = `${ctx.identity.activeOrganizationId}:${args.teamId}`;

    return {
      total: await todosByTeam.count(ctx, { namespace }),
    };
  },
});

Composite Namespaces

For multi-level aggregation, combine keys:

// Organization + Team + Status
export const todosByOrgTeamStatus = new TableAggregate<DataModel, "todos">(
  components.aggregate,
  {
    namespace: (doc) =>
      `${doc.organizationId}:${doc.teamId ?? "none"}`,
    sortKey: (doc) => doc.status,
  }
);

// Query: Done todos in a specific team
const doneInTeam = await todosByOrgTeamStatus.count(ctx, {
  namespace: `${orgId}:${teamId}`,
  bounds: { key: "done" },
});

Real-Time Updates

Aggregates are reactive - when data changes, queries automatically update:

function Dashboard() {
  const stats = useQuery(api.todos.stats.getOrgStats);

  // Stats update in real-time as todos are created/updated/deleted
  return (
    <div>
      <p>Total: {stats?.total}</p>
      <p>Done: {stats?.byStatus.done}</p>
    </div>
  );
}

Best Practices

1. Choose Namespaces Carefully

// Good: Specific namespace for the query pattern
namespace: (doc) => doc.organizationId

// Bad: Too broad, can't filter by org
namespace: () => "global"

2. Register All Needed Aggregates

Create separate aggregates for each dimension you need to query.

3. Use Promise.all for Multiple Counts

// Good: Parallel queries
const [a, b, c] = await Promise.all([
  agg1.count(ctx, opts),
  agg2.count(ctx, opts),
  agg3.count(ctx, opts),
]);

Next, learn about Pagination.