Subscriptions and real-time updates

The WebSocket transport, pub/sub design, the subscription lifecycle, backpressure and scaling past a single instance.

Defining a subscription

type Subscription {
  bookPublished(authorId: ID!): Book!
  orderStatusChanged(orderId: ID!): OrderStatusEvent!
}

type OrderStatusEvent {
  orderId: ID!
  status: OrderStatus!
  changedAt: DateTime!
}
import { PubSub } from "graphql-subscriptions";
const pubsub = new PubSub();      // in-memory: single instance only

const resolvers = {
  Subscription: {
    bookPublished: {
      subscribe: (_p, { authorId }) => pubsub.asyncIterableIterator("BOOK_PUBLISHED_" + authorId),
      resolve: (payload) => payload.book,
    },
  },
  Mutation: {
    async publishBook(_p, { id }, ctx) {
      const book = await ctx.db.book.publish(id);
      // one topic per author, so only interested subscribers are woken
      await pubsub.publish("BOOK_PUBLISHED_" + book.authorId, { book });
      return book;
    },
  },
};
  • Subscriptions run over WebSockets using the graphql-transport-ws protocol. HTTP and plain graphql-ws do not carry them.
  • Authentication happens during the connection handshake, not per message. Re-authenticate on a timer or close the socket when the token expires.
  • A subscription returns an async iterator. Every subscription must have a matching unsubscribe path, or the socket and its listeners leak.
⚠️
A subscription per entity is a client-controlled fan-out. Without a limit on concurrent subscriptions per connection, one client can subscribe to ten thousand ids, and every publish then walks that list on your server.

Delivery and reliability

ConcernApproachWhy
Multiple instancesRedis pub/sub or a brokerAn in-memory PubSub only reaches one process
Missed messagesClient re-syncs on reconnectWebSockets drop; subscriptions do not replay
Slow consumerBound the queue per socketOtherwise the publisher blocks or memory grows
OrderingPartition by entity idGlobal ordering is expensive and rarely needed
At-least-onceSend an event id, client dedupesA reconnect can replay a frame
import { RedisPubSub } from "graphql-redis-subscriptions";
import Redis from "ioredis";

const pubsub = new RedisPubSub({
  publisher: new Redis(process.env.REDIS_URL),
  subscriber: new Redis(process.env.REDIS_URL),
});

// a client that reconnects must catch up
// query the delta since lastEventId before resuming the subscription
const { data } = await client.query({ query: ORDER_EVENTS_SINCE, variables: { since: lastEventId } });

Treat a subscription as an optimisation, never as the only source of truth. The client should be able to reconstruct current state with a query after a reconnect; the subscription only keeps it fresh between queries.

Running subscriptions in production

  • WebSocket connections are long-lived: configure idle timeouts on the load balancer to match, and send a periodic keep-alive ping.
  • Sticky sessions are needed only if you keep per-connection state; a shared pub/sub backend removes that requirement.
  • Authenticate the handshake, then check authorization per event: a user who lost access must stop receiving updates.
  • Emit a bounded payload. Sending an entire entity on every change is a bandwidth problem at scale - send the id and let the client refetch if it needs details.
  • Measure the number of active subscriptions and the publish-to-delivery latency; a growing subscriber set is a capacity signal.
// a graceful shutdown that closes sockets properly
process.on("SIGTERM", async () => {
  await server.stop();          // stop accepting new connections
  await pubsub.close();         // release the redis subscribers
  await db.destroy();
  process.exit(0);
});

FAQ

Subscriptions or polling?
Polling is simpler, stateless and easy to cache; it is often good enough for anything that changes every few seconds. Choose subscriptions when the update rate is high, the latency requirement is tight, or the traffic cost of polling every client is real.
How do I test a subscription?
Subscribe with a test client, run the mutation that should trigger it, and assert on the received payload with a timeout. Also test that unsubscribing stops delivery and that a token expiring closes the connection.

Authentication and field-level authorization Federation, caching and next steps

Last refreshed 2026-09-18.