GraphQL queries and mutations are request/response, but chat, live dashboards and "your order has shipped" updates need the server to push data to the client. GraphQL subscriptions do that over a WebSocket. This guide builds subscriptions with Apollo Server and the graphql-ws protocol, scales them with Redis pub/sub, and puts nginx in front, on a VPS.
Run Apollo Server with a graphql-ws WebSocket server on the same HTTP server, publish events through Redis so several instances can share them, filter every subscription by user, and proxy it through nginx with the Upgrade and Connection headers. Subscriptions keep long-lived connections open, so they need a VPS: WebSocket servers are stopped on shared hosting and the App Platform doesn't support WebSockets.
1. Queries vs mutations vs subscriptions
| Operation | Direction | Lifetime | Usual transport |
|---|---|---|---|
| Query | Client asks, server answers once | Short | HTTP |
| Mutation | Client changes data, server answers once | Short | HTTP |
| Subscription | Server sends many events over time | Until the client disconnects | WebSocket |
Subscriptions let the server push events such as "new message", "price changed" or "build finished".
2. Architecture
Client (browser/app) ──WebSocket──► nginx ──proxy──► Apollo Server ◄──pub/sub──► Redis
──HTTP (queries, mutations)──► │
DatabaseKey pieces:
- Apollo Server for queries and mutations over HTTP, with a
graphql-wsserver for subscriptions on the same port. - Redis pub/sub, so an event published on one instance reaches subscribers connected to another.
- nginx passing the
UpgradeandConnectionheaders so WebSockets work through the proxy.
3. Step 1: install dependencies
npm install @apollo/server @as-integrations/express4 express graphql \
@graphql-tools/schema graphql-ws ws \
graphql-subscriptions graphql-redis-subscriptions ioredisThese examples use Express 4. For Express 5, use @as-integrations/express5 instead. Use a current Node.js LTS release.
4. Step 2: define the schema
const typeDefs = `#graphql
type Message {
id: ID!
text: String!
author: String!
createdAt: String!
}
type Query {
messages: [Message!]!
}
type Mutation {
postMessage(text: String!): Message!
}
type Subscription {
messageAdded: Message!
}
`;The author comes from the logged-in user in the context, not from a client-supplied argument.
5. Step 3: resolvers with Redis pub/sub
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const options = { host: process.env.REDIS_HOST || '127.0.0.1', 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({ take: 50, orderBy: { createdAt: 'desc' } }),
},
Mutation: {
postMessage: async (_, { text }, { user }) => {
if (!user) throw new Error('Not authenticated');
const msg = await db.message.create({ data: { text, author: user.name } });
await pubsub.publish(MESSAGE_ADDED, { messageAdded: msg });
return msg;
},
},
Subscription: {
messageAdded: {
// graphql-subscriptions 3.x names this asyncIterableIterator; older versions use asyncIterator
subscribe: () => pubsub.asyncIterableIterator([MESSAGE_ADDED]),
},
},
};With two or more Apollo instances behind a load balancer, a mutation handled by instance A must reach subscribers connected to instance B. Redis passes events between them. The in-memory PubSub only works with a single process, and it is meant for development.
6. Step 4: wire up HTTP and WebSocket on one server
import express from 'express';
import http from 'node:http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/use/ws'; // graphql-ws 5.x: '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,
// Reject the connection unless the token is valid
onConnect: async (ctx) => {
const token = String(ctx.connectionParams?.authorization ?? '').replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
if (!user) return false; // closes with "Forbidden"
ctx.extra.user = user;
},
context: async (ctx) => ({ user: ctx.extra.user, pubsub }),
}, 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 }) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '');
return { user: token ? await verifyToken(token) : null, pubsub };
},
}));
httpServer.listen(4000, '127.0.0.1', () => {
console.log('GraphQL on http://127.0.0.1:4000/graphql (HTTP and WebSocket)');
});Binding to 127.0.0.1 keeps the Node.js port private; nginx is the only public entry point.
7. 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;
# WebSocket connections stay open, so allow long reads
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}The map block plus the Upgrade and Connection headers are what make WebSockets work through nginx. Add HTTPS before going live, for example with Certbot's nginx plugin, so clients connect with wss://.
8. Step 6: the browser client
import { ApolloClient, ApolloLink, InMemoryCache, HttpLink, gql } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
const httpLink = new HttpLink({ uri: 'https://api.yourcompany.com/graphql' });
const wsLink = new GraphQLWsLink(createClient({
url: 'wss://api.yourcompany.com/graphql',
connectionParams: () => ({ authorization: `Bearer ${getAccessToken()}` }),
retryAttempts: Infinity,
shouldRetry: () => true,
}));
// Subscriptions go over the WebSocket; queries and mutations over HTTP
const link = ApolloLink.split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === 'OperationDefinition' && def.operation === 'subscription';
},
wsLink,
httpLink,
);
const client = new ApolloClient({ link, cache: new InMemoryCache() });
client.subscribe({
query: gql`subscription { messageAdded { id text author } }`,
}).subscribe({
next: ({ data }) => console.log('New message:', data.messageAdded),
});9. Authentication and filtering
Not every subscriber should get every event. Filter on the server:
import { withFilter } from 'graphql-subscriptions';
const resolvers = {
Subscription: {
orderStatusChanged: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator('ORDER_STATUS'),
(payload, variables, context) =>
// Only deliver to the order's owner, for the order they asked about
payload.orderStatusChanged.userId === context.user.id &&
payload.orderStatusChanged.orderId === variables.orderId,
),
},
},
};Without a filter, every subscriber receives every event, which leaks other users' data.
10. Scaling, heartbeats and reconnection
Scaling. Memory per connection depends on your code and payloads, so measure with a load test (for example k6 or Artillery) before choosing a server size. The pattern stays the same as you grow: one instance first; then several instances behind a load balancer sharing Redis pub/sub (sticky sessions aren't needed for WebSocket-only subscriptions); and at very large scale, a dedicated real-time layer such as Pushpin or a managed service such as Ably.
Heartbeats. Connections drop because of mobile networks, NAT timeouts and proxy restarts. The graphql-ws server sends a ping every 12 seconds by default; you can change it with keepAlive:
useServer({ schema, keepAlive: 15000 }, wsServer); // milliseconds; 0 turns it offReconnection. On the client, retryAttempts and shouldRetry make graphql-ws reconnect automatically with backoff. After a reconnect, re-fetch anything the user may have missed with a normal query.
11. Running this on Domain India
- Use a VPS. WebSocket servers are long-running processes, which are stopped on shared hosting, and the App Platform does not support WebSockets. A Domain India VPS is self-managed with full root access, so you install Node.js, nginx and Redis and keep them patched yourself.
- Redis is not available on shared hosting or the App Platform; install it on the VPS and bind it to
127.0.0.1. - Queries and mutations only? A GraphQL API without subscriptions is ordinary request/response, so it can also run on the App Platform or with Setup Node.js App on cPanel and DirectAdmin hosting.
- Backups: VPS plans include no backups or snapshots, so back up your database and config yourself.
12. Common pitfalls
Upgrade/Connection headers or a short proxy_read_timeout. Test with wscat -c wss://api.yourcompany.com/graphql -s graphql-transport-ws.withFilter. Every event leaks to every subscriber.onConnect. Reject the connection when it's missing or invalid.FAQ
Server-Sent Events or WebSockets for subscriptions?
SSE is one-way from server to client over ordinary HTTP, and the graphql-sse library supports it for subscriptions. WebSockets are two-way, so one connection can also carry queries and mutations. Both keep long-lived connections, so both need a server that allows them.
Can I run GraphQL subscriptions on Domain India shared hosting?
No. Long-running processes such as WebSocket servers are stopped on shared hosting, and the App Platform does not support WebSockets. Run subscriptions on a Domain India VPS; queries and mutations alone can run on shared hosting or the App Platform.
Hasura subscriptions or custom Apollo?
Hasura gives you subscriptions on PostgreSQL tables and views with little code, which suits "notify me when this row changes". Apollo suits custom event logic, such as events filtered by business rules or coming from outside the database.
Do subscriptions replace Redis pub/sub or a queue in my app?
No. Subscriptions are the last step, delivering events to end users. Service-to-service messaging and background work still belong on Redis pub/sub or a proper queue.
How do I debug WebSocket problems?
Use the browser's developer tools (Network tab, WS filter) to see frames, wscat to test the endpoint directly, and the nginx error log for proxy problems. Check the Node.js logs for rejected connections in onConnect.
Ready to add real-time updates? Run the subscription server on a VPS, and see real-time sync and push notifications for mobile apps for the mobile side. Questions about a plan? Open a support ticket.
Full root access for Node.js, nginx and Redis, so long-lived WebSocket connections stay open.
See VPS plans