Episode 16 builds real-time features with subscriptions: the subscription versus polling concept, WebSocket transport with graphql-ws, setting up subscriptions in Apollo Server 4, the PubSub pattern with Redis, per-connection subscription security, and client integration with useSubscription.

So far every GraphQL operation has been request-response: the client asks, the server answers, done. Episode 16 introduces a third mode: subscriptions, where the server "pushes" data to the client when an event happens — without the client having to ask repeatedly. We'll cover the subscription concept, WebSocket transport with the graphql-ws library, setting up subscriptions in Apollo Server 4, the PubSub pattern including a Redis implementation for production, per-connection security, and client integration with Apollo Client.
A subscription is a GraphQL operation that maintains an active connection. Every time a watched event happens, the server sends the latest payload. Classic use cases: real-time chat, notifications, and live dashboard updates.
Comparison with the alternatives:
Subscriptions generally run over WebSocket. The modern standard library is graphql-ws, which replaces the deprecated subscriptions-transport-ws. graphql-ws supports more robust connections, including ping/pong handling and error recovery; install it with npm install graphql-ws ws.
There's also the Server-Sent Events (SSE) alternative, which is one-way (server to client) and runs over plain HTTP — simpler for one-way notifications, no WebSocket needed. For chat and two-way collaboration, WebSocket remains the primary choice.
Apollo Server 4 handles subscriptions in two ways: via the ApolloServerPluginSubscriptionCallback plugin (HTTP callbacks) or via a standalone WebSocket with graphql-ws. The WebSocket approach:
import { useServer } from "graphql-ws/lib/use/ws";
import { WebSocketServer } from "ws";
import { createServer } from "http";
const httpServer = createServer();
const wsServer = new WebSocketServer({ server: httpServer, path: "/graphql" });
useServer(
{ schema: server.schema, context: async (ctx) => ({ user: authenticate(ctx) }) },
wsServer
);
httpServer.listen(4001, () => console.log("WS di port 4001"));Subscriptions are exposed on a separate WebSocket endpoint (/graphql), while queries and mutations still run over HTTP. The schema:
type Subscription {
messageAdded(roomId: ID!): Message!
notificationReceived: Notification!
}A subscription resolver has two parts: subscribe (returns an event iterator) and resolve (shapes the payload):
Subscription: {
messageAdded: {
subscribe: (_, args, ctx) =>
ctx.pubsub.asyncIterator(["MESSAGE_ADDED", args.roomId].join(":")),
resolve: (payload) => payload.message,
},
},PubSub (publish-subscribe) separates event publishers from subscribers. Mutations publish events, subscriptions catch them:
import { PubSub } from "graphql-subscriptions";
const pubsub = new PubSub();
async function addMessage(_, args, ctx) {
const message = await ctx.db.messages.create(args.input);
await pubsub.publish("MESSAGE_ADDED:general", { message });
return message;
}In-memory PubSub is fine for development, but doesn't work across server instances. If you run many instances (episode 34), an event published on instance A won't reach clients connected to instance B. The solution is a PubSub backed by a shared broker.
Redis PubSub is the standard for distributed subscriptions; install it with npm install graphql-redis-subscriptions ioredis:
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),
});All server instances listen to the same Redis channel, so events are distributed to every client across every instance. Redis PubSub will be revisited when we scale WebSockets in episode 34.
WebSocket connections are long-lived, so identity validation happens once when the connection is established — via the connectionParams the client sends:
useServer(
{
schema,
onConnect: (ctx) => {
const token = ctx.connectionParams?.token;
if (!token) throw new Error("Autentikasi gagal");
ctx.user = verifyToken(token);
},
context: (ctx) => ({ user: ctx.user }),
},
wsServer
);Beyond authentication, also apply authorization to subscription data (episode 14): make sure users only receive events they're allowed to see, for example by filtering topics based on room membership.
On the client side, Apollo Client provides the useSubscription hook:
import { useSubscription, gql } from "@apollo/client";
const MESSAGE_ADDED = gql`
subscription OnMessageAdded($roomId: ID!) {
messageAdded(roomId: $roomId) {
id
content
author { username }
}
}
`;
function ChatRoom({ roomId }) {
const { data, loading, error } = useSubscription(MESSAGE_ADDED, {
variables: { roomId },
});
if (loading) return <p>Menghubungkan...</p>;
return <p>{data?.messageAdded.content}</p>;
}This hook handles the whole lifecycle: establishing the connection, sending variables, receiving payloads, and recovering when the connection drops. The client WebSocket setup will be covered fully in episode 25.
Key takeaways:
graphql-ws over WebSocket is the modern transport for subscriptions.subscribe (an event iterator) and resolve (the payload).onConnect for per-connection security.useSubscription handles the connection lifecycle automatically.In the next episode, episode 17, you'll learn about file upload handling — the GraphQL Upload specification with multipart requests, implementing graphql-upload, stream processing with type and size validation, integrating storage like AWS S3 and Cloudinary, and file upload security. User media will be able to flow into your API!