Реалізація 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 дні. Вартість проєкту розраховується індивідуально — економія на розробці за рахунок готових рішень.
Замовте впровадження підписок — наші інженери допоможуть з архітектурою. Отримайте консультацію: напишіть нам.







