Zum Inhalt springen

GraphQL-Subscriptions für Echtzeit-Features

Praxisnahe Anleitung zu GraphQL-Subscriptions für Echtzeit-Updates: WebSocket-Transport, Subscription-Resolver, Filterung und Skalierung.

4 Min. Lesezeit
Diagramm eines GraphQL-Subscription-Ablaufs, das zeigt, wie der Server Echtzeit-Updates an verbundene Clients pusht

REST-APIs folgen einem Request-Response-Muster — der Client fragt, der Server antwortet. Für Echtzeit-Features wie Live-Benachrichtigungen, Chat-Nachrichten oder Dashboard-Updates bricht dieses Modell zusammen. Entweder pollst du permanent (was Bandbreite verschwendet und die Latenz erhöht) oder du setzt auf einen Push-Mechanismus. GraphQL-Subscriptions geben dir Push-Semantik mit derselben Typsicherheit und demselben schemagetriebenen Design wie Queries und Mutations.

Subscriptions nutzen unter der Haube WebSocket-Verbindungen. Der Client deklariert, welche Events ihn interessieren, und der Server pusht Daten über die offene Verbindung, sobald diese Events auftreten.

Den Subscription-Server aufsetzen

Die meisten GraphQL-Server unterstützen Subscriptions über das graphql-ws-Protokoll. Hier ein Setup mit Apollo Server und 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);

Das entscheidende Detail: HTTP bedient Queries und Mutations, WebSocket bedient Subscriptions — beide über denselben /graphql-Endpunkt. Die Bibliothek graphql-ws verwaltet den Lebenszyklus des WebSockets: Verbindungs-Handshake, Message-Framing und Keep-alive-Pings.

Subscription-Typen definieren

Subscriptions werden in deinem Schema neben Queries und Mutations definiert. Sie beschreiben, welche Events Clients abonnieren können und welche Datenform sie dabei erhalten.

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
}

Subscription-Resolver implementieren

Subscription-Resolver arbeiten nach dem AsyncIterator-Muster. Du veröffentlichst Events aus deiner Business-Logik, und der Resolver filtert sie und stellt sie den richtigen Subscribern zu.

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

Subscriptions auf der Client-Seite behandeln

Auf dem Client fügen sich Subscriptions in deinen bestehenden GraphQL-Client ein. Hier eine React-Komponente mit 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;
}

Subscriptions in Produktion skalieren

Das In-Memory-PubSub aus graphql-subscriptions reicht für eine einzelne Serverinstanz. In Produktion, mit mehreren Instanzen hinter einem Load Balancer, brauchst du ein verteiltes Pub/Sub-Backend.

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

Ein Redis-gestütztes Pub/Sub stellt sicher, dass ein von irgendeiner Serverinstanz veröffentlichtes Event alle Subscriber erreicht — unabhängig davon, mit welcher Instanz sie verbunden sind. Das ist das Standardmuster für horizontal skalierte Subscription-Server.

Die wichtigsten Punkte

  1. Subscriptions ergänzen GraphQL um Push-Semantik — Clients deklarieren ihr Interesse, Server pushen Daten über persistente WebSocket-Verbindungen
  2. Nutze withFilter, damit Subscriber nur die Events erhalten, die zu den Argumenten ihrer Query passen
  3. Payload-Keys müssen den Feldnamen der Subscription entsprechen — bei Abweichungen wird stillschweigend null zugestellt
  4. Ersetze In-Memory-PubSub durch Redis, sobald mehrere Serverinstanzen hinter einem Load Balancer laufen
  5. Erzwinge Verbindungslimits und Authentifizierung auf WebSocket-Verbindungen, um Ressourcenerschöpfung zu vermeiden
  6. Subscriptions ergänzen Queries, sie ersetzen sie nicht — der initiale Load läuft über eine Query, danach streamen Subscriptions die inkrementellen Updates
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX