GraphQL Subscriptions with WebSockets on DomainIndia VPS
Queries vs Mutations vs Subscriptions
| Operation | Direction | Lifetime | Transport |
|---|---|---|---|
| Query | Client → Server (one request, one response) | Short | HTTP |
| Mutation | Client → Server (change + response) | Short | HTTP |
| Subscription | Server → Client (many events over time) | Long (until client disconnects) | WebSocket |
Subscriptions let the server push events: new message arrived, stock price changed, build finished.
Architecture on DomainIndia VPS
Client (browser/app) ───WebSocket──► nginx ───proxy──► Apollo Server ◄──pub/sub──► Redis
│
Mutations/Queries via
same WS connectionKey pieces:
- Apollo Server with
graphql-wssubscription transport - Redis for pub/sub across multiple Apollo instances
- nginx with
Upgrade+Connectionheaders for WebSocket proxy
Step 1 — Install dependencies
npm install @apollo/server graphql graphql-ws ws
graphql-subscriptions graphql-redis-subscriptions
ioredis express @as-integrations/express4Step 2 — Define subscription schema
const typeDefs = `#graphql
type Message {
id: ID!
text: String!
author: String!
createdAt: String!
}
type Query {
messages: [Message!]!
}
type Mutation {
postMessage(text: String!, author: String!): Message!
}
type Subscription {
messageAdded: Message!
}
`;Step 3 — Implement resolvers with PubSub
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const options = {
host: process.env.REDIS_HOST || 'localhost',
port: 6379,
};
const pubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});
const MESSAGE_ADDED = 'MESSAGE_ADDED';
const resolvers = {
Query: {
messages: () => db.message.findMany(),
},
Mutation: {
postMessage: async (_, { text, author }) => {
const msg = await db.message.create({ data: { text, author } });
pubsub.publish(MESSAGE_ADDED, { messageAdded: msg });
return msg;
},
},
Subscription: {
messageAdded: {
subscribe: () => pubsub.asyncIterator([MESSAGE_ADDED]),
},
},
};Why Redis pub/sub (not in-memory)? If you run 2+ Apollo instances behind a load balancer, a mutation on instance A must reach subscribers on instance B. Redis routes messages between them. In-memory works only with 1 instance — fragile.
Step 4 — Wire up server with WS support
import express from 'express';
import http from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = http.createServer(app);
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql',
});
const serverCleanup = useServer({
schema,
context: async (ctx) => {
// Auth via connection_init params
const token = ctx.connectionParams?.authorization?.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
return { user, pubsub };
},
onConnect: async (ctx) => {
// Reject unauthenticated connections
if (!ctx.connectionParams?.authorization) {
throw new Error('Missing auth token');
}
},
}, wsServer);
const apollo = new ApolloServer({
schema,
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return { async drainServer() { await serverCleanup.dispose(); } };
},
},
],
});
await apollo.start();
app.use('/graphql', express.json(), expressMiddleware(apollo, {
context: async ({ req }) => ({ user: req.user, pubsub }),
}));
httpServer.listen(4000, () => {
console.log('http://localhost:4000/graphql (also WS)');
});Step 5 — nginx WebSocket reverse proxy
/etc/nginx/conf.d/gql.conf:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream graphql { server 127.0.0.1:4000; }
server {
listen 80;
server_name api.yourcompany.com;
location /graphql {
proxy_pass http://graphql;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WS connections stay open — no short timeout
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}The map block + Upgrade/Connection headers are what make WS work through nginx.
Step 6 — Client (browser)
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';
const httpLink = new HttpLink({ uri: '/graphql' });
const wsLink = new GraphQLWsLink(createClient({
url: 'wss://api.yourcompany.com/graphql',
connectionParams: () => ({
authorization: `Bearer ${localStorage.getItem('token')}`,
}),
retryAttempts: Infinity,
shouldRetry: () => true,
}));
// Split: subscriptions go to WS, queries/mutations to HTTP
const splitLink = split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === 'OperationDefinition' && def.operation === 'subscription';
},
wsLink,
httpLink,
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
// Usage:
client.subscribe({
query: gql`subscription { messageAdded { id text author } }`,
}).subscribe({
next: ({ data }) => console.log('New message:', data.messageAdded),
});Authentication and filtering
Subscriptions must filter — not every subscriber gets every event.
Subscription: {
orderStatusChanged: {
subscribe: withFilter(
() => pubsub.asyncIterator('ORDER_STATUS'),
(payload, variables, context) => {
// Only deliver to the order's owner
return payload.orderStatusChanged.userId === context.user.id
&& payload.orderStatusChanged.orderId === variables.orderId;
}
),
},
}Without filtering, every subscriber sees every event — huge privacy leak.
Scaling — when one VPS isn't enough
- 100 concurrent WS connections: 1 VPS (2 GB RAM), no problem
- 1,000: 1 VPS (4 GB RAM), monitor memory
- 10,000: 2-3 VPS behind a load balancer (sticky sessions not required with Redis pub/sub)
- 100,000+: dedicated infra, consider PushPin, Socket.IO Redis adapter, or managed service like Ably
Each WS connection uses ~50–100 KB of Node.js heap. Budget accordingly.
Heartbeats and reconnection
WebSocket connections drop — mobile networks, NAT timeouts, load balancer restarts.
graphql-ws sends ping/pong every 30s by default. Configure:
useServer({
schema,
keepAlive: 12000, // 12s — good for mobile
}, wsServer);Client reconnection is automatic with retryAttempts: Infinity.
Common pitfalls
FAQ
SSE is one-way (server→client), simpler, works over HTTP/2. graphql-sse exists and is fine for pure subscriptions. WS is two-way; needed if same connection serves queries too.
Passenger has limited WS support. Node.js apps in cPanel's "Setup Node.js App" generally don't proxy WS cleanly. For production subscriptions, use VPS.
Hasura gives you subscriptions for free on every PostgreSQL view/table. Great for "notify on row change". Apollo for custom event logic (e.g. subscription filtered by auth, business rules).
Only for subscribed clients. Internal service-to-service messaging is still better via direct Redis pub/sub or a queue. Subscriptions are the end-of-chain delivery to end users.
Browser devtools → Network tab → WS filter. wscat CLI tool for raw WS testing. tcpdump on VPS for low-level inspection. nginx error log for proxy issues.
Production WebSocket/GraphQL subscriptions need a VPS. See VPS plans