Skip to main content

What is Lazy Aggregation?

The Aggregate component internally denormalizes counts in a B-tree data structure so they can be calculated efficiently by reading only a few documents instead of every document in your table. However, this eager aggregation isn’t always required or optimal. Lazy aggregation allows you to trade off query speed and database bandwidth for reduced impact of writes on each other.

Default Behavior: Lazy Root

By default, the root aggregation node is lazy - it doesn’t store a precomputed count.

Benefits of a Lazy Root

A lazy root node reduces write contention by ensuring that inserts at very small keys don’t interfere with writes or reads on very large keys.
How it works:
  • When aggregate.count(ctx) is called, it must read several documents instead of just one
  • However, writes to different parts of the tree don’t all contend on a single root node
  • This reduces OCC conflicts for concurrent writes
Trade-offs:
  • Slightly slower queries (must read multiple nodes)
  • Lower bandwidth usage for queries
  • Much better write throughput and reduced conflicts

When to Use Eager Root Aggregation

If your workload is read-heavy with infrequent updates, you can optimize for query speed by disabling the lazy root:
// Clear and reinitialize with eager root aggregation
await aggregate.clear(ctx, { rootLazy: false });
With rootLazy: false, all writes update a single root node, causing every write operation to contend with every other write. Only use this for read-heavy workloads.
Use cases for eager root:
  • Data that changes infrequently (e.g., daily or weekly updates)
  • Workloads with many more reads than writes
  • Scenarios where maximum query speed is critical

Converting to Lazy Root

If you have an existing aggregate with frequent writes causing contention, you can convert it to use a lazy root:
await aggregate.makeRootLazy(ctx, namespace);
This removes the frequent writes to the root node. After conversion:
  • Updates will only contend with other updates to the same shard of the tree
  • Write throughput improves significantly
  • Query latency increases slightly

Converting All Namespaces

If you’re using namespaced aggregates:
await aggregate.makeAllRootsLazy(ctx);
This iterates through all namespaces and makes each root lazy.
makeRootLazy is a one-way operation. To go back to eager aggregation, you must use clear() with rootLazy: false, which removes all data.

Tuning maxNodeSize

The maxNodeSize parameter controls the width and depth of the aggregate B-tree structure. This is another key tuning parameter for lazy aggregation.

How maxNodeSize Affects Performance

With a lazy root and default maxNodeSize of 16:
  • Each write updates a document that accumulates 1/16th of the entire data structure
  • Each write intersects with approximately 1/16th of all other writes
  • Reads may spuriously rerun about 1/16th of the time

Increasing maxNodeSize

To reduce write contention further, increase maxNodeSize:
// Clear and reinitialize with larger node size
await aggregate.clear(ctx, { maxNodeSize: 64 });
Effects of larger maxNodeSize:
  • Fewer internal nodes in the tree (wider, shallower tree)
  • Less write contention (each write affects a smaller fraction of other writes)
  • Slightly slower queries (may need to scan more items per node)
  • Less spurious query reruns
If you’re experiencing high OCC conflict rates, try increasing maxNodeSize to 32, 64, or even 128.

Choosing the Right maxNodeSize

maxNodeSizeTree DepthWrite ContentionQuery SpeedBest For
16 (default)DeeperModerateFasterBalanced workloads
32-64MediumLowerMediumWrite-heavy workloads
128+ShallowerLowestSlowerVery high write throughput
Changing maxNodeSize requires calling clear(), which removes all existing data. You’ll need to backfill the aggregate after clearing.

Combining Lazy Root and maxNodeSize

For maximum write throughput with minimal contention:
// Optimize for high write throughput
await aggregate.clear(ctx, { 
  maxNodeSize: 64,
  rootLazy: true  // This is the default
});
This configuration:
  • Uses a lazy root to avoid all writes contending on a single node
  • Increases node size to reduce the number of internal nodes
  • Minimizes write contention across the tree

Example: Converting an Existing Aggregate

If you have an aggregate that’s experiencing OCC conflicts:
import { internalMutation } from "./_generated/server";
import { components } from "./_generated/api";
import { TableAggregate } from "@convex-dev/aggregate";

const aggregate = new TableAggregate<{
  Key: number;
  DataModel: DataModel;
  TableName: "events";
}>(components.aggregate, {
  sortKey: (doc) => doc.timestamp,
});

// Step 1: Make the root lazy without clearing data
export const optimizeAggregate = internalMutation({
  handler: async (ctx) => {
    await aggregate.makeRootLazy(ctx, undefined);
  },
});

// Step 2: If you need more optimization, clear and increase maxNodeSize
export const deepOptimizeAggregate = internalMutation({
  handler: async (ctx) => {
    // This will remove all data - you'll need to backfill
    await aggregate.clear(ctx, { 
      maxNodeSize: 64,
      rootLazy: true 
    });
    // Now run your backfill process
  },
});

Performance Monitoring

Watch for these signs that you need lazy aggregation:
  1. High OCC conflict rate: Mutations frequently failing with OCC errors
  2. Slow mutation throughput: Writes taking longer than expected
  3. Query reruns: Queries rerunning frequently without result changes
Use the Convex dashboard to monitor function call rates and error rates. A spike in OCC errors indicates write contention issues.

Summary

SettingWrite ThroughputQuery SpeedUse When
rootLazy: true (default)HighMediumMost workloads
rootLazy: falseLowFastRead-heavy, infrequent writes
rootLazy: true + large maxNodeSizeHighestSlowerVery high write throughput needed

See Also

Build docs developers (and LLMs) love