StackPractices
advanced Por Mathias Paulenko

Datos en tiempo real con suscripciones GraphQL sobre

Implementa suscripciones GraphQL sobre WebSockets con Apollo Server y PubSub para actualizaciones en tiempo real enviadas a clientes conectados

Las suscripciones GraphQL entregan datos a clientes en tiempo real usando una conexion WebSocket persistente. A diferencia de queries y mutaciones, que siguen un ciclo request-response, las suscripciones mantienen la conexion abierta y envian actualizaciones cuando ocurren eventos en el servidor. Aqui hay una implementacion de suscripciones con el motor PubSub de Apollo Server y un gateway WebSocket.

Cuando Usar Esto

  • For alternatives, see GraphQL Federation in Production.

  • Dashboards en vivo o feeds de actividad que se actualizan al cambiar datos

  • Aplicaciones de chat donde los mensajes aparecen al instante

  • Edicion colaborativa con presencia de cursores o actualizaciones a nivel campo

  • Cualquier escenario donde el polling es demasiado lento o costoso

Requisitos Previos

  • Node.js 18+ con Apollo Server instalado
  • Un servidor HTTP con capacidad WebSocket (paquete ws o @nestjs/websockets)

Solucion

1. Instalar dependencias

npm install @apollo/server graphql-ws graphql ws

2. Definir el schema de suscripcion

// schema.ts
import gql from 'graphql-tag';

export const typeDefs = gql`
  type Post {
    id: ID!
    title: String!
    content: String!
    authorId: ID!
    createdAt: String!
  }

  type Query {
    posts: [Post!]!
  }

  type Mutation {
    createPost(title: String!, content: String!): Post!
  }

  type Subscription {
    postCreated: Post!
    postUpdated(id: ID!): Post!
  }
`;

3. Configurar PubSub y resolvers

// resolvers.ts
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

const POST_CREATED = 'POST_CREATED';
const POST_UPDATED = 'POST_UPDATED';

export const resolvers = {
  Query: {
    posts: (_: unknown, __: unknown, ctx: Context) =>
      ctx.db.posts.findMany(),
  },

  Mutation: {
    createPost: async (_: unknown, args: { title: string; content: string }, ctx: Context) => {
      const post = await ctx.db.posts.create({
        ...args,
        authorId: ctx.user.id,
        createdAt: new Date().toISOString(),
      });

      pubsub.publish(POST_CREATED, { postCreated: post });
      return post;
    },
  },

  Subscription: {
    postCreated: {
      subscribe: () => pubsub.asyncIterator([POST_CREATED]),
    },

    postUpdated: {
      subscribe: (_: unknown, { id }: { id: string }) =>
        pubsub.asyncIterator([{ topic: POST_UPDATED, payload: { id } }]),
    },
  },
};

export { pubsub, POST_CREATED, POST_UPDATED };

4. Iniciar los servidores HTTP y WebSocket

// server.ts
import { ApolloServer } from '@apollo/server';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import http from 'http';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';

const schema = makeExecutableSchema({ typeDefs, resolvers });

const httpServer = http.createServer();
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });

const serverCleanup = useServer(
  {
    schema,
    context: async (ctx) => {
      const token = ctx.connectionParams?.authorization?.replace('Bearer ', '');
      const user = token ? await verifyToken(token) : null;
      if (!user) throw new Error('Unauthorized');
      return createContext(user);
    },
  },
  wsServer
);

const apolloServer = new ApolloServer({ schema });
await apolloServer.start();

httpServer.listen(4000, () => {
  console.log('Server ready at http://localhost:4000/graphql');
});

5. Suscripcion en el cliente

// client.ts
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';

const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:4000/graphql',
    connectionParams: {
      authorization: `Bearer ${getToken()}`,
    },
  })
);

const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' });

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(),
});

const POST_CREATED_SUBSCRIPTION = gql`
  subscription OnPostCreated {
    postCreated {
      id
      title
      content
      createdAt
    }
  }
`;

client.subscribe({ query: POST_CREATED_SUBSCRIPTION }).subscribe({
  next: ({ data }) => {
    console.log('New post:', data.postCreated);
  },
  error: (err) => console.error('Subscription error:', err),
});

Como Funciona

  1. PubSub es un emisor de eventos en memoria. pubsub.publish(topic, payload) notifica a todos los iteradores activos de ese topic.
  2. asyncIterator envuelve el stream de PubSub en un iterable asincrono que el motor de ejecucion de GraphQL consume, entregando cada evento publicado al suscriptor.
  3. graphql-ws maneja el protocolo WebSocket — la consulta de suscripcion se envia por el socket y el servidor envia eventos a medida que se publican.
  4. El link split en el cliente enruta suscripciones al link WebSocket y queries/mutaciones al link HTTP, para que un solo ApolloClient maneje ambos.

Variantes

Redis PubSub para multi-instancia

Cuando ejecutas multiples instancias del servidor, usa graphql-redis-subscriptions para compartir eventos entre procesos:

npm install graphql-redis-subscriptions ioredis
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';

const pubsub = new RedisPubSub({
  publisher: new Redis('redis://localhost:6379'),
  subscriber: new Redis('redis://localhost:6379'),
});

Suscripciones filtradas

Filtra eventos para que los clientes solo reciban actualizaciones relevantes:

Subscription: {
  postCreated: {
    subscribe: withFilter(
      () => pubsub.asyncIterator([POST_CREATED]),
      (payload, variables, ctx) => {
        return payload.postCreated.authorId === ctx.user.id;
      }
    ),
  },
},

Hooks del ciclo de vida de suscripcion

Rastrea conexion y desconexion para limpieza:

const serverCleanup = useServer(
  {
    schema,
    onConnect: async (ctx) => {
      console.log('Client connected:', ctx.connectionParams);
    },
    onDisconnect: (ctx, code, reason) => {
      console.log('Client disconnected:', code, reason);
    },
  },
  wsServer
);

Mejores Practicas

  • Autentica al conectar — valida el token en context cuando se abre el WebSocket, no por mensaje
  • Usa Redis PubSub en produccion — PubSub en memoria no comparte eventos entre instancias del servidor
  • Filtra eventos en el servidor — usa withFilter para evitar enviar datos irrelevantes a cada cliente
  • Cierra iteradores al desconectargraphql-ws lo maneja automaticamente, pero implementaciones custom deben limpiar

Errores Comunes

  • Usar PubSub en memoria en un cluster — los eventos publicados en una instancia nunca llegan a suscriptores en otra
  • Olvidar dividir el link del cliente — sin split, las suscripciones van por HTTP y fallan
  • No manejar reconexion — las conexiones WebSocket caen; configura retryAttempts y reconnecting en el cliente
  • Publicar datos sensibles — el evento de suscripcion llega a cada suscriptor del topic; filtra por usuario o permiso

Troubleshooting

  • Query returns null unexpectedly: verify resolvers, data loaders, and authorization. Check for nullable fields that fail silently.
  • N+1 query performance issue: use DataLoader or equivalent batching. Inspect resolver execution traces.
  • Introspection disabled in production breaks tools: enable it only in development, or use schema artifacts in CI.
  • Mutation input rejected: confirm input validation, custom scalars, and whether variables are passed as the right type.
  • Subscription stops receiving events: check the pub/sub backend, event filtering, and that the resolver is emitting events.

Errores Comunes en Producción

  • Copiar el ejemplo sin adaptarlo a volúmenes y modos de fallo reales.
  • Saltar tests de carga e inyección de errores antes del primer despliegue productivo.
  • Codificar valores fijos que deberían ser configurables por entorno.
  • Olvidar agregar logging y monitoreo en cada paso.
  • Desplegar sin plan de rollback ni estrategia de backup probada.
  • Asumir que el ejemplo mínimo escalará sin agregar caché o procesamiento por lotes.
  • No documentar la versión y configuración usadas en producción.
  • Dejar la receta sin cambios cuando evolucionan las dependencias o la escala.

Preguntas frecuentes

Las suscripciones son soportadas sobre HTTP/2?

WebSocket es el transporte estandar. SSE (Server-Sent Events) funciona para actualizaciones unidireccionales pero no puede manejar el protocolo completo de suscripciones GraphQL.

Cuantas suscripciones concurrentes puede manejar un servidor?

Miles por instancia con graphql-ws. Usa Redis PubSub y escalado horizontal para cargas mayores.

Debo usar suscripciones o polling para datos en vivo?

Las suscripciones son mejores para actualizaciones frecuentes enviadas por el servidor. El polling es mas simple para datos de baja frecuencia o cuando WebSockets no estan disponibles.

Como pruebo suscripciones?

Usa el cliente graphql-ws en tu suite de pruebas para abrir una conexion WebSocket real y verificar eventos recibidos. Mockea PubSub para pruebas unitarias.