Fix: GraphQL Subscription Not Updating — WebSocket Connection Not Receiving Events
Part of: React & Frontend Errors
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 on top of the usual HTTP request/response model, and most of those layers are infrastructure rather than GraphQL itself.
Protocol mismatch is the most frequent root cause. Apollo Client 3 uses the graphql-ws protocol by default, but many servers still implement the older subscriptions-transport-ws protocol. The two are incompatible — the client connects, but no events flow because the server never recognizes the subscription start message. WebSocket configuration on the server is another common gap: express, Fastify, and Koa HTTP servers need a separate WebSocket server setup. The regular HTTP route handler doesn’t handle WebSocket upgrades, so the handshake fails with a 400.
Pub/sub wiring is the next layer. The subscription resolver’s subscribe function must return an AsyncIterator. If the pub/sub system isn’t properly set up, subscriptions silently receive nothing — no error, no log line, just dead silence. Authentication adds its own twist: HTTP cookies and Authorization headers work differently over WebSocket connections, and tokens sent in HTTP headers aren’t automatically available in the WS handshake. Infrastructure layers (nginx, AWS ALB, Cloudflare) need explicit configuration to forward WebSocket Upgrade requests, and they default to HTTP-only proxying that silently drops the upgrade. Finally, single-process pub/sub in a multi-instance deployment causes the “events arrive randomly” symptom: 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:
| 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);
};Production Incident Patterns
Subscriptions failing in production is one of the most painful classes of outage because the symptom is silent. Queries and mutations keep working, the page loads, the API health check is green — but users see stale data. Chat messages don’t arrive, dashboards freeze on the last value, notifications go missing. The blast radius is “every consumer of every subscription,” and you usually find out from a customer complaint rather than a monitor.
Scenario: ALB sticky session timeout expires mid-conversation. A chat app runs three backend instances behind an AWS ALB. The team added sticky sessions early on, set the cookie duration to one hour. A power user keeps the chat tab open for two hours. The sticky session cookie expires, the next reconnect lands on a different instance, and the in-memory PubSub subscription is silently lost. The client believes it’s still connected (the new instance accepts the WebSocket); the server believes the subscription exists; but the original pub/sub channel is on a different node. The fix is to move to Redis pub/sub so the instance affinity stops mattering. Sticky sessions become a perf optimization, not a correctness requirement.
Monitoring subscriptions in production. Track WebSocket connection count as a primary SLI — a sudden drop means subscriptions are dying en masse. Also track “events published vs. events delivered” if you use Redis pub/sub, because a divergence indicates a fan-out problem (one instance publishing, another not delivering). Add a synthetic check that opens a subscription, triggers a known mutation, and asserts the subscription event arrives within N seconds — this catches the entire round trip the way users experience it.
Recovery playbook. When subscriptions stop arriving, the first move is to verify the WebSocket layer is up: check connection count, check that new connections succeed, check that Upgrade: websocket headers are passing through any proxy. If the WS layer is healthy but events aren’t flowing, log every pubsub.publish call and compare against the subscriber side. Once the failing layer is identified — proxy, transport, pub/sub, resolver — the fix is usually one of the patterns above. As a last resort, falling back the client to HTTP polling (5-second useQuery with pollInterval) restores correctness while you investigate, at the cost of higher backend load.
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.
Cloudflare WebSocket plan limits — Cloudflare’s free and lower paid plans cap WebSocket connection duration and concurrency. Long-lived subscriptions may be terminated by Cloudflare’s edge even when your origin is healthy. Check the plan’s WebSocket limits, or proxy through a transport that doesn’t run through Cloudflare for the WS endpoint.
Subscription works the first time only after server restart — in-memory PubSub channels are tied to the process. If the server restarts (deploy, OOM kill, process supervisor), all subscribers must reconnect. Clients should handle reconnect explicitly; if they don’t, the symptom looks like “subscriptions worked yesterday but not today.” Confirm whether the server restarted in the relevant window.
For related real-time issues, see Fix: GraphQL N+1 Query Problem, Fix: Socket.IO CORS Error, Fix: Socket.IO Not Connecting, and Fix: nginx WebSocket Proxy Not Working.
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.