Concept
The beginner framing: a query or mutation is a single request that gets a single response, if something changes on the server afterward, the client has no way to find out unless it asks again. A subscription is different: the client opens a connection and the server can push new data to it whenever a relevant event happens, with no new request needed.
subscription {commentAdded(postId: 1) { text, author }}
Unlike a query or mutation, a subscription opens a PERSISTENT WebSocket connection (via graphql-ws, the modern transport), the server registers this client as a listener for a specific event topic, and the connection stays open.
The transport: graphql-ws, not the deprecated subscriptions-transport-ws
import { createClient } from "graphql-ws";
const client = createClient({
url: "ws://localhost:4000/graphql",
});
const unsubscribe = client.subscribe(
{ query: `subscription { commentAdded { text } }` },
{
next: (data) => console.log("New comment:", data),
error: (err) => console.error(err),
complete: () => console.log("Subscription closed"),
}
);Version-currency callout, confirmed directly: subscriptions-transport-ws, the transport package a lot of older GraphQL subscription tutorials still teach, carries its own published npm deprecation notice stating it's "no longer maintained" and explicitly recommending graphql-ws as the replacement. graphql-ws is the actively maintained modern transport, confirmed via its own recent publish activity, and its API (createClient, .subscribe() with next/error/complete callbacks) is meaningfully different from the older package's, this isn't a drop-in rename, code written for one won't work unmodified against the other.
The server side: PubSub and asyncIterableIterator
import { PubSub } from "graphql-subscriptions";
const pubsub = new PubSub();
const resolvers = {
Subscription: {
commentAdded: {
subscribe: () => pubsub.asyncIterableIterator(["COMMENT_ADDED"]),
},
},
Mutation: {
addComment: async (_parent, { postId, text }) => {
const comment = await saveComment(postId, text);
pubsub.publish("COMMENT_ADDED", { commentAdded: comment }); // triggers the subscription above
return comment;
Confirmed by running this exact publish/subscribe flow end-to-end: a subscription resolver's subscribe function returns an async iterable, here, pubsub.asyncIterableIterator(["COMMENT_ADDED"]), and calling pubsub.publish("COMMENT_ADDED", payload) anywhere else in the server (typically inside a mutation resolver, after the actual write succeeds) pushes that payload to every currently-subscribed client listening for that event name.
A second, easy-to-miss version-currency trap, also confirmed directly: the current graphql-subscriptions package (v3) exposes pubsub.asyncIterableIterator(...), calling the older method name, pubsub.asyncIterator(...), which a substantial amount of existing tutorial content still uses, is confirmed undefined on the current version. This is a genuine breaking rename, not two equally-valid spellings.
Try It
Predict the outcome before checking the solution.
// two clients subscribe to the same event around the same time:
client.subscribe({ query: `subscription { commentAdded { text } }` }, handlerA);
client.subscribe({ query: `subscription { commentAdded { text } }` }, handlerB);
// later, a mutation runs:
pubsub.publish("COMMENT_ADDED", { commentAdded: { text: "hi" } });Does only one of handlerA/handlerB receive the pushed event, or both?
Solution
Both receive it. pubsub.publish() broadcasts to every currently-subscribed listener for that event name, it's not a queue where one consumer takes the message and others miss it (unlike, say, a work queue). Each independently-subscribed client gets its own copy of the pushed payload, which is exactly the intended behavior for something like a live comment feed, where every viewer should see the new comment appear.
Implement It Yourself
Build a minimal in-memory pub/sub, to see the core mechanism graphql-subscriptions' PubSub class wraps:
function createMiniPubSub() {
const listeners = new Map(); // eventName -> Set of callback functions
return {
subscribe(eventName, callback) {
if (!listeners.has(eventName)) listeners.set(eventName, new Set());
listeners.get(eventName).add(callback);
return () => listeners.get(eventName).delete(callback); // returns an unsubscribe function
},
publish(eventName, payload) {
const callbacks = listeners.get(eventName);
This is the essential shape of what PubSub/asyncIterableIterator provide in production form, a real implementation additionally handles backpressure, cleanup on client disconnect, and wrapping the callback-based notification into an actual async iterable that subscribe() can consume, but the core "register listeners per event name, notify all of them on publish" idea is exactly this.
Under the Hood
Subscriptions are the one place in this domain where the request/response model from Queries & Mutations genuinely doesn't apply, everything else in that topic (variables, aliases, fragments) still works inside a subscription operation's selection set, but the execution model underneath (a long-lived connection instead of one request/one response) is fundamentally different. Setting up the actual server that serves both regular operations and subscriptions is covered in Apollo Server.
Common Mistakes
1. Using subscriptions-transport-ws in new code
import { SubscriptionClient } from "subscriptions-transport-ws"; // ❌ deprecated, no longer maintainedConfirmed via the package's own deprecation notice, new code should use graphql-ws instead; the two have meaningfully different APIs, not just different names for the same thing.
2. Calling pubsub.asyncIterator() instead of asyncIterableIterator()
subscribe: () => pubsub.asyncIterator(["COMMENT_ADDED"]), // ❌ undefined on graphql-subscriptions v3Confirmed directly, this method doesn't exist on the current package version. Code copied from an older tutorial will fail immediately with a "not a function" error.
3. Forgetting to call publish() after the actual write succeeds
addComment: async (_parent, args) => {
pubsub.publish("COMMENT_ADDED", { commentAdded: args }); // ❌ published BEFORE the save even runs
return saveComment(args);
}Publishing before the underlying write has actually completed (or worse, before it's even attempted) can notify subscribers about data that doesn't yet exist, or that fails to save entirely, publish only after the write has genuinely succeeded.
Best Practices
- Use
graphql-wsfor any new subscription transport work,subscriptions-transport-wsis confirmed deprecated and unmaintained. - Publish only after the underlying mutation's write has actually succeeded, never before or during, to avoid notifying subscribers about data that isn't real yet.
- Double-check method names against the actually-installed package version when following older subscription tutorials,
asyncIterableIteratorvs.asyncIteratoris exactly the kind of silent, breaking rename that only surfaces as a runtime error, not a type error, if the wrong one is used with plain JS. - Design subscription payloads to be self-contained, since a subscribed client isn't making a fresh request each time, the pushed payload needs to include everything the client actually needs to update its UI, not assume a follow-up query will fill in gaps.
Performance Tips
- Each open subscription connection consumes server resources for its entire lifetime (unlike a query/mutation's brief request/response cycle), a server design needs to account for potentially many long-lived connections, not just request throughput.
pubsub.publish()broadcasting to every subscriber is O(number of subscribers) per publish, for an event with a very large number of concurrent subscribers, this is a real scaling consideration distinct from a typical query's cost, which doesn't grow with unrelated clients' activity.
