Skip to main content

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:

MethodUse
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

ts
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:

ts
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.