Fix: GraphQL Subscription Not Updating — WebSocket Connection Not Receiving Events
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 shownOr the subscription throws a connection error:
WebSocket connection to 'ws://localhost:4000/graphql' failed:
Error during WebSocket handshake: Unexpected response code: 400Or 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/ALBOr 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 firstWhy 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-wsprotocol by default, but many servers still implement the oldersubscriptions-transport-wsprotocol. 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
subscribefunction must return anAsyncIterator. 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
Upgraderequests. - Single-process pub/sub in multi-instance deployment — if you use in-memory pub/sub (like
PubSubfromgraphql-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:
| Protocol | Package | Status |
|---|---|---|
graphql-transport-ws | graphql-ws | Modern — use this |
graphql-ws (old naming) | subscriptions-transport-ws | Legacy — 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-wsTest 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: gql.tada Not Working — Types Not Inferred, Schema Not Found, or IDE Not Showing Completions
How to fix gql.tada issues — schema introspection, type-safe GraphQL queries, fragment masking, urql and Apollo Client integration, IDE setup, and CI type checking.
Fix: tRPC Not Working — Type Inference Lost, Procedure Not Found, or Context Not Available
How to fix tRPC issues — router setup, type inference across packages, context injection, middleware, error handling, and common tRPC v10/v11 configuration mistakes.
Fix: GraphQL Error Handling Not Working — Errors Not Returned or Always 200 OK
How to fix GraphQL error handling — error extensions, partial data with errors, Apollo formatError, custom error classes, client-side error detection, and network vs GraphQL errors.
Fix: Socket.IO CORS Error — Cross-Origin Connection Blocked
How to fix Socket.IO CORS errors — server-side CORS configuration, credential handling, polling vs WebSocket transport, proxy setup, and common connection failures.