Stubbing GraphQL Subscriptions Locally

Your UI subscribes to onOrderStatusChanged and expects a stream of updates to arrive over a WebSocket, but there is no backend pushing them. Queries and mutations are easy to mock because they are plain HTTP; subscriptions are not, and the usual MSW graphql handlers do not touch them. This page shows two ways to feed a subscription locally: a tiny standalone graphql-ws server that pushes a scripted sequence of events, and MSW’s ws handler for browser-only interception.

Context: why the HTTP handlers do not apply

A GraphQL subscription is a long-lived stream. Clients open a WebSocket, speak the graphql-transport-ws sub-protocol over it, send a subscribe message, and then receive any number of next messages until a complete message ends the stream. None of that is an HTTP request-response, so the graphql.query and graphql.mutation handlers from the GraphQL mocking setup never see it. MSW’s Node interceptor does not patch WebSocket upgrades either, which is why subscription mocking needs a different tool.

Two approaches work locally:

  • A standalone graphql-ws server — the most faithful option. It speaks the real protocol, so any client (Apollo Client, urql, a raw graphql-ws client) connects unchanged. This is the recommended path for both development and Node-based tests.
  • MSW’s ws() handler — intercepts the WebSocket in the browser only, useful when you already run MSW for HTTP and want subscriptions handled in the same layer for Playwright or in-browser tests.

Solution A: a local graphql-ws server

Install the server dependencies:

npm install --save-dev graphql-ws ws graphql

Create the mock subscription server at mocks/subscription-server.ts. It defines a schema with a subscription field whose resolver is an async generator, then serves it over ws on port 4000.

// mocks/subscription-server.ts
import { WebSocketServer } from 'ws'
import { useServer } from 'graphql-ws/lib/use/ws'
import { makeExecutableSchema } from '@graphql-tools/schema'

const typeDefs = /* GraphQL */ `
  type OrderStatus {
    orderId: ID!
    status: String!
    updatedAt: String!
  }

  type Query {
    _empty: String
  }

  type Subscription {
    onOrderStatusChanged(orderId: ID!): OrderStatus!
  }
`

// A deterministic, ordered sequence of statuses pushed one per interval.
async function* orderStatusStream(orderId: string) {
  const statuses = ['PLACED', 'CONFIRMED', 'PACKED', 'SHIPPED', 'DELIVERED']
  for (const status of statuses) {
    await new Promise((resolve) => setTimeout(resolve, 800))
    yield {
      onOrderStatusChanged: {
        orderId,
        status,
        updatedAt: new Date().toISOString(),
      },
    }
  }
}

const schema = makeExecutableSchema({
  typeDefs,
  resolvers: {
    Query: { _empty: () => null },
    Subscription: {
      onOrderStatusChanged: {
        subscribe: (_root: unknown, args: { orderId: string }) =>
          orderStatusStream(args.orderId),
      },
    },
  },
})

const port = Number(process.env.MOCK_WS_PORT ?? 4000)
const wsServer = new WebSocketServer({ port, path: '/graphql' })

useServer({ schema }, wsServer)

// eslint-disable-next-line no-console
console.log(`Mock GraphQL subscription server listening on ws://localhost:${port}/graphql`)

// Clean shutdown for test teardown.
export function closeSubscriptionServer(): Promise<void> {
  return new Promise((resolve) => wsServer.close(() => resolve()))
}

Start it alongside your dev server with a script:

{
  "scripts": {
    "mock:subs": "tsx mocks/subscription-server.ts"
  }
}

Connecting the client

Point the WebSocket link at the local server. With Apollo Client, that is a GraphQLWsLink; the plain client below is enough to prove the stream:

// mocks/subscribe-demo.ts
import { createClient } from 'graphql-ws'

const client = createClient({
  url: process.env.MOCK_WS_URL ?? 'ws://localhost:4000/graphql',
})

const SUBSCRIPTION = /* GraphQL */ `
  subscription OnOrderStatusChanged($orderId: ID!) {
    onOrderStatusChanged(orderId: $orderId) {
      orderId
      status
      updatedAt
    }
  }
`

async function run(): Promise<void> {
  const iterator = client.iterate({
    query: SUBSCRIPTION,
    variables: { orderId: 'o_42' },
  })

  for await (const event of iterator) {
    // eslint-disable-next-line no-console
    console.log('event:', JSON.stringify(event.data))
  }

  // eslint-disable-next-line no-console
  console.log('stream complete')
}

run().catch((err) => {
  // eslint-disable-next-line no-console
  console.error('subscription error:', err)
  process.exit(1)
})

In a React app you would use the same ws://localhost:4000/graphql URL inside a GraphQLWsLink and split it from your HTTP link so queries and mutations continue to flow through the MSW-mocked HTTP endpoint while subscriptions hit the local socket. That split keeps this approach compatible with the request interception patterns MSW already handles for HTTP.

Solution B: MSW’s ws handler (browser)

If you run tests in a real browser (Playwright, or a jsdom setup that supports WebSocket interception), MSW 2.x can intercept the socket directly, keeping subscription mocks in the same handler file as your HTTP mocks. The ws link is created once and reused.

// src/mocks/subscription-ws-handler.ts
import { ws } from 'msw'

const orders = ws.link('ws://localhost:4000/graphql')

export const subscriptionHandlers = [
  orders.addEventListener('connection', ({ client }) => {
    client.addEventListener('message', (event) => {
      const message = JSON.parse(event.data as string)

      // The graphql-transport-ws handshake: ack the connection_init.
      if (message.type === 'connection_init') {
        client.send(JSON.stringify({ type: 'connection_ack' }))
        return
      }

      // On subscribe, push a scripted sequence of `next` frames, then `complete`.
      if (message.type === 'subscribe') {
        const id = message.id
        const statuses = ['PLACED', 'CONFIRMED', 'SHIPPED', 'DELIVERED']

        statuses.forEach((status, index) => {
          setTimeout(() => {
            client.send(
              JSON.stringify({
                id,
                type: 'next',
                payload: {
                  data: {
                    onOrderStatusChanged: {
                      orderId: 'o_42',
                      status,
                      updatedAt: new Date().toISOString(),
                    },
                  },
                },
              }),
            )
            if (index === statuses.length - 1) {
              client.send(JSON.stringify({ id, type: 'complete' }))
            }
          }, (index + 1) * 500)
        })
      }
    })
  }),
]

Add subscriptionHandlers to the array you pass to setupWorker, exactly as the GraphQL mocking setup combines handler lists. Because this speaks the raw protocol frames, it is more code than Solution A — prefer the standalone server unless you specifically need everything mocked inside the browser worker.

Verification

With npm run mock:subs running, connect the demo client and watch ordered events arrive:

npx tsx mocks/subscribe-demo.ts

Expected output — five events, in order, followed by a clean completion:

event: {"onOrderStatusChanged":{"orderId":"o_42","status":"PLACED","updatedAt":"2026-07-05T09:00:00.800Z"}}
event: {"onOrderStatusChanged":{"orderId":"o_42","status":"CONFIRMED","updatedAt":"2026-07-05T09:00:01.600Z"}}
event: {"onOrderStatusChanged":{"orderId":"o_42","status":"PACKED","updatedAt":"2026-07-05T09:00:02.400Z"}}
event: {"onOrderStatusChanged":{"orderId":"o_42","status":"SHIPPED","updatedAt":"2026-07-05T09:00:03.200Z"}}
event: {"onOrderStatusChanged":{"orderId":"o_42","status":"DELIVERED","updatedAt":"2026-07-05T09:00:04.000Z"}}
stream complete

For a quick protocol-level check without writing a client, wscat confirms the handshake and first next frame:

npx wscat -c ws://localhost:4000/graphql -s graphql-transport-ws
# > {"type":"connection_init"}
# < {"type":"connection_ack"}
# > {"id":"1","type":"subscribe","payload":{"query":"subscription OnOrderStatusChanged($orderId:ID!){ onOrderStatusChanged(orderId:$orderId){ status } }","variables":{"orderId":"o_42"}}}
# < {"id":"1","type":"next","payload":{"data":{"onOrderStatusChanged":{"status":"PLACED"}}}}

Gotchas and edge cases

  • The socket is never intercepted. If events never arrive, confirm the client URL scheme is ws:// (or wss://) and the path matches the server’s path: '/graphql'. A client pointed at http://localhost:4000 silently fails to upgrade. Remember that MSW’s Node interceptor does not touch WebSockets at all — a Node-based test must connect to the real graphql-ws server from Solution A, not rely on setupServer.

  • Keepalive frames close the stream if mishandled. The graphql-ws protocol exchanges ping/pong frames to detect dead connections. The graphql-ws server library answers them for you, so do not intercept or drop them. On the raw ws handler in Solution B, ignore any ping message rather than treating it as a subscribe, or the client’s keepalive will be misread.

  • Event ordering is not guaranteed by fire-and-forget timers. Scheduling every payload with an independent setTimeout at the same delay can interleave unpredictably under load. Drive the sequence from a single async generator (Solution A) or from incrementing delays (Solution B) so next frames arrive strictly in order. For state that must survive across reconnects, model it explicitly rather than relying on timer coincidence — the same discipline described in advanced MSW handler patterns for stateful HTTP handlers.


FAQ

Why does MSW not intercept my GraphQL subscription?

MSW’s graphql.query and graphql.mutation handlers only match HTTP operations. Subscriptions run over a WebSocket, so they need either MSW’s separate ws() handler in the browser or a small standalone graphql-ws server. The Node interceptor does not patch WebSocket upgrades at all, which is why a Node-based test must connect to the real server from Solution A rather than relying on setupServer.

How do I keep a mocked subscription alive without the server closing it?

graphql-ws sends protocol-level ping and pong frames to keep the connection healthy, and the server library handles them automatically. Do not return or complete the async generator until you actually want the stream to end, and avoid throwing inside it, which sends an error frame and closes the subscription. On the raw ws handler, ignore incoming ping messages rather than misreading them as a subscribe.


← Back to GraphQL Mocking Setup