GraphQL Subscriptions con Apollo Server y Client
Guía práctica de suscripciones GraphQL en tiempo real: WebSocket con Apollo Server, resolvers de suscripción, integración del cliente y escalado.

Cuando las consultas y mutaciones no son suficientes
Las consultas de GraphQL obtienen datos. Las mutaciones modifican datos. Pero ninguna le avisa al cliente cuando los datos han cambiado. Sin suscripciones, el cliente recurre al polling —preguntando una y otra vez "¿cambió algo?" a intervalos fijos. El polling funciona, pero desperdicia ancho de banda, aumenta la carga del servidor e introduce una latencia igual al intervalo de sondeo.
Las suscripciones resuelven esto enviando actualizaciones al cliente en el momento en que ocurren. Un mensaje de chat aparece al instante. Una cotización bursátil se actualiza en tiempo real. El estado de un despliegue cambia sin que el usuario recargue la página.
Esta guía construye un sistema completo de suscripciones con Apollo Server y Apollo Client, cubriendo la infraestructura WebSocket, patrones de resolvers, autenticación en la conexión de suscripción y los aspectos de producción que los tutoriales suelen omitir.
Configuración del servidor: Apollo con transporte WebSocket
Apollo Server 4 no incluye soporte incorporado para suscripciones. Se combina con la biblioteca graphql-ws para el transporte WebSocket y Express (o Fastify) para el transporte HTTP.
// src/server.ts
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { createServer } from "http";
import express from "express";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { typeDefs } from "./schema";
import { resolvers } from "./resolvers";
import { Context, createContext, createWsContext } from "./context";
const app = express();
const httpServer = createServer(app);
const schema = makeExecutableSchema({ typeDefs, resolvers });
// WebSocket server for subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: "/graphql",
});
const serverCleanup = useServer(
{
schema,
context: async (ctx): Promise<Context> => {
return createWsContext(ctx);
},
onConnect: async (ctx) => {
const token = ctx.connectionParams?.authToken;
if (!token) {
throw new Error("Missing authentication token");
}
// Validate token — reject connection if invalid
const user = await validateToken(token as string);
if (!user) return false; // Reject connection
return true;
},
onDisconnect: async (ctx) => {
console.log("Client disconnected from subscriptions");
},
},
wsServer
);
const server = new ApolloServer<Context>({
schema,
plugins: [
{
async serverWillStart() {
return {
async drainServer() {
await serverCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.use(
"/graphql",
express.json(),
expressMiddleware(server, {
context: createContext,
})
);
httpServer.listen(4000, () => {
console.log("Server running on http://localhost:4000/graphql");
console.log("Subscriptions on ws://localhost:4000/graphql");
});La configuración de transporte dual es esencial. HTTP maneja consultas y mutaciones. WebSocket maneja suscripciones. Ambos comparten el mismo esquema y la misma ruta de endpoint, pero usan diferentes protocolos.
Diseño de esquema para suscripciones
# src/schema.graphql
type Message {
id: ID!
content: String!
author: User!
channel: Channel!
createdAt: DateTime!
}
type Channel {
id: ID!
name: String!
messages(last: Int): [Message!]!
memberCount: Int!
}
type Mutation {
sendMessage(channelId: ID!, content: String!): Message!
createChannel(name: String!): Channel!
}
type Subscription {
messageAdded(channelId: ID!): Message!
channelUpdated: Channel!
userPresenceChanged(channelId: ID!): PresenceEvent!
}
type PresenceEvent {
userId: ID!
username: String!
status: PresenceStatus!
channelId: ID!
}
enum PresenceStatus {
ONLINE
OFFLINE
TYPING
}Los campos de suscripción deben ser granulares. En lugar de una única suscripción onAnythingChanged, define suscripciones específicas para cada tipo de evento que pueda interesarle a un cliente. Así los clientes se suscriben solo a los eventos que necesitan.
Resolvers: PubSub y lógica de suscripción
El sistema PubSub conecta las mutaciones (que producen eventos) con las suscripciones (que los consumen). Para desarrollo, un PubSub en memoria funciona. Para producción, usa un PubSub respaldado por Redis.
// src/pubsub.ts
import { PubSub } from "graphql-subscriptions";
import { RedisPubSub } from "graphql-redis-subscriptions";
import Redis from "ioredis";
// Development: in-memory
const devPubSub = new PubSub();
// Production: Redis-backed for multi-instance support
const prodPubSub = new RedisPubSub({
publisher: new Redis(process.env.REDIS_URL!),
subscriber: new Redis(process.env.REDIS_URL!),
});
export const pubsub =
process.env.NODE_ENV === "production" ? prodPubSub : devPubSub;
// Event name constants
export const EVENTS = {
MESSAGE_ADDED: "MESSAGE_ADDED",
CHANNEL_UPDATED: "CHANNEL_UPDATED",
PRESENCE_CHANGED: "PRESENCE_CHANGED",
} as const;// src/resolvers/mutation.ts
import { pubsub, EVENTS } from "../pubsub";
export const mutationResolvers = {
Mutation: {
sendMessage: async (
_: unknown,
args: { channelId: string; content: string },
ctx: Context
) => {
const message = await ctx.db.message.create({
data: {
content: args.content,
channelId: args.channelId,
authorId: ctx.user.id,
},
include: {
author: true,
channel: true,
},
});
// Publish to subscribers
await pubsub.publish(EVENTS.MESSAGE_ADDED, {
messageAdded: message,
channelId: args.channelId,
});
return message;
},
},
};// src/resolvers/subscription.ts
import { withFilter } from "graphql-subscriptions";
import { pubsub, EVENTS } from "../pubsub";
export const subscriptionResolvers = {
Subscription: {
messageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator(EVENTS.MESSAGE_ADDED),
(payload, variables) => {
// Only send to subscribers watching this specific channel
return payload.channelId === variables.channelId;
}
),
},
channelUpdated: {
subscribe: () =>
pubsub.asyncIterableIterator(EVENTS.CHANNEL_UPDATED),
},
userPresenceChanged: {
subscribe: withFilter(
() => pubsub.asyncIterableIterator(EVENTS.PRESENCE_CHANGED),
(payload, variables) => {
return payload.userPresenceChanged.channelId === variables.channelId;
}
),
},
},
};La función withFilter es crítica para el rendimiento. Sin ella, cada suscriptor recibe cada evento y el cliente descarta los irrelevantes. Con filtrado, el servidor solo envía eventos a los suscriptores que les interesan ese canal o entidad específicos.
Integración del cliente con Apollo Client
// src/lib/apollo-client.ts
import {
ApolloClient,
InMemoryCache,
split,
HttpLink,
} 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",
credentials: "include",
});
const wsLink = new GraphQLWsLink(
createClient({
url: "ws://localhost:4000/graphql",
connectionParams: () => ({
authToken: getAuthToken(),
}),
retryAttempts: 5,
shouldRetry: () => true,
on: {
connected: () => console.log("WS connected"),
closed: () => console.log("WS closed"),
error: (err) => console.error("WS error:", err),
},
})
);
// Route subscription operations to WebSocket, everything else to HTTP
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === "OperationDefinition" &&
definition.operation === "subscription"
);
},
wsLink,
httpLink
);
export const apolloClient = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});La función split enruta las operaciones hacia el transporte correcto: las suscripciones van por WebSocket, las consultas y mutaciones por HTTP. Esta separación es importante porque las conexiones WebSocket son persistentes y con estado, mientras que las peticiones HTTP son sin estado y más fáciles de balancear.
Componentes de React con actualizaciones en vivo
// ❌ Bad: Polling for new messages
import { useQuery, gql } from "@apollo/client";
const MESSAGES_QUERY = gql`
query Messages($channelId: ID!) {
channel(id: $channelId) {
messages(last: 50) {
id
content
author { name }
createdAt
}
}
}
`;
function ChatBad({ channelId }: { channelId: string }) {
const { data } = useQuery(MESSAGES_QUERY, {
variables: { channelId },
pollInterval: 1000, // Wasteful — 1 request/second
});
return <MessageList messages={data?.channel?.messages ?? []} />;
}// ✅ Good: Initial query + subscription for live updates
import { useQuery, useSubscription, gql } from "@apollo/client";
const MESSAGE_SUBSCRIPTION = gql`
subscription OnMessageAdded($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
content
author {
name
avatar
}
createdAt
}
}
`;
function ChatChannel({ channelId }: { channelId: string }) {
const { data, loading } = useQuery(MESSAGES_QUERY, {
variables: { channelId },
});
useSubscription(MESSAGE_SUBSCRIPTION, {
variables: { channelId },
onData: ({ client, data: subData }) => {
const newMessage = subData.data?.messageAdded;
if (!newMessage) return;
// Update the Apollo cache with the new message
client.cache.modify({
id: client.cache.identify({
__typename: "Channel",
id: channelId,
}),
fields: {
messages(existing = []) {
const newRef = client.cache.writeFragment({
data: newMessage,
fragment: gql`
fragment NewMessage on Message {
id
content
author { name avatar }
createdAt
}
`,
});
return [...existing, newRef];
},
},
});
},
});
if (loading) return <ChatSkeleton />;
return <MessageList messages={data?.channel?.messages ?? []} />;
}El callback onData actualiza manualmente la caché de Apollo cuando llega un evento de suscripción. Este enfoque es más confiable que subscribeToMore para actualizaciones complejas de caché porque tienes control total sobre cómo se fusionan los nuevos datos con los existentes.
Gestión de conexiones y heartbeats
Las conexiones WebSocket mueren en silencio. Las redes móviles las cortan sin enviar frames de cierre. Los balanceadores de carga terminan las conexiones inactivas. Sin heartbeats, los clientes terminan con conexiones muertas que nunca reciben actualizaciones.
// Server-side: graphql-ws handles heartbeats automatically via ping/pong
// Client-side: configure keepalive and reconnection
const wsClient = createClient({
url: "ws://localhost:4000/graphql",
connectionParams: () => ({
authToken: getAuthToken(),
}),
keepAlive: 10000, // Send ping every 10 seconds
retryAttempts: Infinity, // Always retry
retryWait: async (retries: number) => {
// Exponential backoff with jitter
const baseDelay = Math.min(1000 * 2 ** retries, 30000);
const jitter = Math.random() * 1000;
await new Promise((resolve) =>
setTimeout(resolve, baseDelay + jitter)
);
},
});El backoff exponencial con jitter evita problemas de estampida cuando un servidor se reinicia y todos los clientes intentan reconectarse al mismo tiempo. El jitter distribuye los intentos de reconexión a lo largo de una ventana de tiempo en lugar de concentrarlos en el mismo instante.
Conclusiones clave
Las suscripciones de GraphQL transforman tu API de request-response a event-driven. La arquitectura requiere una configuración de transporte dual —HTTP para consultas y mutaciones, WebSocket para suscripciones— con esquema y autenticación compartidos.
El filtrado del lado del servidor con withFilter es la clave del rendimiento. Sin él, cada cliente conectado recibe cada evento, lo cual no escala. Un PubSub respaldado por Redis permite despliegues multi-instancia compartiendo eventos entre procesos del servidor.
En el cliente, combina consultas iniciales con suscripciones para la mejor experiencia de usuario: carga el estado actual inmediatamente y luego aplica actualizaciones en vivo de forma incremental. Siempre configura keepalive y backoff exponencial para la conexión WebSocket: las desconexiones silenciosas son el problema más común en producción con suscripciones.


