Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Audit and fix Convex performance issues: hot reads, OCC conflicts, subscription cost, and function limits.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/hot-path-rules.md
1# Hot Path Rules23Use these rules when the top-level workflow points to read amplification,4denormalization, index rollout, reactive query cost, or invalidation-heavy5writes.67## Contents89- Core Principle10- Consistency Rule11- 1. Push Filters To Storage (indexes, migration rule, redundant indexes)12- 2. Minimize Data Sources (denormalization, fallback rule)13- 3. Minimize Row Size (digest tables)14- 4. Skip No-Op Writes15- 5. Match Consistency To Read Patterns (high-read/low-write,16high-read/high-write)17- Convex-Specific Notes (reactive queries, point-in-time reads, triggers,18aggregates, backfills)19- Verification2021## Core Principle2223Every byte read or written multiplies with concurrency.2425Think:2627`cost x calls_per_second x 86400`2829In Convex, every write can also fan out into reactive invalidation, replication30work, and downstream sync.3132## Consistency Rule3334If you fix a hot-path pattern for one function, audit sibling functions touching35the same tables for the same pattern.3637Do this especially for:3839- multiple list queries over the same table40- multiple writers to the same table41- public browse and search queries over the same records42- helper functions reused by more than one endpoint4344## 1. Push Filters To Storage4546Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB47scan mean you already paid for the read. The Convex `.filter()` method has the48same performance as filtering in JS, it does not push the predicate to the49storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the50documents scanned.5152Prefer:5354- `withIndex(...)`55- `.withSearchIndex(...)` for text search56- narrower tables57- summary tables5859before accepting a scan-plus-filter pattern.6061```ts62// Bad: scans then filters in JavaScript63export const listOpen = query({64args: {},65handler: async (ctx) => {66const tasks = await ctx.db.query("tasks").collect();67return tasks.filter((task) => task.status === "open");68},69});70```7172```ts73// Also bad: Convex .filter() does not push to storage either74export const listOpen = query({75args: {},76handler: async (ctx) => {77return await ctx.db78.query("tasks")79.filter((q) => q.eq(q.field("status"), "open"))80.collect();81},82});83```8485```ts86// Good: use an index so storage does the filtering87export const listOpen = query({88args: {},89handler: async (ctx) => {90return await ctx.db91.query("tasks")92.withIndex("by_status", (q) => q.eq("status", "open"))93.collect();94},95});96```9798### Migration rule for indexes99100New indexes on partially backfilled fields can create correctness bugs during101rollout.102103Important Convex detail:104105`undefined !== false`106107If an older document is missing a field entirely, it will not match a compound108index entry that expects `false`.109110Do not trust old comments saying a field is "not backfilled" or "already111backfilled". Verify.112113If correctness depends on handling old and new states during rollout, do not114improvise a partial-backfill workaround in the hot path. Use a migration-safe115rollout and consult `skills/convex-migration-helper/SKILL.md`.116117```ts118// Bad: optional booleans can miss older rows where the field is undefined119const projects = await ctx.db120.query("projects")121.withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false))122.order("desc")123.take(20);124```125126```ts127// Good: switch hot-path reads only after the rollout is migration-safe128// See the migration helper skill for dual-read / backfill / cutover patterns.129```130131### Check for redundant indexes132133Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need134`by_foo_and_bar`, since you can query it with just the `foo` condition and omit135`bar`. Extra indexes add storage cost and write overhead on every insert, patch,136and delete.137138```ts139// Bad: two indexes where one would do140defineTable({ team: v.id("teams"), user: v.id("users") })141.index("by_team", ["team"])142.index("by_team_and_user", ["team", "user"]);143```144145```ts146// Good: single compound index serves both query patterns147defineTable({ team: v.id("teams"), user: v.id("users") }).index(148"by_team_and_user",149["team", "user"],150);151```152153Exception: `.index("by_foo", ["foo"])` is really an index on `foo` +154`_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` +155`bar` + `_creationTime`. If you need results sorted by `foo` then156`_creationTime`, you need the single-field index because the compound one would157sort by `bar` first.158159## 2. Minimize Data Sources160161Trace every read.162163If a function resolves a foreign key for a tiny display field and a denormalized164copy already exists, prefer the denormalized field on the hot path.165166### When to denormalize167168Denormalize when all of these are true:169170- the path is hot171- the joined document is much larger than the field you need172- many readers are paying that join cost repeatedly173174Useful mental model:175176`join_cost = rows_per_page x foreign_doc_size x pages_per_second`177178Small-table joins are often fine. Large-document joins for tiny fields on hot179list pages are usually not.180181### Fallback rule182183Denormalized data is an optimization. Live data is the correctness path.184185Rules:186187- If the denormalized field is missing or null, fall back to the live read188- Do not show placeholders instead of falling back189- In lookup maps, only include fully populated entries190191```ts192// Bad: missing denormalized data becomes a placeholder and blocks correctness193const ownerName = project.ownerName ?? "Unknown owner";194```195196```ts197// Good: denormalized data is an optimization, not the only source of truth198const ownerName =199project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;200```201202Bad lookup map pattern:203204```ts205const ownersById = {206[project.ownerId]: { ownerName: null },207};208```209210That blocks fallback because the map says "I have data" when it does not.211212Good lookup map pattern:213214```ts215const ownersById =216project.ownerName !== undefined && project.ownerName !== null217? { [project.ownerId]: { ownerName: project.ownerName } }218: {};219```220221### No denormalized copy yet222223Prefer adding fields to an existing summary, companion, or digest table instead224of bloating the primary hot-path table.225226If introducing the new field or table requires a staged rollout, backfill, or227old/new-shape handling, use the migration helper skill for the rollout plan.228229Rollout order:2302311. Update schema2322. Update write path2333. Backfill2344. Switch read path235236## 3. Minimize Row Size237238Hot list pages should read the smallest document shape that still answers the239UI.240241Prefer summary or digest tables over full source tables when:242243- the list page only needs a subset of fields244- source documents are large245- the query is high volume246247An 800 byte summary row is materially cheaper than a 3 KB full document on a hot248page.249250Digest tables are a tradeoff, not a default:251252- Worth it when the path is clearly hot, the source rows are much larger than253the UI needs, or many readers are repeatedly paying the same join and payload254cost255- Probably not worth it when an indexed read on the source table is already256cheap enough, the table is still small, or the extra write and migration257complexity would dominate the benefit258259```ts260// Bad: list page reads source docs, then joins owner data per row261const projects = await ctx.db262.query("projects")263.withIndex("by_public", (q) => q.eq("isPublic", true))264.collect();265```266267```ts268// Good: list page reads the smaller digest shape first269const projects = await ctx.db270.query("projectDigests")271.withIndex("by_public_and_updated", (q) => q.eq("isPublic", true))272.order("desc")273.take(20);274```275276## 4. Isolate Frequently-Updated Fields277278Convex already no-ops unchanged writes. The invalidation problem here is real279writes hitting documents that many queries subscribe to.280281Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status282off widely-read documents when most readers do not need them.283284Apply this across sibling writers too. Splitting one write path does not help285much if three other mutations still update the same widely-read document.286287```ts288// Bad: every presence heartbeat invalidates subscribers to the whole profile289await ctx.db.patch(user._id, {290name: args.name,291avatarUrl: args.avatarUrl,292lastSeen: Date.now(),293});294```295296```ts297// Good: keep profile reads stable, move heartbeat updates to a separate document298await ctx.db.patch(user._id, {299name: args.name,300avatarUrl: args.avatarUrl,301});302303await ctx.db.patch(presence._id, {304lastSeen: Date.now(),305});306```307308## 5. Match Consistency To Read Patterns309310Choose read strategy based on traffic shape.311312### High-read, low-write313314Examples:315316- public browse pages317- search results318- landing pages319- directory listings320321Prefer:322323- point-in-time reads where appropriate324- explicit refresh325- local state for pagination326- caching where appropriate327328Do not treat subscriptions as automatically wrong here. Prefer point-in-time329reads only when the product does not need live freshness and the reactive cost330is material. See `subscription-cost.md` for detailed patterns.331332### High-read, high-write333334Examples:335336- collaborative editors337- live dashboards338- presence-heavy views339340Reactive queries may be worth the ongoing cost.341342## Convex-Specific Notes343344### Reactive queries345346Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set347for the query.348349On the client:350351- `useQuery` creates a live subscription352- `usePaginatedQuery` creates a live subscription per page353354For low-freshness flows, consider a point-in-time read instead of a live355subscription only when the product does not need updates pushed automatically.356357### Point-in-time reads358359Framework helpers, server-rendered fetches, or one-shot client reads can avoid360ongoing subscription cost when live updates are not useful.361362Use them for:363364- aggregate snapshots365- reports366- low-churn listings367- pages where explicit refresh is fine368369### Triggers and fan-out370371Triggers fire on every write, including writes that did not materially change372the document.373374When a write exists only to keep derived state in sync:375376- diff before patching377- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate378379### Aggregates380381Reactive global counts invalidate frequently on busy tables.382383Prefer:384385- one-shot aggregate fetches386- periodic recomputation387- precomputed summary rows388389for global stats that do not need live updates every second.390391### Backfills392393For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs394or the migrations component.395396Deploy code that can handle both states before running the backfill.397398During the gap:399400- writes should populate the new shape401- reads should fall back safely402403## Verification404405Before closing the audit, confirm:4064071. Same results as before, no dropped records4082. The removed table or lookup is no longer in the hot-path read set4093. Tests or validation cover fallback behavior4104. Migration safety is preserved while fields or indexes are unbackfilled4115. Sibling functions were fixed consistently412