Skip to content

Fix: GraphQL Subscription Not Updating — WebSocket Connection Not Receiving Events

FixDevs ·

Quick Answer

How to fix GraphQL subscriptions not receiving updates — WebSocket setup, subscription protocol, Apollo Client config, server-side pub/sub, authentication over WebSocket, and reconnection.

The Problem

A GraphQL subscription connects but never receives events:

// Apollo Client subscription
const { data, loading, error } = useSubscription(MESSAGES_SUBSCRIPTION);
// loading stays true forever — no data arrives
// No error shown

Or the subscription throws a connection error:

WebSocket connection to 'ws://localhost:4000/graphql' failed:
Error during WebSocket handshake: Unexpected response code: 400

Or subscriptions work in development but fail in production behind a load balancer:

Error: Subscription transport failed
// Works on direct connection — fails when going through nginx/ALB

Or the subscription receives the first event but stops updating:

// First message arrives — then nothing
// Server is publishing events (confirmed via logs)
// But client doesn't receive them after the first

Why This Happens

GraphQL subscriptions use WebSockets (or SSE) rather than HTTP. This adds several layers of potential failure:

  • Protocol mismatch — Apollo Client 3 uses the graphql-ws protocol by default, but many servers still implement the older subscriptions-transport-ws protocol. They’re incompatible.
  • WebSocket not configured on server — express/Fastify/Koa HTTP servers need separate WebSocket server setup. The regular HTTP route handler doesn’t handle WebSocket upgrades.
  • Pub/sub not connected to resolver — the subscription resolver’s subscribe function must return an AsyncIterator. If the pub/sub system isn’t properly set up, subscriptions silently receive nothing.
  • Authentication not passing over WebSocket — HTTP cookies and Authorization headers work differently over WebSocket connections. Tokens sent in HTTP headers aren’t automatically available in the WS handshake.
  • Load balancer/proxy not forwarding upgrades — nginx, AWS ALB, and Cloudflare need explicit configuration to forward WebSocket Upgrade requests.
  • Single-process pub/sub in multi-instance deployment — if you use in-memory pub/sub (like PubSub from graphql-subscriptions), events published on one server instance aren’t received by subscriptions on other instances.

Fix 1: Match Client and Server Protocols

The two main GraphQL WebSocket protocols are incompatible with each other:

ProtocolPackageStatus
graphql-transport-wsgraphql-wsModern — use this
graphql-ws (old naming)subscriptions-transport-wsLegacy — being deprecated

Server — graphql-ws (modern protocol):

// server.js — Apollo Server 4 + graphql-ws
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';

const app = express();
const httpServer = http.createServer(app);

const schema = makeExecutableSchema({ typeDefs, resolvers });

// WebSocket server — graphql-ws protocol
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});

// Attach graphql-ws to the WebSocket server
const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      // WebSocket context — extract auth from connectionParams
      const token = ctx.connectionParams?.Authorization;
      const user = token ? await verifyToken(token) : null;
      return { user };
    },
  },
  wsServer,
);

const apolloServer = new ApolloServer({
  schema,
  plugins: [
    ApolloServerPluginDrainHttpServer({ httpServer }),
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});

await apolloServer.start();
app.use('/graphql', expressMiddleware(apolloServer, {
  context: async ({ req }) => ({ user: req.user }),
}));

await new Promise(resolve => httpServer.listen(4000, resolve));

Client — Apollo Client with graphql-ws:

// apollo-client.js
import { ApolloClient, InMemoryCache, HttpLink, split } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';

// WebSocket link — graphql-ws protocol
const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:4000/graphql',
    connectionParams: () => ({
      Authorization: `Bearer ${localStorage.getItem('token')}`,
    }),
    retryAttempts: 5,
    on: {
      connected: () => console.log('WS connected'),
      closed: () => console.log('WS closed'),
      error: (err) => console.error('WS error', err),
    },
  }),
);

// HTTP link for queries and mutations
const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' });

// Route subscriptions through WS, everything else through HTTP
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  httpLink,
);

export const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});

Fix 2: Implement Subscription Resolvers Correctly

The server-side resolver needs both subscribe and resolve functions:

// resolvers.js
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

// Trigger names — use constants to avoid typos
const MESSAGE_ADDED = 'MESSAGE_ADDED';
const USER_UPDATED = 'USER_UPDATED';

const resolvers = {
  Mutation: {
    sendMessage: async (_, { content, roomId }, { user }) => {
      const message = await db.createMessage({ content, roomId, userId: user.id });

      // Publish event after creating the message
      pubsub.publish(MESSAGE_ADDED, {
        messageAdded: message,   // Object shape must match Subscription type
        roomId,                  // Additional filter data
      });

      return message;
    },
  },

  Subscription: {
    messageAdded: {
      // subscribe — returns AsyncIterator of events
      subscribe: (_, { roomId }) =>
        pubsub.asyncIterator([MESSAGE_ADDED]),

      // resolve — transforms the event payload
      resolve: (payload) => payload.messageAdded,
    },

    // With filtering — only send to subscribers of the matching room
    messageAddedFiltered: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([MESSAGE_ADDED]),
        (payload, variables) => {
          // Return true if this subscriber should receive the event
          return payload.roomId === variables.roomId;
        },
      ),
      resolve: (payload) => payload.messageAdded,
    },
  },
};

Type definitions:

type Message {
  id: ID!
  content: String!
  roomId: ID!
  user: User!
  createdAt: String!
}

type Subscription {
  messageAdded(roomId: ID!): Message!
  userUpdated(userId: ID!): User!
}

Fix 3: Use Redis Pub/Sub for Multi-Instance Deployments

In-memory PubSub doesn’t work when running multiple server instances. Use Redis instead:

npm install graphql-redis-subscriptions ioredis
// pubsub.js — Redis-backed pub/sub
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';

const options = {
  host: process.env.REDIS_HOST,
  port: parseInt(process.env.REDIS_PORT || '6379'),
  retryStrategy: (times) => Math.min(times * 50, 2000),
};

export const pubsub = new RedisPubSub({
  publisher: new Redis(options),
  subscriber: new Redis(options),  // Separate Redis connections for pub and sub
});

// Usage is identical to the in-memory PubSub
pubsub.publish('MESSAGE_ADDED', { messageAdded: message });
pubsub.asyncIterator(['MESSAGE_ADDED']);

Note: Redis pub/sub delivers messages to all connected subscribers across instances. All server instances receive the event and forward it to their connected WebSocket clients.

Fix 4: Configure nginx for WebSocket Proxying

nginx needs explicit WebSocket upgrade headers to proxy WebSocket connections:

# /etc/nginx/sites-available/myapp
upstream api_servers {
    server app1:4000;
    server app2:4000;
    # Use ip_hash or sticky sessions for WebSocket — connections are stateful
    ip_hash;
}

server {
    listen 443 ssl;
    server_name api.example.com;

    # HTTP requests — queries and mutations
    location /graphql {
        proxy_pass http://api_servers;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # WebSocket upgrade headers — REQUIRED for subscriptions
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # Increase timeouts — WebSocket connections are long-lived
        proxy_read_timeout 86400s;   # 24 hours
        proxy_send_timeout 86400s;
        keepalive_timeout 86400s;
    }
}

AWS ALB — enable sticky sessions for WebSocket:

WebSocket connections must reach the same backend instance throughout their lifetime. With multiple server instances, configure ALB sticky sessions:

# Terraform — ALB target group with sticky sessions
resource "aws_lb_target_group" "api" {
  name     = "api-tg"
  port     = 4000
  protocol = "HTTP"

  stickiness {
    type            = "lb_cookie"
    cookie_duration = 86400   # 24 hours
    enabled         = true
  }
}

Fix 5: Handle Authentication Over WebSocket

HTTP Authorization headers don’t carry over to WebSocket connections automatically. Pass tokens via connectionParams:

// Client — send token in connection params
const wsLink = new GraphQLWsLink(
  createClient({
    url: 'wss://api.example.com/graphql',
    connectionParams: async () => {
      // Async — can refresh token before connecting
      const token = await getValidToken();
      return { Authorization: `Bearer ${token}` };
    },
  }),
);
// Server — extract token from connectionParams
useServer(
  {
    schema,
    context: async (ctx, msg, args) => {
      const token = ctx.connectionParams?.Authorization?.replace('Bearer ', '');

      if (!token) throw new Error('Missing auth token');

      try {
        const user = await verifyToken(token);
        return { user, pubsub };
      } catch {
        throw new Error('Invalid auth token');
      }
    },
    onConnect: async (ctx) => {
      // Return false to reject the connection
      const token = ctx.connectionParams?.Authorization;
      if (!token) return false;
      return true;
    },
  },
  wsServer,
);

Cookie-based auth over WebSocket:

// Server — cookies are sent with the WebSocket handshake
// Read them from ctx.extra.request.headers.cookie
useServer(
  {
    context: async (ctx) => {
      const cookies = parse(ctx.extra.request.headers.cookie || '');
      const sessionId = cookies['session'];
      const user = sessionId ? await getSession(sessionId) : null;
      return { user };
    },
  },
  wsServer,
);

Fix 6: Implement Client-Side Reconnection

graphql-ws handles reconnection automatically with the right configuration:

import { createClient, Client } from 'graphql-ws';

const client = createClient({
  url: 'wss://api.example.com/graphql',

  // Retry configuration
  retryAttempts: Infinity,     // Keep trying to reconnect
  retryWait: async (retryCount) => {
    // Exponential backoff — wait longer between each retry
    const delay = Math.min(1000 * 2 ** retryCount, 30000);
    await new Promise(resolve => setTimeout(resolve, delay));
  },

  // Event handlers
  on: {
    connecting: () => console.log('Connecting...'),
    connected: (socket) => console.log('Connected'),
    closed: (event) => console.log('Closed:', event),
    error: (error) => console.error('Error:', error),
    ping: (received) => console.log(received ? 'Pong received' : 'Ping sent'),
  },

  // Keep-alive ping
  keepAlive: 30000,    // Send ping every 30 seconds to keep connection alive

  // Re-authenticate on reconnect
  connectionParams: async () => ({
    Authorization: `Bearer ${await getValidToken()}`,
  }),
});

Fix 7: Debug Subscription Issues

Verify the WebSocket connection:

// Browser DevTools — Network tab → WS
// Look for the WebSocket connection upgrade request
// Check the Messages tab to see frames being sent/received

// Or use wscat for command-line testing:
// npm install -g wscat
// wscat -c ws://localhost:4000/graphql -s graphql-transport-ws

Test subscriptions directly with graphql-ws:

// test-subscription.js — run with node
import { createClient } from 'graphql-ws';
import WebSocket from 'ws';

const client = createClient({
  url: 'ws://localhost:4000/graphql',
  webSocketImpl: WebSocket,   // Required for Node.js
  connectionParams: { Authorization: 'Bearer test-token' },
});

const unsubscribe = client.subscribe(
  {
    query: `subscription { messageAdded(roomId: "1") { id content } }`,
  },
  {
    next: (data) => console.log('Received:', data),
    error: (err) => console.error('Error:', err),
    complete: () => console.log('Subscription complete'),
  },
);

// Keep alive for 10 seconds then disconnect
setTimeout(() => {
  unsubscribe();
  client.dispose();
}, 10000);

Server-side debugging — log all pub/sub events:

// Wrap pubsub to log all publishes
const originalPublish = pubsub.publish.bind(pubsub);
pubsub.publish = (triggerName, payload) => {
  console.log(`[PubSub] Publishing to ${triggerName}:`, JSON.stringify(payload));
  return originalPublish(triggerName, payload);
};

Still Not Working?

Subscription fires but resolver returns null — the resolve function in the subscription resolver transforms the event payload. If the payload shape doesn’t match what resolve expects, it may return null or undefined.

withFilter blocking all events — if the filter function throws an exception, it defaults to returning false, blocking all events. Wrap the filter in try/catch and log errors.

Memory leak from undisposed subscriptions — on the client, always call the unsubscribe function returned by client.subscribe() when the component unmounts. In React with Apollo Client, useSubscription handles this automatically. Manual subscriptions require manual cleanup.

SSL/TLS for WebSocket in production — use wss:// (WebSocket Secure) in production. Plain ws:// connections are blocked by HTTPS pages in most browsers.

For related issues, see Fix: GraphQL N+1 Query Problem and Fix: Socket.IO CORS Error.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles