Saltar al contenido

Suscripciones de GraphQL para funcionalidades en tiempo real

Guía práctica para implementar suscripciones GraphQL en tiempo real: transporte WebSocket, resolvers, filtrado y consideraciones de escalado.

4 min de lectura
Diagrama del flujo de una suscripción de GraphQL que muestra al servidor enviando actualizaciones en tiempo real a los clientes conectados

Las APIs REST siguen un patrón de solicitud-respuesta: el cliente pregunta, el servidor responde. Para funcionalidades en tiempo real como notificaciones en vivo, mensajes de chat o actualizaciones de dashboards, ese modelo se rompe. O haces polling repetidamente (desperdiciando ancho de banda y aumentando la latencia), o usas un mecanismo de push. Las suscripciones de GraphQL te dan semántica de push con la misma seguridad de tipos y el mismo diseño dirigido por el esquema que las queries y las mutations.

Las suscripciones usan conexiones WebSocket por debajo. El cliente declara qué eventos le interesan y el servidor envía datos a través de la conexión abierta cada vez que esos eventos ocurren.

Montar el servidor de suscripciones

La mayoría de servidores GraphQL soportan suscripciones mediante el protocolo graphql-ws. Aquí tienes una configuración con Apollo Server y Express:

tstypescript
import { createServer } from 'http';
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
 
const app = express();
const httpServer = createServer(app);
 
// Create WebSocket server for subscriptions
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});
 
const schema = makeExecutableSchema({ typeDefs, resolvers });
 
// Set up graphql-ws handler
const serverCleanup = useServer({ schema }, wsServer);
 
const server = new ApolloServer({
  schema,
  plugins: [
    ApolloServerPluginDrainHttpServer({ httpServer }),
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});
 
await server.start();
app.use('/graphql', express.json(), expressMiddleware(server));
httpServer.listen(4000);

El detalle clave: HTTP atiende las queries y las mutations mientras que WebSocket atiende las suscripciones, ambos en el mismo endpoint /graphql. La librería graphql-ws gestiona el ciclo de vida del WebSocket — el handshake de conexión, el framing de los mensajes y los pings de keep-alive.

Definir los tipos de suscripción

Las suscripciones se definen en tu esquema junto a las queries y las mutations. Describen a qué eventos se pueden suscribir los clientes y qué forma tienen los datos que reciben.

graphqlgraphql
type Subscription {
  # Simple subscription — receive every new message
  messageCreated(channelId: ID!): Message!
 
  # Filtered subscription — only specific order status changes
  orderStatusChanged(orderId: ID!): OrderStatusUpdate!
 
  # Broadcast subscription — all connected clients receive this
  systemNotification: SystemNotification!
}
 
type Message {
  id: ID!
  channelId: ID!
  author: User!
  content: String!
  createdAt: DateTime!
}
 
type OrderStatusUpdate {
  orderId: ID!
  previousStatus: OrderStatus!
  newStatus: OrderStatus!
  updatedAt: DateTime!
}
 
type SystemNotification {
  id: ID!
  level: NotificationLevel!
  message: String!
  timestamp: DateTime!
}
 
enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}
 
enum NotificationLevel {
  INFO
  WARNING
  CRITICAL
}

Implementar los resolvers de suscripción

Los resolvers de suscripción usan un patrón de AsyncIterator. Publicas eventos desde tu lógica de negocio y el resolver los filtra y los entrega a los suscriptores correctos.

tstypescript
import { PubSub, withFilter } from 'graphql-subscriptions';
 
const pubsub = new PubSub();
 
// Event name constants
const EVENTS = {
  MESSAGE_CREATED: 'MESSAGE_CREATED',
  ORDER_STATUS_CHANGED: 'ORDER_STATUS_CHANGED',
  SYSTEM_NOTIFICATION: 'SYSTEM_NOTIFICATION',
} as const;
 
const resolvers = {
  Subscription: {
    messageCreated: {
      // withFilter ensures clients only receive messages
      // for the channel they subscribed to
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator(EVENTS.MESSAGE_CREATED),
        (payload, variables) => {
          return payload.messageCreated.channelId === variables.channelId;
        }
      ),
    },
 
    orderStatusChanged: {
      subscribe: withFilter(
        () => pubsub.asyncIterableIterator(EVENTS.ORDER_STATUS_CHANGED),
        (payload, variables) => {
          return payload.orderStatusChanged.orderId === variables.orderId;
        }
      ),
    },
 
    systemNotification: {
      // No filter — all subscribers receive all notifications
      subscribe: () =>
        pubsub.asyncIterableIterator(EVENTS.SYSTEM_NOTIFICATION),
    },
  },
 
  Mutation: {
    sendMessage: async (_: unknown, args: { channelId: string; content: string }, context: { userId: string }) => {
      const message = await createMessage({
        channelId: args.channelId,
        authorId: context.userId,
        content: args.content,
      });
 
      // Publish event — all matching subscribers receive it
      await pubsub.publish(EVENTS.MESSAGE_CREATED, {
        messageCreated: message,
      });
 
      return message;
    },
  },
};
tstypescript
// ❌ Publishing without the correct payload shape
await pubsub.publish('MESSAGE_CREATED', { message: newMessage });
// Resolver expects payload.messageCreated, not payload.message
// Subscribers receive null/undefined — silent failure
 
// ✅ Payload key must match the subscription field name
await pubsub.publish('MESSAGE_CREATED', {
  messageCreated: newMessage,  // Matches subscription field name exactly
});

Gestionar las suscripciones en el cliente

En el cliente, las suscripciones se integran con tu cliente de GraphQL habitual. Aquí tienes un componente de React que usa Apollo Client:

tstypescript
import { useSubscription, gql } from '@apollo/client';
 
const MESSAGE_SUBSCRIPTION = gql`
  subscription OnMessageCreated($channelId: ID!) {
    messageCreated(channelId: $channelId) {
      id
      content
      author {
        id
        name
        avatar
      }
      createdAt
    }
  }
`;
 
function ChatMessages({ channelId }: { channelId: string }) {
  const { data, loading, error } = useSubscription(MESSAGE_SUBSCRIPTION, {
    variables: { channelId },
    onData: ({ data: subscriptionData }) => {
      // Optional: handle each incoming message
      const message = subscriptionData.data?.messageCreated;
      if (message) {
        playNotificationSound();
      }
    },
  });
 
  if (error) return <div>Connection error: {error.message}</div>;
  if (loading) return <div>Connecting to channel...</div>;
 
  const newMessage = data?.messageCreated;
  return newMessage ? (
    <div className="message">
      <strong>{newMessage.author.name}:</strong> {newMessage.content}
    </div>
  ) : null;
}

Escalar las suscripciones en producción

El PubSub en memoria de graphql-subscriptions sirve para una sola instancia del servidor. En producción, con varias instancias detrás de un balanceador de carga, necesitas un backend de pub/sub distribuido.

tstypescript
// ❌ In-memory PubSub — breaks with multiple server instances
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
// Server A publishes an event — only Server A's subscribers see it
// Clients connected to Server B miss the event entirely
 
// ✅ Redis-backed PubSub — works across all server instances
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
 
const pubsub = new RedisPubSub({
  publisher: new Redis({ host: 'redis-host', port: 6379 }),
  subscriber: new Redis({ host: 'redis-host', port: 6379 }),
});
// Server A publishes → Redis broadcasts → all servers deliver
// to their connected subscribers
tstypescript
// Connection management for production
interface SubscriptionConfig {
  maxConnectionsPerUser: number;
  connectionTimeoutMs: number;
  heartbeatIntervalMs: number;
  maxSubscriptionsPerConnection: number;
}
 
const productionConfig: SubscriptionConfig = {
  maxConnectionsPerUser: 5,       // Prevent connection leaks
  connectionTimeoutMs: 30_000,    // Close idle connections
  heartbeatIntervalMs: 10_000,    // Detect dead connections
  maxSubscriptionsPerConnection: 20,  // Limit resource usage
};
 
// WebSocket server context with authentication
const wsServerOptions = {
  schema,
  context: async (ctx: { connectionParams: Record<string, unknown> }) => {
    const token = ctx.connectionParams?.authorization as string;
    if (!token) {
      throw new Error('Missing authentication token');
    }
    const user = await verifyToken(token);
    return { user };
  },
  onConnect: async (ctx: { connectionParams: Record<string, unknown> }) => {
    console.log('Client connected');
    // Validate authentication before allowing subscription
  },
  onDisconnect: () => {
    console.log('Client disconnected');
    // Clean up per-connection resources
  },
};

Un pub/sub respaldado por Redis garantiza que un evento publicado por cualquier instancia del servidor llegue a todos los suscriptores, sin importar a qué instancia estén conectados. Este es el patrón estándar para servidores de suscripciones escalados horizontalmente.

Puntos clave

  1. Las suscripciones añaden semántica de push a GraphQL — los clientes declaran qué les interesa y los servidores envían los datos por conexiones WebSocket persistentes
  2. Usa withFilter para asegurarte de que cada suscriptor solo reciba los eventos relevantes según los argumentos de su query
  3. Las claves del payload deben coincidir con los nombres de los campos de la suscripción — si no coinciden, la entrega es null en silencio
  4. Sustituye el PubSub en memoria por Redis cuando ejecutes varias instancias del servidor detrás de un balanceador de carga
  5. Impón límites de conexión y autenticación en las conexiones WebSocket para evitar el agotamiento de recursos
  6. Las suscripciones complementan a las queries, no las sustituyen — la carga inicial usa una query y a partir de ahí las suscripciones transmiten las actualizaciones incrementales
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX