GraphQL APIs

GraphQL Subscriptions with WebSockets on DomainIndia VPS

By Domain India Team · DomainIndia EngineeringPublished · Updated 7 min read
Knowledge base article
Contents (13 sections)
TL;DR

GraphQL subscriptions deliver real-time updates over WebSockets — for chat, live dashboards, notifications, collaborative editing. This guide covers Apollo Server subscriptions, graphql-ws protocol, Redis pub/sub for multi-instance scaling, and the nginx WebSocket proxy setup on DomainIndia VPS.

Queries vs Mutations vs Subscriptions

OperationDirectionLifetimeTransport
QueryClient → Server (one request, one response)ShortHTTP
MutationClient → Server (change + response)ShortHTTP
SubscriptionServer → 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

code
Client (browser/app) ───WebSocket──► nginx ───proxy──► Apollo Server ◄──pub/sub──► Redis
                                                              │
                                                     Mutations/Queries via
                                                     same WS connection

Key pieces:

  1. Apollo Server with graphql-ws subscription transport
  2. Redis for pub/sub across multiple Apollo instances
  3. nginx with Upgrade + Connection headers for WebSocket proxy

Step 1 — Install dependencies

bash
npm install @apollo/server graphql graphql-ws ws 
            graphql-subscriptions graphql-redis-subscriptions 
            ioredis express @as-integrations/express4

Step 2 — Define subscription schema

javascript
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

javascript
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]),
    },
  },
};
Info

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

javascript
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:

nginx
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)

javascript
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.

javascript
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:

javascript
useServer({
  schema,
  keepAlive: 12000,  // 12s — good for mobile
}, wsServer);

Client reconnection is automatic with retryAttempts: Infinity.

Common pitfalls

WebSocket 502 from nginx
missing Upgrade/Connection headers or bad proxy_read_timeout. Verify with wscat -c wss://api.yourcompany.com/graphql.
Subscriptions fire for all users
no withFilter. Every event leaks to every subscriber.
Memory leak
pub/sub subscribers not cleaned up on disconnect. graphql-ws handles this automatically; verify you're not double-subscribing in code.
Redis connection explosion
each subscription opening its own connection. Use a single shared Redis client.
Unauthenticated WS hijacking
not checking auth in onConnect. Always verify connectionParams.
N+1 queries in subscription payload
resolver fields trigger DB calls per event. Use DataLoader even in subscriptions.

FAQ

Server-Sent Events (SSE) vs WebSocket for subscriptions?

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.

Can I run this on DomainIndia shared hosting?

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 subscriptions vs custom Apollo?

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).

Do subscriptions replace Redis pub/sub for my app?

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.

How do I debug WebSocket issues?

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

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app
GraphQL Subscriptions with WebSockets on DomainIndia VPS