mrkeyoor.com_
Thu 06 Aug 15:42 UTC
npmWeb Backendupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5createClient and useServer have kept the same shape since v5 in 2021. The v6 break in January 2025 was packaging and peers rather than semantics: drop the /lib/ path segment, drop ws v7 and fastify-websocket, rename ctx.extra.connection to ctx.extra.socket for @fastify/websocket. Real migrations are a find and replace.
Docs4/5the-guild.dev/graphql/ws has a get-started guide, a recipes page covering auth, context, error handling and each server adapter, plus TypeDoc for every option. The PROTOCOL.md is genuinely good and worth reading. What is thin is operations: connection limits, load balancer configuration and token refresh during a live subscription are left to you.
Maintenance5/56.2.1 published 6 August 2026 with the repo pushed the same day, graphql 17 support added a month earlier, and 9 open items on GitHub of which 7 are issues. Changesets-based releases with a written explanation per change, and The Guild backs it.
Ecosystem5/5Around 12M weekly downloads and the documented transport for Apollo Server, Apollo Client, urql, Relay, graphql-yoga and Mercurius. Adapters exist for ws, Bun, Deno, uWebSockets.js, crossws and @fastify/websocket, so the client stays the same across runtimes.

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
Skip it if

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(), unsubscribes

iterate() 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

PackageRegistryPick it when
graphql-ssenpmYou only stream server to client and would rather use plain HTTP that proxies and CDNs already understand
graphql-yoganpmYou want a GraphQL server that already ships and configures the WebSocket handler instead of assembling one
subscriptions-transport-wsnpmOnly 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