GraphQL APIs

GraphQL Subscriptions with WebSockets on Domain India VPS

By Domain India Team · DomainIndia EngineeringPublished 9 min read
Knowledge base article
Contents (13 sections)

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.

Key takeaways

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

OperationDirectionLifetimeUsual transport
QueryClient asks, server answers onceShortHTTP
MutationClient changes data, server answers onceShortHTTP
SubscriptionServer sends many events over timeUntil the client disconnectsWebSocket

Subscriptions let the server push events such as "new message", "price changed" or "build finished".

2. Architecture

code
Client (browser/app) ──WebSocket──► nginx ──proxy──► Apollo Server ◄──pub/sub──► Redis
                     ──HTTP (queries, mutations)──►        │
                                                        Database

Key pieces:

  1. Apollo Server for queries and mutations over HTTP, with a graphql-ws server for subscriptions on the same port.
  2. Redis pub/sub, so an event published on one instance reaches subscribers connected to another.
  3. nginx passing the Upgrade and Connection headers so WebSockets work through the proxy.

3. Step 1: install dependencies

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

These 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

javascript
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

javascript
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]),
    },
  },
};
Why Redis pub/sub instead of in-memory?

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

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

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;

        # 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

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

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

javascript
useServer({ schema, keepAlive: 15000 }, wsServer);   // milliseconds; 0 turns it off

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

WebSocket fails through nginx
Usually missing Upgrade/Connection headers or a short proxy_read_timeout. Test with wscat -c wss://api.yourcompany.com/graphql -s graphql-transport-ws.
Subscriptions reach every user
No withFilter. Every event leaks to every subscriber.
Memory growth
Listeners not cleaned up. graphql-ws disposes of subscriptions on disconnect; check you aren't subscribing twice in your own code.
Too many Redis connections
Creating a client per subscription. Share one publisher and one subscriber per process.
Unauthenticated sockets
Not checking the token in onConnect. Reject the connection when it's missing or invalid.
N+1 queries in payloads
Resolver fields hit the database for every event. Use DataLoader in subscriptions too.

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.

GraphQL subscriptions need a VPS

Full root access for Node.js, nginx and Redis, so long-lived WebSocket connections stay open.

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