Optimistic Updates
Update local Convex query results directly, then let Convex confirm or roll back the change.
An optimistic update changes local query results before the mutation response arrives. Convex rolls the overlay back when the mutation fails and reconciles it with confirmed server data when it succeeds.
Use optimism when the expected server result is predictable. Prefer the normal live update for complex authorization or server-generated values.
optimisticUpdate must be synchronous and return undefined. An async function or Promise-like return is rejected by the TypeScript contract and diagnosed at runtime for JavaScript or casted callers.
The local-store contract
The callback receives Convex's OptimisticLocalStore. It has three direct methods:
| Method | Use |
|---|---|
getQuery(query, args) | Read one currently loaded exact query result |
getAllQueries(query) | Read every loaded argument variant of a query |
setQuery(query, args, data) | Replace or remove one exact local result |
Treat every returned value as immutable. Create new arrays and objects before passing them back.
Regular query example
import type { Id } from '~/convex/_generated/dataModel'
import { api } from '#convex/api'
const addTodo = useConvexMutation(api.todos.create, {
optimisticUpdate: (store, args) => {
const current = store.getQuery(api.todos.list, {})
if (current === undefined) return undefined
const optimisticTodo = {
_id: crypto.randomUUID() as Id<'todos'>,
_creationTime: Date.now(),
text: args.text,
completed: false,
}
store.setQuery(api.todos.list, {}, [optimisticTodo, ...current])
return undefined
},
})The optimistic item must contain every field the UI reads. Its ID is temporary and must not be used as a durable external identifier.
Exact query arguments matter. Updating { organizationId } does not update another argument set automatically. Use getAllQueries() only when the product explicitly needs to update multiple variants.
Paginated example
Pagination results are stored as Convex pages. Update only pages that are already loaded and preserve their cursor metadata:
const sendMessage = useConvexMutation(api.messages.send, {
optimisticUpdate: (store, args) => {
const optimisticMessage = {
_id: crypto.randomUUID() as Id<'messages'>,
_creationTime: Date.now(),
channelId: args.channelId,
body: args.body,
}
for (const loaded of store.getAllQueries(api.messages.list)) {
if (
loaded.value !== undefined &&
loaded.args.channelId === args.channelId &&
loaded.args.paginationOpts.cursor === null
) {
store.setQuery(api.messages.list, loaded.args, {
...loaded.value,
page: [optimisticMessage, ...loaded.value.page],
})
}
}
return undefined
},
})This does not create unloaded pages or change backend ordering. More complex pagination edits must preserve page boundaries and are often clearer when left to the confirmed live result.
Failure and identity behavior
Convex owns rollback. Do not manually undo the same optimistic change in a catch block.
An auth: 'none' query uses a physically separate anonymous Convex client and local store. A mutation on the primary client cannot update that anonymous view; it reconciles when its server result arrives.
Identity changes retire the old client, including its optimistic state. A mutation that crossed the identity boundary may have committed remotely even though the local call rejects. Do not automatically replay it.