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/subscription-cost.md
1# Subscription Cost23Use these rules when the problem is too many reactive subscriptions, queries4invalidating too frequently, or React components re-rendering excessively due to5Convex state changes.67## Core Principle89Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The10server tracks the query's read set and re-executes the query whenever any11document in that read set changes. Subscription cost scales with:1213`subscriptions x invalidation_frequency x query_cost`1415Subscriptions are not inherently bad. Convex reactivity is often the right16default. The goal is to reduce unnecessary invalidation work, not to eliminate17subscriptions on principle.1819## Symptoms2021- Dashboard shows high active subscription count22- UI feels sluggish or laggy despite fast individual queries23- React profiling shows frequent re-renders from Convex state24- Pages with many components each running their own `useQuery`25- Paginated lists where every loaded page stays subscribed2627## Common Causes2829### Reactive queries on low-freshness flows3031Some user flows are read-heavy and do not need live updates every time the32underlying data changes. In those cases, ongoing subscriptions may cost more33than they are worth.3435### Overly broad queries3637A query that returns a large result set invalidates whenever any document in38that set changes. The broader the query, the more frequent the invalidation.3940### Too many subscriptions per page4142A page with 20 list items, each running its own `useQuery` to fetch related43data, creates 20+ subscriptions per visitor.4445### Paginated queries keeping all pages live4647`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a48page where a user has scrolled through 10 pages, all 10 stay reactive.4950### Frequently-updated fields on widely-read documents5152A document that many queries touch gets a frequently-updated field (like53`lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates54every subscription that reads the document, even if those subscriptions never55use the field. This is different from OCC conflicts (see `occ-conflicts.md`),56which are write-vs-write contention. This is write-vs-subscription: the write57succeeds fine, but it forces hundreds of queries to re-run for no reason.5859## Fix Order6061### 1. Use point-in-time reads when live updates are not valuable6263Keep `useQuery` and `usePaginatedQuery` by default when the product benefits64from fresh live data.6566Consider a point-in-time read instead when all of these are true:6768- the flow is high-read69- the underlying data changes less often than users need to see70- explicit refresh, periodic refresh, or a fresh read on navigation is71acceptable7273Possible implementations depend on environment:7475- a server-rendered fetch76- a framework helper like `fetchQuery`77- a point-in-time client read such as `ConvexHttpClient.query()`7879```ts80// Reactive by default when fresh live data matters81function TeamPresence() {82const presence = useQuery(api.teams.livePresence, { teamId });83return <PresenceList users={presence} />;84}85```8687```ts88// Point-in-time read when explicit refresh is acceptable89import { ConvexHttpClient } from "convex/browser";9091const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);9293function SnapshotView() {94const [items, setItems] = useState<Item[]>([]);9596useEffect(() => {97client.query(api.items.snapshot).then(setItems);98}, []);99100return <ItemGrid items={items} />;101}102```103104Good candidates for point-in-time reads:105106- aggregate snapshots107- reports108- low-churn listings109- flows where explicit refresh is already acceptable110111Keep reactive for:112113- collaborative editing114- live dashboards115- presence-heavy views116- any surface where users expect fresh changes to appear automatically117118### 2. Batch related data into fewer queries119120Instead of N components each fetching their own related data, fetch it in a121single query.122123```ts124// Bad: each card fetches its own author125function ProjectCard({ project }: { project: Project }) {126const author = useQuery(api.users.get, { id: project.authorId });127return <Card title={project.name} author={author?.name} />;128}129```130131```ts132// Good: parent query returns projects with author names included133function ProjectList() {134const projects = useQuery(api.projects.listWithAuthors);135return projects?.map((p) => (136<Card key={p._id} title={p.name} author={p.authorName} />137));138}139```140141This can use denormalized fields or server-side joins in the query handler.142Either way, it is one subscription instead of N.143144This is not automatically better. If the combined query becomes much broader and145invalidates much more often, several narrower subscriptions may be the better146tradeoff. Optimize for total invalidation cost, not raw subscription count.147148### 3. Use skip to avoid unnecessary subscriptions149150The `"skip"` value prevents a subscription from being created when the arguments151are not ready.152153```ts154// Bad: subscribes with undefined args, wastes a subscription slot155const profile = useQuery(api.users.getProfile, { userId: selectedId! });156```157158```ts159// Good: skip when there is nothing to fetch160const profile = useQuery(161api.users.getProfile,162selectedId ? { userId: selectedId } : "skip",163);164```165166### 4. Isolate frequently-updated fields into separate documents167168If a document is widely read but has a field that changes often, move that field169to a separate document. Queries that do not need the field will no longer be170invalidated by its writes.171172```ts173// Bad: lastSeen lives on the user doc, every heartbeat invalidates174// every query that reads this user175const users = defineTable({176name: v.string(),177email: v.string(),178lastSeen: v.number(),179});180```181182```ts183// Good: lastSeen lives in a separate heartbeat doc184const users = defineTable({185name: v.string(),186email: v.string(),187heartbeatId: v.id("heartbeats"),188});189190const heartbeats = defineTable({191lastSeen: v.number(),192});193```194195Queries that only need `name` and `email` no longer re-run on every heartbeat.196Queries that actually need online status fetch the heartbeat document197explicitly.198199For an even further optimization, if you only need a coarse online/offline200boolean rather than the exact `lastSeen` timestamp, add a separate presence201document with an `isOnline` flag. Update it immediately when a user comes202online, and use a cron to batch-mark users offline when their heartbeat goes203stale. This way the presence query only invalidates when online status actually204changes, not on every heartbeat.205206### 5. Use the aggregate component for counts and sums207208Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert209or delete to the table. The210[`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate)211component maintains denormalized COUNT, SUM, and MAX values efficiently so you212do not need a reactive query scanning the full table.213214Use it for leaderboards, totals, "X items" badges, or any stat that would215otherwise require scanning many rows reactively.216217If the aggregate component is not appropriate, prefer point-in-time reads for218global stats, or precomputed summary rows updated by a cron or trigger, over219reactive queries that scan large tables.220221### 6. Narrow query read sets222223Queries that return less data and touch fewer documents invalidate less often.224225```ts226// Bad: returns all fields, invalidates on any field change227export const list = query({228handler: async (ctx) => {229return await ctx.db.query("projects").collect();230},231});232```233234```ts235// Good: use a digest table with only the fields the list needs236export const listDigests = query({237handler: async (ctx) => {238return await ctx.db.query("projectDigests").collect();239},240});241```242243Writes to fields not in the digest table do not invalidate the digest query.244245### 7. Remove `Date.now()` from queries246247Using `Date.now()` inside a query defeats Convex's query cache. The cache is248invalidated frequently to avoid showing stale time-dependent results, which249increases database work even when the underlying data has not changed.250251```ts252// Bad: Date.now() defeats query caching and causes frequent re-evaluation253const releasedPosts = await ctx.db254.query("posts")255.withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now()))256.take(100);257```258259```ts260// Good: use a boolean field updated by a scheduled function261const releasedPosts = await ctx.db262.query("posts")263.withIndex("by_is_released", (q) => q.eq("isReleased", true))264.take(100);265```266267If the query must compare against a time value, pass it as an explicit argument268from the client and round it to a coarse interval (e.g. the most recent minute)269so requests within that window share the same cache entry.270271### 8. Consider pagination strategy272273For long lists where users scroll through many pages:274275- If the data does not need live updates, use point-in-time fetching with manual276"load more"277- If it does need live updates, accept the subscription cost but limit the278number of loaded pages279- Consider whether older pages can be unloaded as the user scrolls forward280281### 9. Separate backend cost from UI churn282283If the main problem is loading flash or UI churn when query arguments change,284stabilizing the reactive UI behavior may be better than replacing reactivity285altogether.286287Treat this as a UX problem first when:288289- the underlying query is already reasonably cheap290- the complaint is flicker, loading flashes, or re-render churn291- live updates are still desirable once fresh data arrives292293## Verification2942951. Subscription count in dashboard is lower for the affected pages2962. UI responsiveness has improved2973. React profiling shows fewer unnecessary re-renders2984. Surfaces that do not need live updates are not paying for persistent299subscriptions unnecessarily3005. Sibling pages with similar patterns were updated consistently301