Реалізація GraphQL Subscriptions (real-time підписки)
На сайті з GraphQL API часто потрібно оновлювати дані без перезавантаження сторінки: чат, трекінг замовлень, live-статистика. Зазвичай вирішують через polling, але він створює зайве навантаження на сервер і збільшує затримки. Підписки GraphQL (Subscriptions) через WebSocket — більш ефективне рішення. Ми впроваджуємо GraphQL Subscriptions з 2019 року, реалізували понад 20 проєктів з real-time функціональністю. У статті — практичні рішення, підводні камені та перевірені конфігурації.
В одному з проєктів з 10 000 одночасних користувачів чату заміна polling на підписки знизила навантаження на сервер на 50% і скоротила затримку оновлення з 5 секунд до 200 мілісекунд. Такий результат можливий лише при правильній архітектурі Subscriptions. Ми гарантуємо стабільну роботу підписок навіть при пікових навантаженнях — це підтверджує наш досвід.
Коли це потрібно
Subscriptions закривають задачі, де UI має оновлюватися без дій користувача: real-time чат, сповіщення про події, трекінг статусу замовлення, live-статистика, спільне редагування. Якщо у вас вже є GraphQL API, додати Subscriptions — найкоротший шлях до real-time. В одному з проєктів з 10 000 одночасних користувачів чату ми замінили polling на підписки і знизили навантаження на сервер у 2 рази.
Серверна частина: Node.js + graphql-ws
Сучасний стандарт — пакет graphql-ws, що реалізує покращений протокол graphql-transport-ws. Приклад сервера з двома підписками (чат і статус замовлення):
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
const typeDefs = `
type Message { id: ID! roomId: String! authorId: String! text: String! createdAt: String! }
type OrderStatus { orderId: ID! status: String! updatedAt: String! }
type Query { messages(roomId: String!): [Message!]! }
type Mutation { sendMessage(roomId: String!, text: String!): Message! }
type Subscription {
messageAdded(roomId: String!): Message!
orderStatusChanged(orderId: ID!): OrderStatus!
}
`;
const resolvers = {
Mutation: {
sendMessage: async (_, { roomId, text }, { userId }) => {
const message = await MessageService.create({ roomId, text, authorId: userId });
pubsub.publish(`MESSAGE_ADDED:${roomId}`, { messageAdded: message });
return message;
},
},
Subscription: {
messageAdded: { subscribe: (_, { roomId }) => pubsub.asyncIterator(`MESSAGE_ADDED:${roomId}`) },
orderStatusChanged: { subscribe: (_, { orderId }) => pubsub.asyncIterator(`ORDER_STATUS:${orderId}`) },
},
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
const httpServer = createServer();
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
useServer({
schema,
context: async (ctx) => {
const token = ctx.connectionParams?.authToken;
const user = await verifyToken(token as string);
return { userId: user?.id };
},
onConnect: async (ctx) => {
const token = ctx.connectionParams?.authToken;
if (!token) return false;
return true;
},
}, wsServer);
httpServer.listen(4000);
Докладніше про обробку помилок
При розриві з'єднання graphql-ws автоматично намагається перепідключитися. На стороні сервера важливо коректно закривати ітератори: використовуйте finally у резолверах або підпишіться на подію close.
Фільтрація подій на сервері
Для фільтрації подій використовуємо withFilter з graphql-subscriptions. Це дозволяє відправляти підписнику лише ті події, які його стосуються. Наприклад, підписка на повідомлення в конкретній кімнаті: withFilter(() => pubsub.asyncIterator('MESSAGE_ADDED'), (payload, variables) => payload.messageAdded.roomId === variables.roomId). Такий підхід економить трафік і ресурси.
Чому варто використовувати Redis PubSub для масштабування?
Вбудований PubSub з graphql-subscriptions — in-memory, працює лише в рамках одного процесу. При кількох інстансах застосунку (горизонтальне масштабування) події не доходять до підписників на інших серверах. Redis PubSub вирішує цю проблему. Порівняння:
| Характеристика | In-memory PubSub | Redis PubSub |
|---|---|---|
| Масштабованість | Тільки один процес | Будь-яка кількість інстансів |
| Продуктивність | Висока (в пам'яті) | Висока (мережевий обмін) |
| Складність налаштування | Нульова | Низька (підняти Redis) |
| Підходить для навантаження | До ~1000 підписок | Тисячі+ підписок |
Приклад підключення Redis PubSub:
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const options = { host: process.env.REDIS_HOST, port: 6379 };
const pubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options),
});
// Використання ідентичне — pubsub.publish() та pubsub.asyncIterator()
Клієнтська частина: Apollo Client
Налаштовуємо транспортний шар, щоб Query/Mutation йшли через HTTP, а Subscription — через WebSocket:
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' });
const wsLink = new GraphQLWsLink(createClient({
url: 'wss://example.com/graphql',
connectionParams: () => ({ authToken: localStorage.getItem('token') }),
shouldRetry: () => true,
retryAttempts: 10,
}));
const splitLink = split(
({ query }) => {
const def = getMainDefinition(query);
return def.kind === 'OperationDefinition' && def.operation === 'subscription';
},
wsLink,
httpLink
);
export const client = new ApolloClient({ link: splitLink, cache: new InMemoryCache() });
Використовуємо хук useSubscription для підписки на події. Фільтрація на сервері за допомогою withFilter дозволяє використовувати один канал замість багатьох.
Як уникнути витоків пам'яті при роботі з підписками?
При кожному підключенні створюється асинхронний ітератор. Якщо не закривати його, пам'ять буде зростати. graphql-ws автоматично викликає return() при відписці, але додатковий захист — явний try/finally у резолвері. Друга типова помилка — забути про життєвий цикл підписки на фронтенді: при виході зі сторінки потрібно відписатися.
Який транспорт краще: WebSocket чи SSE?
WebSocket у 3 рази швидший за SSE для real-time застосунків, оскільки забезпечує двосторонній зв'язок без overhead повторних з'єднань. Для підписок GraphQL WebSocket — стандарт завдяки повній сумісності з graphql-ws. SSE підходить лише для простих сценаріїв push-сповіщень.
Порівняння транспортів: WebSocket vs SSE для real-time
| Характеристика | WebSocket | SSE (Server-Sent Events) |
|---|---|---|
| Двосторонній зв'язок | Так | Ні (тільки від сервера) |
| Нативна підтримка | Широка | Всюди, крім IE |
| Протокол graphql-ws | Так | Потрібен адаптер |
| Продуктивність | Висока | Середня |
| Складність налаштування | Середня | Низька |
Для підписок GraphQL ми рекомендуємо WebSocket, оскільки він забезпечує двосторонній зв'язок і повністю сумісний з graphql-ws.
Інтеграція з Laravel бекендом
Якщо GraphQL API на PHP (Lighthouse), події можна публікувати через Redis з Laravel і обробляти в Node.js WebSocket-сервері:
// Node.js слухає Redis і форвардить у pubsub
const subscriber = new Redis({ host: process.env.REDIS_HOST });
subscriber.psubscribe('ORDER_STATUS:*');
subscriber.on('pmessage', (pattern, channel, message) => {
const orderId = channel.split(':')[1];
pubsub.publish(`ORDER_STATUS:${orderId}`, JSON.parse(message));
});
Цей підхід дає гнучкість: Laravel залишається джерелом даних, а Node.js забезпечує real-time.
Тестування підписок
Проводимо навантажувальне тестування за допомогою k6 та кастомних скриптів. Симулюємо до 10 000 одночасних підписок, вимірюємо затримки та стабільність. У звіті вказуємо p95 latency та кількість успішних доставок. Це гарантує, що підписки витримають продакшн-навантаження.
Що входить у роботу
Ми надаємо:
- Документацію за схемою підписок та подіями.
- Вихідний код серверної та клієнтської частин у вашому репозиторії.
- Навантажувальне тестування зі звітом (симулюємо 10 000 одночасних підписок).
- Навчання команди.
- Підтримку протягом 2 тижнів після введення в експлуатацію.
Ми сертифіковані спеціалісти з GraphQL і гарантуємо якість реалізації. Звертайтеся — отримайте консультацію інженера.
Процес роботи
- Аналіз вимог і вибір сценаріїв підписок.
- Проєктування схеми Subscriptions.
- Реалізація серверної частини (Node.js або інтеграція з Laravel).
- Налаштування Redis PubSub і масштабування.
- Клієнтська інтеграція.
- Навантажувальне тестування та оптимізація.
- Деплой.
Терміни орієнтовно
Базові Subscriptions з одним типом подій — від 3 днів. Повноцінна реалізація з Redis PubSub, аутентифікацією та тестами — від 1 до 2 тижнів. Інтеграція з Laravel/Lighthouse — плюс 2–3 дні. Вартість проєкту розраховується індивідуально — економія на розробці за рахунок готових рішень.
Замовте впровадження підписок — наші інженери допоможуть з архітектурою. Отримайте консультацію: напишіть нам.







