graphql-ws review
graphql-ws 6.2.1 implements the GraphQL over WebSocket protocol for clients and servers. Its client opens a lazy socket, multiplexes operations, handles protocol messages, retries connections, and exposes sink callbacks or an async iterator. Server adapters connect GraphQL execution to `ws`, Fastify, crossws, Bun, Deno, and other runtimes. It is transport code, not a pub/sub system or authentication product. Version 6.2 added a custom `parse` hook, and 6.2.1 fixes WebSocketServer typings. The wire format is intentionally incompatible with the retired `subscriptions-transport-ws` protocol.
graphql-ws 6.2.1 is the focused choice when you have committed to GraphQL subscriptions over the current WebSocket protocol. Use SSE for one-way streams, and budget real work for auth expiry, reconnects, proxy timeouts, observability, and graceful shutdown.
We installed it
| Install | ✓ · 2.5s | 2 packages on disk · 12 MB |
| Import | ✓ | ESM import works · require() works · ESM package with exports map |
| Browser | 46.2 KB | gzipped (176.6 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does graphql-ws install cleanly?
Yes. In a fresh container with an empty cache, npm install graphql-ws finished in 3 seconds, leaving 2 packages and 12 MB on disk. npm audit reported no known vulnerabilities.
How much does graphql-ws add to a browser bundle?
46.2 KB gzipped (176.6 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does graphql-ws work with both ESM and CommonJS?
Yes. Both import 'graphql-ws' and require('graphql-ws') worked in Node 22 in our run. The package is published as ESM with an exports map.
Does graphql-ws include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
graphql-ws or graphql-sse: which should you use?
graphql-sse: Use it for GraphQL streaming over HTTP when bidirectional WebSocket messages are unnecessary. graphql-ws 6.2.1 is the focused choice when you have committed to GraphQL subscriptions over the current WebSocket protocol.
When should you not use graphql-ws?
Updates only travel from server to client. graphql-sse uses HTTP and avoids WebSocket upgrade, idle-timeout, and connection-draining work.
Use it if
- A GraphQL API needs subscriptions over WebSockets and both endpoints can speak the `graphql-transport-ws` subprotocol.
- One client socket should carry several live operations with connection events, retry policy, ping/pong handling, and explicit disposal.
- You already have a GraphQL schema and a supported WebSocket server, and want transport adapters instead of another application framework.
- The same client API must work across browser, Node, Bun, Deno, Fastify, crossws, or uWebSockets deployments.
- Updates only travel from server to client. `graphql-sse` uses HTTP and avoids WebSocket upgrade, idle-timeout, and connection-draining work.
- A legacy endpoint or client only speaks the `subscriptions-transport-ws` protocol. The README says the two protocols cannot interoperate.
- Your platform cannot retain long-lived connections or route repeat connections from one client predictably. The library does not change gateway or serverless limits.
- You expect token rotation and per-operation authorization to arrive prebuilt. `connectionParams` and lifecycle hooks expose the points, but application code owns policy and refresh behavior.
- Node 18 must remain in production. Package metadata for 6.2.1 requires Node 20 or newer, and the current peer set expects supported GraphQL and transport packages.
Setup reality
We installed graphql-ws 6.2.1 in a fresh unprivileged Node 22 Bookworm container. npm completed in 2.5 seconds, leaving 2 packages and 12 MB on disk. The package has 0 direct dependencies, 4 peer dependencies, and 368 KB unpacked. npm audit reported 0 known vulnerabilities at every severity. It includes TypeScript declarations and requires Node 20 or newer.
The package is ESM with an exports map; both require() and ESM import worked in our checks. Server use still needs the appropriate peer, such as GraphQL plus ws 8 or @fastify/websocket. Version 6 removed /lib/ from adapter subpaths, so import graphql-ws/use/ws. TypeScript should use an exports-aware module resolution mode. A browser build importing the whole package measured 176.6 KB minified and 46.2 KB gzipped, so import the client entry you need and keep server adapters out of frontend graphs.
Authentication normally starts in an async connectionParams function and is checked by onConnect or onSubscribe. Read the token on each connection so retries can pick up a refresh. The server must still enforce authorization for each operation. Connection authentication can become stale during a long subscription; graphql-ws does not renegotiate a token in place.
WebSocket operations outlive individual HTTP requests. Add heartbeat policy, connection limits, metrics, and a shutdown path that disposes the graphql-ws handler before closing the transport and HTTP server. Client retries need jitter to avoid a reconnect wave. Async-iterator consumers must exit or call return(), and subscription resolvers should release pub/sub listeners in finally when a socket disappears.
Patterns
Attach GraphQL to a ws server serve-with-ws
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/use/ws';
const sockets = new WebSocketServer({ server: httpServer, path: '/graphql' });
const serverCleanup = useServer({ schema }, sockets);Version 6 uses graphql-ws/use/ws without /lib/. Install ws and graphql as peers.
Open a browser subscription subscribe-from-browser
import { createClient } from 'graphql-ws';
const client = createClient({ url: 'wss://api.example.com/graphql' });
const stop = client.subscribe(
{ query: 'subscription { priceChanged { sku price } }' },
{ next: console.log, error: console.error, complete: () => {} },
);The default client is lazy. Reuse one client per endpoint rather than opening a socket in every component.
Consume results with for await iterate-results
const stream = client.iterate({
query: 'subscription($id: ID!) { commentAdded(postId: $id) { id body } }',
variables: { id: postId },
});
try {
for await (const result of stream) render(result.data?.commentAdded);
} finally {
await stream.return?.();
}The iterator can buffer while the consumer is busy. Always finish or return it so the server receives a completion message.
Read a fresh token on each connection authenticate-connection
const client = createClient({
url,
connectionParams: async () => ({ authorization: `Bearer ${await getToken()}` }),
});
useServer({
schema,
onConnect: async (ctx) => Boolean(await verify(ctx.connectionParams?.authorization)),
}, sockets);A reconnect calls connectionParams again. An already-open socket does not refresh credentials by itself.
Reject an unauthorized subscription authorize-operation
useServer({
schema,
onSubscribe: async (ctx, id, payload) => {
if (!canSubscribe(ctx.extra.user, payload)) {
return [new GraphQLError('Forbidden')];
}
},
}, sockets);Returning GraphQLError objects ends that operation without necessarily closing every operation on the socket.
Add bounded reconnect backoff configure-retries
const client = createClient({
url,
retryAttempts: 8,
retryWait: async (attempt) => {
const delay = Math.min(500 * 2 ** attempt, 15_000) + Math.random() * 500;
await new Promise((resolve) => setTimeout(resolve, delay));
},
});Keep jitter. Identical delays make every browser reconnect together after a deployment or regional outage.
Monitor protocol ping and pong events send-heartbeats
const client = createClient({
url,
keepAlive: 12_000,
on: {
ping: (received) => metrics.count(received ? 'ping.in' : 'ping.out'),
pong: (received) => metrics.count(received ? 'pong.in' : 'pong.out'),
},
});Protocol heartbeats do not override shorter idle limits in a proxy unless their interval stays below that limit.
Clean up an async generator resolver write-subscription-resolver
async function* subscribe(_root, { room }, ctx) {
const events = ctx.pubsub.subscribe(`room:${room}`);
try {
for await (const message of events) yield { messageAdded: message };
} finally {
await events.return?.();
}
}Yield an object keyed by the GraphQL subscription field. finally releases the source after unsubscribe or disconnect.
Send only subscriptions through Apollo's WS link route-apollo-operations
const wsLink = new GraphQLWsLink(createClient({ url: wsUrl }));
const link = split(
({ query }) => {
const operation = getMainDefinition(query);
return operation.kind === 'OperationDefinition' && operation.operation === 'subscription';
},
wsLink,
httpLink,
);GraphQLWsLink targets this protocol. Older SubscriptionClient links target subscriptions-transport-ws and cannot handshake here.
Use the 6.2 parser hook customize-parser
useServer({
schema,
parse: (source) => {
const document = persistedDocuments.get(String(source));
if (!document) throw new GraphQLError('Unknown document');
return document;
},
}, sockets);Do not put arbitrary query strings in an unbounded cache. A persisted-document allowlist gives the hook a finite input set.
Count active subscription operations observe-lifecycle
useServer({
schema,
onOperation: (_ctx, id) => metrics.gauge('subscriptions', 1, { id }),
onComplete: (_ctx, id) => metrics.gauge('subscriptions', -1, { id }),
onError: (_ctx, id, _payload, errors) => logger.warn({ id, errors }),
}, sockets);Track connections and operations separately because one socket can carry many active subscriptions.
Drain sockets during shutdown shutdown-server
process.on('SIGTERM', async () => {
await serverCleanup.dispose();
await new Promise((resolve) => sockets.close(resolve));
await new Promise((resolve) => httpServer.close(resolve));
});Stopping HTTP upgrades alone leaves established sockets alive. Dispose the GraphQL handler before closing both servers.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| graphql-sse | npm | Use it for GraphQL streaming over HTTP when bidirectional WebSocket messages are unnecessary. |
| graphql-yoga | npm | Use it when you want a GraphQL server with transport integration and request handling assembled for you. |
| subscriptions-transport-ws | npm | Keep it only as a legacy compatibility dependency for an endpoint that cannot move to the current protocol. |
More web backend guides
urllib3 · requests · ws · anyio · httpx · undici · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

