Live Queries
Live queries allow the server to push updates to clients whenever the underlying data changes, using resource-based invalidation and JSON diff-patching.
How It Works
Section titled “How It Works”- A query is marked as
@livein the schema - The server tracks which resource identifiers the query accesses
- When a mutation invalidates one of those identifiers, the query re-executes
- Only the diff is sent to the client via
jsondiffpatch
Marking Queries as Live
Section titled “Marking Queries as Live”Add the @live directive to query fields in your schema:
type Query { users: [User!]! @live}Invalidating Resources
Section titled “Invalidating Resources”From within a resolver, use the liveQueryStore from context:
const resolvers = { Mutation: { createUser: async (_, args, context) => { const user = await db.createUser(args) await context.liveQueryStore.invalidate('Query.users') return user }, },}Invalidation Helpers
Section titled “Invalidation Helpers”The invalidate function accepts multiple formats:
// Simple stringliveQueryStore.invalidate('Query.users')
// Multiple identifiersliveQueryStore.invalidate(['Query.users', 'Query.userCount'])
// Builder functionliveQueryStore.invalidate((tools) => [ tools.id('User', user.id), tools.args('Query.user', { id: user.id }),])Live Identifier Directive
Section titled “Live Identifier Directive”By default, when a @live query has arguments, the system creates an identifier that includes all arguments. This means invalidating the query requires knowing the exact argument combination each client used.
This becomes a problem with filtered lists — for example, a tasks query with a filter argument. Every unique filter combination produces a different identifier, making it impractical to invalidate all active tasks queries when a task is created:
# ❌ Without @liveIdentifier, each filter combination is a separate identifier.# Invalidating "Query.tasks" won't reach clients using "Query.tasks(filter: ...)"type Query { tasks(filter: TaskFilter): [Task!]! @live}Use @liveIdentifier to explicitly select which arguments should be part of the identifier. Arguments not marked with @liveIdentifier are excluded, so the filter no longer splits subscribers into separate identifiers:
# ✅ Only 'projectId' scopes the identifier — filter is ignoredtype Query { tasks(projectId: ID! @liveIdentifier, filter: TaskFilter): [Task!]! @live}To invalidate all tasks for a given project, regardless of filter:
liveQueryStore.invalidate((t) => t.args('Query.tasks', { projectId: 'xyz' }))Debugging
Section titled “Debugging”When NODE_ENV is not set to production, the server includes the generated live query identifiers in the response extensions. You can inspect them in your browser’s devtools under the Socket (or WS) tab — each live query response will contain the resource identifiers it subscribed to. This is useful for verifying that your @liveIdentifier directives produce the expected identifiers.