What this usually means
GraphQL subscriptions rely on a specific protocol over WebSocket—usually the `graphql-ws` or `subscriptions-transport-ws` protocol. When the connection opens but no data flows, the root cause is typically one of three categories: (1) the client and server are using incompatible WebSocket sub-protocols or negotiation fails silently, (2) the subscription resolver never publishes events because the underlying pub/sub system (e.g., Redis, in-memory) isn't correctly wired, or (3) the server is throwing an error inside the async iterator or event filter that is swallowed. Many engineers assume 'no error = no problem', but in subscriptions, silent failures are the norm. The WebSocket frame logs rarely show errors; they just show no `next` messages.
The first ten minutes — establish facts before touching code.
- 1Open browser DevTools Network tab, filter by WS, and inspect the frames. Confirm you see `subscribe` and `complete` messages, not just `connection_init`.
- 2On the server, add a simple `console.log` inside the subscription resolver's `subscribe` function to verify it's being called.
- 3Check the WebSocket sub-protocol handshake: client sends `Sec-WebSocket-Protocol: graphql-ws` or `graphql-transport-ws`? Server must respond with exactly the same.
- 4Test with a minimal subscription that returns a static value (e.g., `count: Int`) triggered by a mutation to isolate pub/sub issues.
- 5If using Apollo Client, enable `@apollo/client/link/ws` debug logging: `import { print } from 'graphql'; console.log(print(operation.query));` before subscription.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchServer logs for the WebSocket connection handler (e.g., `ws.on('connection')` in Node.js)
- searchApollo Server or Yoga subscription resolver definition (the `subscribe` function)
- searchBrowser DevTools > Network > WS frames (filter for 'receive' and 'send')
- searchPub/sub implementation: Redis channels or EventEmitter events (check if `publish` is actually called)
- searchClient subscription code: the exact `subscribe` call, especially the `query` document and variables
- searchServer-side subscription manager (e.g., `SubscriptionServer.create` options like `keepAlive`, `onConnect`)
Practical causes, not theory. These are the things you will actually find.
- warningWebSocket sub-protocol mismatch: client uses `graphql-ws` but server expects `subscriptions-transport-ws` (or vice versa)
- warningMissing `onSubscribe` payload: subscription query is sent as a string but server expects an AST or vice versa
- warningPub/sub channel name mismatch: resolver publishes to 'postAdded' but client subscribes to 'postAdded' — check for typos or extra whitespace
- warningSilent error in async iterator: filter function throws but the error is caught and ignored by the subscription manager
- warningServer behind a reverse proxy (nginx, Cloudflare) that doesn't support WebSocket upgrade or strips the sub-protocol header
- warningClient side: subscription observable not subscribed to (e.g., missing `.subscribe()` call)
Concrete fix directions. Pick the one that matches your root cause.
- buildNormalize WebSocket sub-protocol: use `graphql-transport-ws` for older Apollo, `graphql-ws` for newer graphql-ws library. Pick one and stick with it on both sides.
- buildAdd explicit error handling in async iterators: wrap filter/iterate in try-catch and log to stderr.
- buildUse a global pub/sub instance (e.g., Redis) with a consistent key naming convention—lowercase, no spaces.
- buildTest with a 'heartbeat' subscription: a simple timer that emits every 5 seconds to verify the base pipeline works.
- buildFor Apollo Server 4, ensure the `SubscriptionServer` is created with the correct `httpServer` and `path` that matches the client.
A fix you cannot prove is a guess. Close the loop.
- verifiedUse `wscat` (WebSocket client) to manually connect and send a subscription message: `wscat -c ws://localhost:4000/graphql -s graphql-ws` then send a JSON subscription payload.
- verifiedAdd a server-side event listener: `pubsub.ee.on('postAdded', (payload) => console.log('published', payload))` to confirm publish is called.
- verifiedRun a mutation that triggers the subscription and watch the server logs for the resolve call.
- verifiedIn browser DevTools, verify that after the mutation, a 'next' frame appears in the WS frames list (not just 'ka' keepalive).
Things that make this bug worse or harder to find.
- warningDon't assume the WebSocket connection is enough—many servers accept connections but fail on subscription messages.
- warningDon't silently catch errors in the subscription resolver; always log them to see if the async iterator throws.
- warningDon't use different pub/sub instances per server process (e.g., in-memory EventEmitter) when scaling horizontally; use Redis or another shared backend.
- warningDon't forget to call `subscription.unsubscribe()` in React's `useEffect` cleanup to avoid memory leaks and duplicate subscriptions.
Silent Subscription Failure After WebSocket Upgrade
Timeline
- 09:15Received alert: real-time dashboard not updating for 10+ minutes
- 09:18Checked WebSocket connection in browser DevTools: connection state is OPEN but no incoming frames besides keepalive
- 09:22Verified server logs: subscription resolver is called on connect, but no subsequent events after mutations
- 09:30Manually triggered a mutation via GraphiQL; subscription in GraphiQL works fine
- 09:35Noticed client uses `graphql-ws` but server's `SubscriptionServer` is configured for `subscriptions-transport-ws`
- 09:40Changed server sub-protocol to `graphql-ws` and restarted; still no events
- 09:45Added logging in subscription resolver's `subscribe` function—discovered the async iterator filter throws an error on `undefined` values
- 09:50Fixed filter to handle `null` payload; events start flowing immediately
We were rolling out a real-time dashboard feature using GraphQL subscriptions. The WebSocket connection established fine (status 101), but the dashboard never updated. The client subscription observable didn't emit any data, and there were no errors in the console. I started by checking the WebSocket frames in Chrome DevTools—only `connection_init` and `subscribe` were sent, but no `next` frames ever arrived. Server logs showed the subscription resolver was called on connection, but no subsequent events after a mutation.
I tested with GraphiQL: subscriptions worked perfectly. That told me the server-side pub/sub was fine, so the issue was in the client-server protocol or the client's subscription handling. I compared the WebSocket sub-protocol: client sent `graphql-ws`, but the server's `SubscriptionServer` was initialized with `subscriptions-transport-ws`. After aligning them, the problem persisted. I added verbose logging inside the subscription resolver's `subscribe` function—it turned out the async iterator's filter function was throwing an error when it received `undefined` from the pub/sub channel, which was silently swallowed by Apollo's subscription manager.
The root cause was a race condition: the mutation published a payload before the subscription's async iterator was ready, causing a `null` value. The filter function assumed the payload was always an object and crashed. The fix was to add a null-check in the filter and log any errors. After that, events flowed. The lesson: never assume errors in async iterators are surfaced—always wrap them in try-catch and log. Also, verify the sub-protocol early; it's a common mismatch.
Root cause
Sub-protocol mismatch between client and server, combined with a silent error in the subscription resolver's filter function that swallowed events.
The fix
Normalized WebSocket sub-protocol to `graphql-ws` on both sides, and added try-catch with logging in the async iterator filter to handle null payloads.
The lesson
Always test subscriptions with a simple heartbeat subscription first, and add explicit error logging in async iterators—silent failures are the norm.
GraphQL subscriptions over WebSocket require a specific sub-protocol to be negotiated during the HTTP upgrade. The two common protocols are `graphql-ws` (newer, from the `graphql-ws` library) and `subscriptions-transport-ws` (older, from Apollo). The client sends a `Sec-WebSocket-Protocol` header, and the server must respond with exactly the same string. If they don’t match, the server may still accept the connection but will silently reject subscription messages.
To debug this, capture the WebSocket handshake headers in the browser's Network tab. Look for `Sec-WebSocket-Protocol` in the request and response. If the response is missing or different, you have a mismatch. On the server side, verify the configuration: in `graphql-ws`, you pass the `ws` module directly; in Apollo Server 4, the `SubscriptionServer` constructor has an `onConnect` option but the sub-protocol is set implicitly. For a quick test, use `wscat -s graphql-ws` to force a protocol.
A common silent failure is when the resolver publishes to one channel name but the subscription listens to another. Even a trailing space or case difference breaks it. Always centralize channel names in constants. Also, race conditions can occur if the async iterator starts listening after the first event is published. Use a buffered pub/sub implementation (like Redis with a recent message history) or ensure the subscription is set up before the trigger.
To debug, add a global event listener on the pub/sub instance (e.g., `pubsub.ee.on('any', console.log)`) to see all events. If events are emitted but the iterator doesn't receive them, check the async generator's `next()` call—it may be stuck.
The subscription resolver returns an `AsyncIterator`. If the iterator's `next()` or filter throws, Apollo Server (or Yoga) often catches the error and logs it at debug level only, or silently ends the subscription. This means you see a successful subscription creation but no errors and no events. Always wrap the iterator logic in try-catch and use `console.error` to surface the error.
Add a simple timeout: if no event is received within 10 seconds, log a warning. This catches cases where the iterator is hanging. Also, ensure the iterator returns `{ value, done }` correctly—if `done` is `true`, the subscription ends.
On the client, the subscription observable must be subscribed to (`.subscribe()` or in React, `useSubscription`). If you use Apollo Client's `useSubscription` hook, it auto-subscribes, but if you use the raw `client.subscribe()`, you must call `.subscribe()` on the returned Observable. Also, the subscription query must be exactly the same as the server's schema—extra whitespace or different variable names can cause a no-match.
Use the Apollo Client devtools to inspect the active subscriptions. If no subscription appears, the query wasn't sent. Check the network tab for the 'subscribe' message on the WebSocket. If it's missing, the client link may not be configured correctly (e.g., missing `WebSocketLink` or wrong URI).
Frequently asked questions
Why does my GraphQL subscription work in GraphiQL but not in my React app?
GraphiQL often uses a different WebSocket client (e.g., `graphql-ws` vs `subscriptions-transport-ws`). Check the sub-protocol. Also, GraphiQL may send the subscription query as a string while your app sends an AST—ensure the server accepts both. Most likely, your React app's WebSocket link is using a different sub-protocol or has a missing header.
How do I debug WebSocket frames for GraphQL subscriptions?
In Chrome DevTools, go to Network tab, filter by 'WS', select the WebSocket connection, then click the 'Messages' tab. You'll see each frame. Look for 'connection_init', 'subscribe', 'next', and 'complete'. If you see 'subscribe' but never 'next', the server never sent data. If you never see 'subscribe', the client didn't send the subscription request.
What does 'WebSocket connection failed: Unexpected response code: 400' mean?
This usually means the server rejected the WebSocket upgrade. Common causes: the URL path is wrong (e.g., `/graphql` vs `/subscriptions`), or the server doesn't have a WebSocket upgrade handler. Also, reverse proxies (nginx, Cloudflare) may block WebSocket upgrades. Check the server logs for the upgrade attempt.
Can a firewall or proxy cause silent subscription failures?
Yes. Some proxies strip the `Sec-WebSocket-Protocol` header or close idle WebSocket connections without notice. If you see keepalive frames but no data, the proxy may be dropping messages. Use `wscat` from outside the proxy to compare. Also, ensure the proxy has a proper WebSocket timeout (e.g., 60 seconds for keepalive).
How do I test a GraphQL subscription without a full frontend?
Use a command-line tool like `wscat` with the appropriate sub-protocol. Install it via npm: `npm install -g wscat`. Then run: `wscat -c ws://localhost:4000/graphql -s graphql-ws`. Once connected, send a JSON message like: `{"type":"subscribe","id":"1","payload":{"query":"subscription { newMessage { text } }"}}`. The server should respond with `{"type":"next","id":"1","payload":{...}}`.