Queries are read-only — they can’t schedule mutations or actions. But queries are often the first place you detect that background work is needed (a cache miss, stale data, a missing materialized row). Today the only options are: client round-trips (extra latency, duplicated trigger logic, race conditions), eager mutation-side computation (timeouts on large fan-outs), or polling crons (wasteful, stale).
Let queries call ctx.scheduleEffect(fnRef, args) to fire-and-forget schedule a mutation or action. The query stays read-only and deterministic — same inputs produce the same return value. The schedule is a side channel that doesn’t affect the result or create read dependencies. Returns void (no job ID). Target functions must be idempotent since re-executions may duplicate schedules. Convex could optionally deduplicate identical (fnRef, args) pairs within a time window.
Some Use Cases:
Authorization materialization — We run Zanzibar-style ReBAC on Convex with materialized access grants. When tuples change, mutations delete stale grants and schedule async backfill. Between deletion and backfill, queries hit cache misses and fall back to expensive dynamic permission checks. With write-through, the query schedules the backfill itself on cache miss instead of waiting for the client to notice.
Lazy denormalization — Dashboard stats, aggregates, or rollups go stale when source data changes. Instead of recomputing synchronously in every mutation or running crons, the query detects staleness and schedules recomputation.
View tracking — Recording “last accessed” or “recently viewed” is a side effect of reading. Today it requires a separate client mutation call. Write-through lets the query signal the event directly.
Search index warming — Custom search indexes can be populated lazily on first miss rather than eagerly on every write.
Quota enforcement — A query detects a usage overage and schedules a mutation to lock the account, without blocking the current read.
Why Not Use the Client?
The query already has the context to detect the problem. Pushing detection to the client splits logic across three layers (query, client, mutation), adds a round-trip, and requires every subscriber to implement the same trigger. Server-side callers (ctx.runQuery() in actions) can’t trigger client-side mutations at all.