API · Graphql

GraphQL Subscriptions for Real-Time

GraphQL Subscriptions for Real-Time is the work that makes the systems talk. The API is the contract between the producer and the consumer; the contract is what determines whether.

John Kihiu12 min read

Queries and mutations answer "what is the state right now" — subscriptions answer "tell me when it changes." That's a fundamentally different transport problem: a request/response cycle over HTTP doesn't hold a connection open, so GraphQL subscriptions run over a persistent connection, almost always a WebSocket, using a long-lived protocol (graphql-ws is the current standard; the older subscriptions-transport-ws is deprecated but still seen in older codebases). The server pushes data to the client as events happen, instead of the client polling for it.

What a subscription actually does

A subscription operation looks like a query in SDL, but its resolver behaves completely differently: instead of returning a value, it returns an async iterator that the server pushes new values into every time the underlying event fires. Most implementations wire this to a pub/sub backend — an in-memory event emitter for a single server, or Redis pub/sub, NATS, or a Kafka consumer group for anything running more than one server instance. The in-memory version is fine for a demo and wrong for production the moment you have two server processes, because an event published on process A never reaches a subscriber connected to process B.

GRAPHQL · SCHEMA
type Subscription {
  orderStatusChanged(orderId: ID!): Order!
  ticketMessageAdded(ticketId: ID!): Message!
}

subscription WatchOrder($orderId: ID!) {
  orderStatusChanged(orderId: $orderId) {
    id
    status
    updatedAt
  }
}

Connection lifecycle and scaling

Every open subscription is a held-open connection and, usually, a small amount of server memory tracking what that client is listening for. That changes your capacity planning: a REST or query-only GraphQL API scales mostly on request throughput, while a subscription-heavy API scales on concurrent connection count, and those hit different limits (file descriptors, load balancer connection tables, memory per connection) well before CPU becomes the bottleneck. Horizontal scaling requires the pub/sub backend mentioned above — without it, a client subscribed via server A never sees an event triggered by a mutation that happened to land on server B.

Authenticate the connection, not just the operation

A WebSocket connection is established once and reused for every subscription sent over it. If you only check auth on the initial HTTP handshake, a token that expires mid-connection keeps working indefinitely. Re-validate on the connection_init message and consider a maximum connection lifetime that forces reconnection (and re-auth) periodically.

When not to use a subscription

Subscriptions are the right tool for genuinely event-driven UI — a live order status, a chat message, a collaborative cursor position — where the client needs to react within seconds of a change. They are the wrong tool for data that changes slowly or where a few seconds of staleness is fine; a query on a 30-second poll interval is simpler to reason about, easier to cache, and doesn't need a persistent connection or pub/sub infrastructure at all. Reach for a subscription because the UI genuinely needs push, not because it feels more modern than polling.

Reconnection and missed events

Mobile clients in particular drop connections constantly — a tunnel closes, the app backgrounds, wifi hands off to cellular. A subscription client needs a reconnection strategy, and the harder problem is what happens to events that fired while disconnected: most GraphQL subscription implementations do not replay missed events by default. If a client absolutely cannot miss an update, pair the subscription with a query that fetches current state on reconnect, rather than assuming the subscription stream is a complete history.

Filtering and authorization per subscriber

A subscription resolver typically needs a filter function that decides, per connected client, whether a published event is relevant to them — orderStatusChanged(orderId: "123") should only push events for order 123, not every order in the system. That filter is also where authorization belongs: check that the connected user actually has permission to see that order before forwarding the event, the same way you'd check permissions in a query resolver. Skipping this check is a common way for subscription-based APIs to leak data that the equivalent query endpoint correctly protects.

Wrapping up

Subscriptions solve a real problem — pushing state changes to clients without polling — but they come with real operational cost: persistent connections, a pub/sub backend for multi-server deployments, and reconnection handling that queries never need to think about. Use them where the UI genuinely needs to react to change in real time, and default to plain queries with a sensible poll interval everywhere else.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.