Client Area
GraphQL APIsAdvanced

GraphQL Subscriptions with WebSockets on DomainIndia VPS

ByDomain India Team·DomainIndia Engineering
6 min readPublished 25 Apr 2026Updated 23 Jun 2026197 views

In this article

  • 1Queries vs Mutations vs Subscriptions
  • 2Architecture on DomainIndia VPS
  • 3Step 1 — Install dependencies
  • 4Step 2 — Define subscription schema
  • 5Step 3 — Implement resolvers with PubSub

GraphQL Subscriptions with WebSockets on DomainIndia VPS

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

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

FAQ

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

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

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

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

Q 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 feedback helps us improve our documentation

Still need help? Submit a support ticket