graphql-ws
graphql-ws is a reference implementation of the GraphQL over WebSocket Protocol, in both directions. On the server it takes an existing WebSocket server (ws, Bun, Deno, uWebSockets, crossws or @fastify/websocket) plus your GraphQL schema and handles the message framing: connection_init, subscribe, next, error, complete, ping and pong. On the client, createClient() gives you a lazy socket that connects on the first subscription, multiplexes every operation over that one connection, retries with backoff when the socket drops, and hands results back either through a sink callback or an async iterator. It carries no runtime dependencies of its own and works for queries and mutations too, not just subscriptions, though most people only route subscriptions through it. Crucially it speaks a different wire protocol from the older subscriptions-transport-ws, so both ends have to be on the same one.
If you are doing GraphQL subscriptions over WebSockets in 2026, this is the implementation, and the zero-dependency, adapter-per-runtime design has aged well. Check first whether SSE would do the job, because a WebSocket is infrastructure you have to operate.
Use it if
- You need GraphQL subscriptions and you are wiring the transport yourself rather than accepting whatever a framework bundles: this is the implementation that Apollo Server, Yoga, Mercurius and Hot Chocolate all document as the supported path
- You are migrating off subscriptions-transport-ws, which has been unmaintained for years and has a known connection-init timing bug that graphql-ws was written to fix
- You want one socket shared by every operation in a tab, with automatic retry and a ping/pong heartbeat, without writing that state machine yourself
- You run somewhere other than Node: there are first-party adapters for Bun, Deno, uWebSockets.js and crossws, so the same client works against all of them
- You only need server-to-client streaming and no bidirectional channel. Server-Sent Events over plain HTTP survives proxies, load balancers and corporate firewalls far better than WebSockets do, and the same author maintains graphql-sse with a near-identical API
- You already run Apollo Client or urql against an Apollo Server and just want subscriptions to work. Both have wrapper links that configure graphql-ws for you, and installing it directly means you own the connectionParams, retry and error plumbing yourself
- You need to interoperate with an old client you cannot change. The protocol is deliberately not cross-compatible with subscriptions-transport-ws, so a v0.9 Apollo Client will connect, fail the handshake, and reconnect forever
- Your deployment cannot hold long-lived connections. Serverless functions, most API gateways with short idle timeouts, and anything that scales to zero will fight this the whole way; you will end up running a separate always-on process just for the socket
- You want authentication solved for you. The library gives you connectionParams and onConnect and nothing else: token expiry mid-subscription, per-operation authorisation and reconnect with a fresh token are all yours to build
Setup reality
npm install graphql-ws installs nothing else, because every transport is an optional peer dependency: you also need ws ^8 for Node, or crossws ~0.3, or @fastify/websocket ^10 or ^11, and graphql ^15.10.1, ^16 or ^17 in all cases. Node 20 or newer is required by the engines field. The import paths changed in v6: graphql-ws/lib/use/ws became graphql-ws/use/ws, so every v5 tutorial you find online has a stale import. The package is ESM-first with CJS builds provided through the exports map, which means TypeScript will only resolve graphql-ws/use/ws if moduleResolution is set to bundler, node16 or nodenext; the older node setting reports the subpath as missing. Also plan for the HTTP upgrade: your WebSocket server attaches to the same http.Server as your GraphQL endpoint, which affects how you terminate the process, because ws does not close open sockets on server.close() and you have to dispose the graphql-ws handler explicitly.
Patterns
Attach the handler to a ws server in Nodeserver-with-ws
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/use/ws';
import { schema } from './schema.js';
const wsServer = new WebSocketServer({
server: httpServer, // reuse the HTTP server
path: '/graphql',
});
const disposable = useServer({ schema }, wsServer);The import is graphql-ws/use/ws with no /lib/ segment; that changed in v6 and most tutorials still show the old path. Reusing the HTTP server means the upgrade happens on the same port and origin as your queries, which keeps cookies and CORS behaviour consistent.
Subscribe from the browserclient-subscribe
import { createClient } from 'graphql-ws';
const client = createClient({ url: 'wss://api.example.com/graphql' });
const unsubscribe = client.subscribe(
{ query: 'subscription { ticker { symbol price } }' },
{
next: (result) => console.log(result.data?.ticker),
error: (err) => console.error(err),
complete: () => console.log('stream ended'),
},
);
// later
unsubscribe();The client is lazy by default: no socket opens until this first subscribe, and it closes again after the last unsubscribe. Every operation from this client shares one socket, so do not create a client per component.
Consume a subscription with for awaitclient-async-iterator
const results = client.iterate({
query: 'subscription OnComment($post: ID!) { commentAdded(post: $post) { id body } }',
variables: { post: postId },
});
for await (const result of results) {
if (result.errors) break;
render(result.data.commentAdded);
}
// breaking out of the loop, or calling results.return(), unsubscribesiterate() buffers results that arrive while your loop body is awaiting something slow, so a slow consumer grows memory rather than applying backpressure. Always exit the loop or call return() in a finally block, otherwise the operation stays open on the server.
Send a token on connect and check it on the serverauth-connection-params
// client
const client = createClient({
url: 'wss://api.example.com/graphql',
connectionParams: async () => ({ authToken: await getFreshToken() }),
});
// server
useServer(
{
schema,
onConnect: async (ctx) => {
const token = ctx.connectionParams?.authToken;
const user = token ? await verify(String(token)) : null;
if (!user) return false; // closes with 4403 Forbidden
ctx.extra.user = user;
},
context: (ctx) => ({ user: ctx.extra.user }),
},
wsServer,
);connectionParams accepts a function, so the token is read at connect time and again on every reconnect rather than being captured once at client creation. Returning false from onConnect closes the socket; throwing closes it too but with a different code. There is no built-in re-authentication, so a token that expires mid-subscription stays valid until the socket drops.
Route subscriptions through graphql-ws in Apollo Clientapollo-client-link
import { split, HttpLink } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
const wsLink = new GraphQLWsLink(createClient({ url: 'wss://api.example.com/graphql' }));
const httpLink = new HttpLink({ uri: 'https://api.example.com/graphql' });
const link = split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === 'OperationDefinition' && def.operation === 'subscription';
},
wsLink,
httpLink,
);Without the split, every query and mutation also goes over the socket, which works but loses HTTP caching and makes a dropped connection break your whole app instead of just live updates. GraphQLWsLink is the current link; SubscriptionClient-based links target the old protocol and will not handshake.
Tune reconnection and keep-aliveretry-and-heartbeat
const client = createClient({
url: 'wss://api.example.com/graphql',
keepAlive: 10_000, // client pings every 10s
retryAttempts: 10,
shouldRetry: () => true, // retry on any close, not just abnormal ones
retryWait: async (retries) => {
const base = Math.min(1000 * 2 ** retries, 30_000);
await new Promise((r) => setTimeout(r, base + Math.random() * 1000));
},
on: {
connected: () => console.info('ws up'),
closed: (e) => console.warn('ws down', e),
},
});shouldRetry defaults to retrying only on close codes the library considers non-fatal, so an application-level 4403 will not be retried and your app goes silent. The jitter in retryWait is not optional in production: without it, every client reconnects in the same millisecond after a deploy.
Observe and reject operationsserver-lifecycle-hooks
useServer(
{
schema,
onSubscribe: async (ctx, id, payload) => {
if (!ctx.extra.user) {
return [new GraphQLError('Unauthorized')]; // errors, not a stream
}
metrics.increment('gql.subscribe', { op: payload.operationName });
},
onNext: (_ctx, _id, _msg, _args, result) => {
// last chance to redact fields before they go out
return result;
},
onError: (_ctx, _id, _msg, errors) => {
errors.forEach((e) => logger.error(e));
},
onComplete: (_ctx, id) => metrics.decrement('gql.active', { id }),
},
wsServer,
);Returning an array of GraphQLError from onSubscribe sends an error message and terminates that operation without touching the socket. Since 6.0.8 the server correctly sends only the error message and no trailing complete, matching the protocol; clients that assumed a complete always follows need checking.
Write the subscribe resolver the transport expectsasync-generator-resolver
const resolvers = {
Subscription: {
commentAdded: {
subscribe: async function* (_root, { post }, ctx) {
const stream = ctx.pubsub.subscribe(`comments:${post}`);
try {
for await (const comment of stream) {
yield { commentAdded: comment };
}
} finally {
stream.return?.();
}
},
},
},
};The yielded object must be keyed by the field name, otherwise the client receives null with no error. The finally block is what releases your pubsub subscription when the socket drops; leave it out and a client that closes its laptop lid leaks a listener per subscription.
Serve over @fastify/websocketfastify-adapter
import fastifyWebsocket from '@fastify/websocket';
import { makeHandler } from 'graphql-ws/use/@fastify/websocket';
await app.register(fastifyWebsocket);
app.get('/graphql', { websocket: true }, makeHandler({
schema,
context: (ctx) => ({ socket: ctx.extra.socket }),
}));v6 renamed ctx.extra.connection to ctx.extra.socket for this adapter, and dropped the deprecated fastify-websocket package entirely. Peer range is @fastify/websocket ^10 or ^11.
Close subscriptions on SIGTERMgraceful-shutdown
const disposable = useServer({ schema }, wsServer);
process.on('SIGTERM', async () => {
await disposable.dispose(); // sends complete, closes each socket
await new Promise((res) => wsServer.close(res));
await new Promise((res) => httpServer.close(res));
process.exit(0);
});wsServer.close() stops accepting upgrades but does not close established sockets, so skipping dispose() means your pod hangs until the orchestrator kills it. Disposing first also lets clients see a clean close and reconnect to a healthy instance instead of timing out.
Exercise the handler in an integration testtest-against-a-real-socket
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/use/ws';
import { createClient } from 'graphql-ws';
import WebSocket from 'ws';
const wsServer = new WebSocketServer({ port: 0 });
const disposable = useServer({ schema }, wsServer);
const { port } = wsServer.address();
const client = createClient({
url: `ws://localhost:${port}`,
webSocketImpl: WebSocket, // Node has no global WebSocket before v22
retryAttempts: 0,
});
const results = [];
for await (const r of client.iterate({ query: 'subscription { tick }' })) {
results.push(r);
if (results.length === 3) break;
}
await client.dispose();
await disposable.dispose();Port 0 lets the OS pick a free port so tests can run in parallel. Set retryAttempts to 0 in tests, otherwise a deliberately failing case reconnects in the background and the test runner never exits.
Cache or restrict GraphQL parsingcustom-parse
import { parse } from 'graphql';
const cache = new Map();
useServer(
{
schema,
parse: (source) => {
const key = typeof source === 'string' ? source : source.body;
let doc = cache.get(key);
if (!doc) { doc = parse(source); cache.set(key, doc); }
return doc;
},
},
wsServer,
);The parse option arrived in 6.2.0. An unbounded Map here is a memory leak if clients can send arbitrary documents; cap it, or key off persisted query ids instead and reject anything not in your allowlist.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| graphql-sse | npm | You only stream server to client and would rather use plain HTTP that proxies and CDNs already understand |
| graphql-yoga | npm | You want a GraphQL server that already ships and configures the WebSocket handler instead of assembling one |
| subscriptions-transport-ws | npm | Only when you must keep talking to a legacy Apollo client that cannot be upgraded; it is unmaintained and should be treated as a compatibility shim |