A subscription in Hasura is a query you keep open. Same syntax, same permissions, same relationships — but instead of one response the server pushes a new one whenever the result changes. That framing is worth holding onto, because it explains both the power and the cost.
The same query, held open
subscription BookingStatus($id: uuid!) {
bookings(where: {publicId: {_eq: $id}}) {
publicId
status
checkIn
checkOut
}
}Change that row in the database — from anywhere, including psql — and
every client watching it receives the new result. Nothing published an event; Hasura noticed the
result changed.
Live queries and how they scale
The obvious implementation is polling per client, and that would fall over immediately. Hasura does something smarter: it multiplexes. Subscriptions with the same query text but different variables are batched into one database query, run on an interval, and the results fanned back out.
Two consequences you should plan around:
Updates are on an interval, not instantaneous. The default refetch is one second. Hasura is not tailing the write-ahead log — it re-runs the query. For a booking status that is perfect; for a trading feed it is not what you want.
Query text is the batching key. A thousand clients running the same subscription with different ids is one efficient query. A thousand clients each with slightly different query text is a thousand queries. This is a strong reason to keep subscription documents in a shared module rather than building them per component.
Streaming subscriptions
The other flavour. A live query sends the whole current result each time; a streaming subscription sends only rows after a cursor, which is what you want for an append-only feed like a chat log or an audit trail:
subscription NewMessages($after: timestamptz!) {
messagesStream(
batchSize: 20,
cursor: {initialValue: {createdAt: $after}, ordering: ASC}
) { id body createdAt }
}Use a live query for “what is the current state of this thing”, and a stream for “what has happened since I last looked”.
Wiring it into React
Subscriptions run over WebSockets, so the client needs a second link alongside HTTP. The demo app builds its Apollo client like this for queries:
const httpLink = new HttpLink({
uri: import.meta.env.VITE_HASURA_URL ?? 'http://localhost:8081/v1/graphql',
})Adding subscriptions means splitting traffic by operation type — queries and mutations over HTTP, subscriptions over the socket:
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'
import { getMainDefinition } from '@apollo/client/utilities'
import { split } from '@apollo/client'
const wsLink = new GraphQLWsLink(createClient({
url: 'ws://localhost:8081/v1/graphql',
connectionParams: () => ({
headers: { Authorization: `Bearer ${localStorage.getItem('token') ?? ''}` },
}),
}))
const splitLink = split(
({ query }) => {
const def = getMainDefinition(query)
return def.kind === 'OperationDefinition' && def.operation === 'subscription'
},
wsLink,
httpLink,
)The auth handshake that trips everyone up
This is the part that costs an evening. An HTTP request carries its Authorization
header every time. A WebSocket authenticates once, at connection, through
connectionParams.
Two things follow. First, the header must go in connectionParams and not in the
link’s HTTP headers, where it is simply ignored. Second, and less obviously: when the token is
refreshed, the open socket keeps using the old one. It was authenticated at connect time and nothing
re-reads the header. Tokens expire, the subscription quietly stops updating, and no error appears in
the UI.
The fix is to close and reopen the socket on token change. Make
connectionParams a function — as above, not an object — so it is re-evaluated
on each reconnect rather than captured once.
Permissions apply exactly as they do to queries
A subscription is a query, so the role’s row filter and column allowlist apply unchanged. A
customer subscribing to bookings sees their own, because
OWN_BOOKING is evaluated on every refetch. You do not write separate rules, and you
cannot accidentally push a row a client is not allowed to read.
Should you use one?
Often, no. A subscription holds a socket open per client and re-runs a query on an interval forever. For a page somebody looks at for ten seconds, refetching on focus is cheaper and simpler.
Reach for a subscription when the data changes while the user is watching and staleness is confusing or costly: a booking being confirmed, an order moving through a kitchen, a document someone else is editing. For everything else, a query with a sensible refetch is the boring right answer.
Subscriptions in v3 (DDN)
On the v3 sections. Everything marked v3 (DDN) is taken from the official Hasura DDN documentation as read on 2026-08-21 and was not run locally — it shows configuration, never claimed output. The v2 material was executed against a running engine.
Subscriptions are supported in DDN. Streaming subscriptions are marked work in progress, so the append-only-feed pattern above is the one to check before depending on it in v3.
As with everything else in v3, the capability is declared rather than generated — a model
exposes a subscription root field in its graphql block, and
ModelPermissions governs what it can return, exactly as it governs a query.
Next
Lesson 8 backs up to the question all of this depends on: who is the caller?