301 lines
9.6 KiB
Markdown
301 lines
9.6 KiB
Markdown
# Subscription Cost
|
|
|
|
Use these rules when the problem is too many reactive subscriptions, queries
|
|
invalidating too frequently, or React components re-rendering excessively due to
|
|
Convex state changes.
|
|
|
|
## Core Principle
|
|
|
|
Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The
|
|
server tracks the query's read set and re-executes the query whenever any
|
|
document in that read set changes. Subscription cost scales with:
|
|
|
|
`subscriptions x invalidation_frequency x query_cost`
|
|
|
|
Subscriptions are not inherently bad. Convex reactivity is often the right
|
|
default. The goal is to reduce unnecessary invalidation work, not to eliminate
|
|
subscriptions on principle.
|
|
|
|
## Symptoms
|
|
|
|
- Dashboard shows high active subscription count
|
|
- UI feels sluggish or laggy despite fast individual queries
|
|
- React profiling shows frequent re-renders from Convex state
|
|
- Pages with many components each running their own `useQuery`
|
|
- Paginated lists where every loaded page stays subscribed
|
|
|
|
## Common Causes
|
|
|
|
### Reactive queries on low-freshness flows
|
|
|
|
Some user flows are read-heavy and do not need live updates every time the
|
|
underlying data changes. In those cases, ongoing subscriptions may cost more
|
|
than they are worth.
|
|
|
|
### Overly broad queries
|
|
|
|
A query that returns a large result set invalidates whenever any document in
|
|
that set changes. The broader the query, the more frequent the invalidation.
|
|
|
|
### Too many subscriptions per page
|
|
|
|
A page with 20 list items, each running its own `useQuery` to fetch related
|
|
data, creates 20+ subscriptions per visitor.
|
|
|
|
### Paginated queries keeping all pages live
|
|
|
|
`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a
|
|
page where a user has scrolled through 10 pages, all 10 stay reactive.
|
|
|
|
### Frequently-updated fields on widely-read documents
|
|
|
|
A document that many queries touch gets a frequently-updated field (like
|
|
`lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates
|
|
every subscription that reads the document, even if those subscriptions never
|
|
use the field. This is different from OCC conflicts (see `occ-conflicts.md`),
|
|
which are write-vs-write contention. This is write-vs-subscription: the write
|
|
succeeds fine, but it forces hundreds of queries to re-run for no reason.
|
|
|
|
## Fix Order
|
|
|
|
### 1. Use point-in-time reads when live updates are not valuable
|
|
|
|
Keep `useQuery` and `usePaginatedQuery` by default when the product benefits
|
|
from fresh live data.
|
|
|
|
Consider a point-in-time read instead when all of these are true:
|
|
|
|
- the flow is high-read
|
|
- the underlying data changes less often than users need to see
|
|
- explicit refresh, periodic refresh, or a fresh read on navigation is
|
|
acceptable
|
|
|
|
Possible implementations depend on environment:
|
|
|
|
- a server-rendered fetch
|
|
- a framework helper like `fetchQuery`
|
|
- a point-in-time client read such as `ConvexHttpClient.query()`
|
|
|
|
```ts
|
|
// Reactive by default when fresh live data matters
|
|
function TeamPresence() {
|
|
const presence = useQuery(api.teams.livePresence, { teamId });
|
|
return <PresenceList users={presence} />;
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// Point-in-time read when explicit refresh is acceptable
|
|
import { ConvexHttpClient } from "convex/browser";
|
|
|
|
const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
|
|
|
|
function SnapshotView() {
|
|
const [items, setItems] = useState<Item[]>([]);
|
|
|
|
useEffect(() => {
|
|
client.query(api.items.snapshot).then(setItems);
|
|
}, []);
|
|
|
|
return <ItemGrid items={items} />;
|
|
}
|
|
```
|
|
|
|
Good candidates for point-in-time reads:
|
|
|
|
- aggregate snapshots
|
|
- reports
|
|
- low-churn listings
|
|
- flows where explicit refresh is already acceptable
|
|
|
|
Keep reactive for:
|
|
|
|
- collaborative editing
|
|
- live dashboards
|
|
- presence-heavy views
|
|
- any surface where users expect fresh changes to appear automatically
|
|
|
|
### 2. Batch related data into fewer queries
|
|
|
|
Instead of N components each fetching their own related data, fetch it in a
|
|
single query.
|
|
|
|
```ts
|
|
// Bad: each card fetches its own author
|
|
function ProjectCard({ project }: { project: Project }) {
|
|
const author = useQuery(api.users.get, { id: project.authorId });
|
|
return <Card title={project.name} author={author?.name} />;
|
|
}
|
|
```
|
|
|
|
```ts
|
|
// Good: parent query returns projects with author names included
|
|
function ProjectList() {
|
|
const projects = useQuery(api.projects.listWithAuthors);
|
|
return projects?.map((p) => (
|
|
<Card key={p._id} title={p.name} author={p.authorName} />
|
|
));
|
|
}
|
|
```
|
|
|
|
This can use denormalized fields or server-side joins in the query handler.
|
|
Either way, it is one subscription instead of N.
|
|
|
|
This is not automatically better. If the combined query becomes much broader and
|
|
invalidates much more often, several narrower subscriptions may be the better
|
|
tradeoff. Optimize for total invalidation cost, not raw subscription count.
|
|
|
|
### 3. Use skip to avoid unnecessary subscriptions
|
|
|
|
The `"skip"` value prevents a subscription from being created when the arguments
|
|
are not ready.
|
|
|
|
```ts
|
|
// Bad: subscribes with undefined args, wastes a subscription slot
|
|
const profile = useQuery(api.users.getProfile, { userId: selectedId! });
|
|
```
|
|
|
|
```ts
|
|
// Good: skip when there is nothing to fetch
|
|
const profile = useQuery(
|
|
api.users.getProfile,
|
|
selectedId ? { userId: selectedId } : "skip",
|
|
);
|
|
```
|
|
|
|
### 4. Isolate frequently-updated fields into separate documents
|
|
|
|
If a document is widely read but has a field that changes often, move that field
|
|
to a separate document. Queries that do not need the field will no longer be
|
|
invalidated by its writes.
|
|
|
|
```ts
|
|
// Bad: lastSeen lives on the user doc, every heartbeat invalidates
|
|
// every query that reads this user
|
|
const users = defineTable({
|
|
name: v.string(),
|
|
email: v.string(),
|
|
lastSeen: v.number(),
|
|
});
|
|
```
|
|
|
|
```ts
|
|
// Good: lastSeen lives in a separate heartbeat doc
|
|
const users = defineTable({
|
|
name: v.string(),
|
|
email: v.string(),
|
|
heartbeatId: v.id("heartbeats"),
|
|
});
|
|
|
|
const heartbeats = defineTable({
|
|
lastSeen: v.number(),
|
|
});
|
|
```
|
|
|
|
Queries that only need `name` and `email` no longer re-run on every heartbeat.
|
|
Queries that actually need online status fetch the heartbeat document
|
|
explicitly.
|
|
|
|
For an even further optimization, if you only need a coarse online/offline
|
|
boolean rather than the exact `lastSeen` timestamp, add a separate presence
|
|
document with an `isOnline` flag. Update it immediately when a user comes
|
|
online, and use a cron to batch-mark users offline when their heartbeat goes
|
|
stale. This way the presence query only invalidates when online status actually
|
|
changes, not on every heartbeat.
|
|
|
|
### 5. Use the aggregate component for counts and sums
|
|
|
|
Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert
|
|
or delete to the table. The
|
|
[`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate)
|
|
component maintains denormalized COUNT, SUM, and MAX values efficiently so you
|
|
do not need a reactive query scanning the full table.
|
|
|
|
Use it for leaderboards, totals, "X items" badges, or any stat that would
|
|
otherwise require scanning many rows reactively.
|
|
|
|
If the aggregate component is not appropriate, prefer point-in-time reads for
|
|
global stats, or precomputed summary rows updated by a cron or trigger, over
|
|
reactive queries that scan large tables.
|
|
|
|
### 6. Narrow query read sets
|
|
|
|
Queries that return less data and touch fewer documents invalidate less often.
|
|
|
|
```ts
|
|
// Bad: returns all fields, invalidates on any field change
|
|
export const list = query({
|
|
handler: async (ctx) => {
|
|
return await ctx.db.query("projects").collect();
|
|
},
|
|
});
|
|
```
|
|
|
|
```ts
|
|
// Good: use a digest table with only the fields the list needs
|
|
export const listDigests = query({
|
|
handler: async (ctx) => {
|
|
return await ctx.db.query("projectDigests").collect();
|
|
},
|
|
});
|
|
```
|
|
|
|
Writes to fields not in the digest table do not invalidate the digest query.
|
|
|
|
### 7. Remove `Date.now()` from queries
|
|
|
|
Using `Date.now()` inside a query defeats Convex's query cache. The cache is
|
|
invalidated frequently to avoid showing stale time-dependent results, which
|
|
increases database work even when the underlying data has not changed.
|
|
|
|
```ts
|
|
// Bad: Date.now() defeats query caching and causes frequent re-evaluation
|
|
const releasedPosts = await ctx.db
|
|
.query("posts")
|
|
.withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now()))
|
|
.take(100);
|
|
```
|
|
|
|
```ts
|
|
// Good: use a boolean field updated by a scheduled function
|
|
const releasedPosts = await ctx.db
|
|
.query("posts")
|
|
.withIndex("by_is_released", (q) => q.eq("isReleased", true))
|
|
.take(100);
|
|
```
|
|
|
|
If the query must compare against a time value, pass it as an explicit argument
|
|
from the client and round it to a coarse interval (e.g. the most recent minute)
|
|
so requests within that window share the same cache entry.
|
|
|
|
### 8. Consider pagination strategy
|
|
|
|
For long lists where users scroll through many pages:
|
|
|
|
- If the data does not need live updates, use point-in-time fetching with manual
|
|
"load more"
|
|
- If it does need live updates, accept the subscription cost but limit the
|
|
number of loaded pages
|
|
- Consider whether older pages can be unloaded as the user scrolls forward
|
|
|
|
### 9. Separate backend cost from UI churn
|
|
|
|
If the main problem is loading flash or UI churn when query arguments change,
|
|
stabilizing the reactive UI behavior may be better than replacing reactivity
|
|
altogether.
|
|
|
|
Treat this as a UX problem first when:
|
|
|
|
- the underlying query is already reasonably cheap
|
|
- the complaint is flicker, loading flashes, or re-render churn
|
|
- live updates are still desirable once fresh data arrives
|
|
|
|
## Verification
|
|
|
|
1. Subscription count in dashboard is lower for the affected pages
|
|
2. UI responsiveness has improved
|
|
3. React profiling shows fewer unnecessary re-renders
|
|
4. Surfaces that do not need live updates are not paying for persistent
|
|
subscriptions unnecessarily
|
|
5. Sibling pages with similar patterns were updated consistently
|