GraphQL Subscriptions: Real-time synchronizácia dát
GraphQL Subscriptions: Real-time synchronizácia dát
Predstavme si klasický scenár: používateľ 1 upraví záznam v databáze, jeho UI sa okamžite aktualizuje (optimistic alebo pessimistic update), ale používateľ 2, ktorý má otvorený ten istý záznam, o zmene vôbec nevie. Jeho obrazovka zobrazuje zastaralé dáta a nevie, že niekto iný už záznam zmenil. Toto je jeden z najčastejších problémov moderných web aplikácií - nedostatok real-time synchronizácie medzi klientmi.
Problém: Zastaralé dáta v multi-user prostredí
Konkrétny príklad
Pracujete na projektovom nástroji typu Jira alebo Trello:
Používateľ A
Používateľ B
Používateľ B začne task upravovať, nevedomý že už je označený ako hotový
Konflikt!
Ďalšie kritické scenáre
E-commerce:
- Používateľ A pridá posledný kus produktu do košíka
- Používateľ B stále vidí produkt ako dostupný
- Obaja sa pokúsia dokončiť objednávku → konflikt inventory
Collaborative editing (Google Docs style):
- Viacero používateľov upravuje dokument
- Bez real-time synchronizácie chaos a prepísané zmeny
Dashboardy a analytics:
- Manager pozerá real-time metriky
- Dáta sa aktualizujú len pri refresh stránky
- Nevidí kritické udalosti (výpadok, spike v trafficu)
Tradičné riešenia a ich problémy
1. Polling - Pravidelné dopytovanie servera
Najjednoduchšie, ale najhoršie riešenie:
// Každých 5 sekúnd fetchni nové dáta
setInterval(async () => {
const response = await fetch('/api/tasks/123');
const data = await response.json();
updateUI(data);
}, 5000);Problémy:
- ❌ Throttling vlastného servera - tisícky klientov bombardujú server každých 5s
- ❌ Latencia - zmena sa prejav až o 0-5 sekúnd (v priemere 2.5s)
- ❌ Zbytočné requesty - 95% requestov vráti "nič sa nezmenilo"
- ❌ Bandwidth waste - prenášame tie isté dáta dookola
- ❌ Battery drain na mobile zariadeniach
- ❌ Škálovateľnosť - čím viac userov, tým väčší chaos
Výpočet zaťaženia:
1000 aktívnych používateľov
× polling každých 5s
= 200 requestov/s na server
= 17,280,000 requestov/deňA väčšina z týchto requestov je úplne zbytočná! 🔥
2. Long Polling
Klient pošle request, server ho drží otvorený a odpovie len keď má nové dáta:
async function longPoll() {
const response = await fetch('/api/tasks/123/changes');
const data = await response.json();
updateUI(data);
longPoll(); // Hneď znova
}Problémy:
- ⚠️ Lepšie ako polling, ale stále HTTP overhead
- ⚠️ Timeouty - connection môže expirovat
- ⚠️ Proxy servery môžu prerušiť spojenie
3. Server-Sent Events (SSE)
Unidirectional stream od servera ku klientovi:
const eventSource = new EventSource('/api/tasks/123/stream');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
updateUI(data);
};Výhody:
- ✅ Automatické reconnect
- ✅ Built-in browser API
- ✅ Jednoduchšia implementácia ako WebSockets
Problémy:
- ❌ Len server → client (nie bidirectional)
- ❌ Limity na počet otvorených connections v prehliadači
- ❌ HTTP/1.1 problém (blokuje connection)
4. WebSockets
Bidirectional, full-duplex communication:
const ws = new WebSocket('ws://localhost:4000');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
updateUI(data);
};Výhody:
- ✅ Bidirectional communication
- ✅ Low latency
- ✅ Efektívne pre real-time
Problémy:
- ⚠️ Komplikovaná implementácia
- ⚠️ Custom protocol - musíte si definovať message format
- ⚠️ Authentication je komplikovanejšia
- ⚠️ Scaling (load balancing, sticky sessions)
GraphQL Subscriptions: Best of both worlds
GraphQL Subscriptions kombinuje silu WebSockets s elegantnosťou GraphQL:
subscription TaskUpdated($taskId: ID!) {
taskUpdated(taskId: $taskId) {
id
title
status
assignee {
id
name
avatar
}
updatedAt
updatedBy {
name
}
}
}Kľúčové výhody
1. Selektívne subscriptions - Žiadaj len to, čo potrebuješ
Na rozdiel od pollingu, kde fetchuješ všetky dáta:
// Polling - Fetches všetko
fetch('/api/tasks/123') // Returns 50+ fields
// GraphQL Subscription - Len relevantné polia
subscription {
taskUpdated(taskId: "123") {
status // Zmena statusu
assignee { id name } // Zmena assignee
# Ignorujeme description, comments, attachments...
}
}Benefit: Ak vieš, že description a comments sa nemenia často, nemusíš ich includovať → menej bandwidth, rýchlejšie UI updates.
2. Event-driven vs Polling
Polling model:
T=0s → Request → Response (no change)
T=5s → Request → Response (no change)
T=10s → Request → Response (CHANGE!)
Latencia: 0-5s (priemer 2.5s)
Subscription model:
T=0s → Subscribe
T=10s → Event occurs → Push (CHANGE!)
Latencia: ~50ms3. Inteligentné filterovanie
Môžeš sa prihlásiť len na špecifické eventy:
subscription {
# Len tasky priradené mne
taskUpdated(assignedTo: $myUserId) {
id
title
status
}
# Len high priority tasky
taskCreated(priority: HIGH) {
id
title
}
# Len zmeny v konkrétnom projekte
projectUpdated(projectId: $projectId) {
stats {
tasksCompleted
tasksInProgress
}
}
}Výsledok: Klient dostáva len relevantné updates, nie "všetko čo sa stalo".
Ako GraphQL Subscriptions fungujú: Pod kapotou
Architektúra
┌─────────────┐ WebSocket ┌─────────────┐
│ │◄──────────────────────────►│ │
│ Client │ 1. Subscribe request │ Server │
│ (Browser) │ 2. Event notifications │ (GraphQL) │
│ │ 3. Unsubscribe │ │
└─────────────┘ └──────┬──────┘
│
┌──────▼──────┐
│ PubSub │
│ System │
│ (Redis, │
│ RabbitMQ) │
└─────────────┘Životný cyklus subscription
1. Client inicializuje subscription
// Apollo Client
import { useSubscription, gql } from '@apollo/client';
const TASK_UPDATED = gql`
subscription OnTaskUpdated($taskId: ID!) {
taskUpdated(taskId: $taskId) {
id
status
assignee { name }
}
}
`;
function TaskDetail({ taskId }) {
const { data, loading } = useSubscription(TASK_UPDATED, {
variables: { taskId }
});
if (loading) return <p>Connecting...</p>;
return <TaskCard task={data.taskUpdated} />;
}2. Server resolver sa pripojí na PubSub
// Apollo Server
const resolvers = {
Subscription: {
taskUpdated: {
subscribe: (_, { taskId }, { pubsub }) => {
// Prihlás sa na channel pre tento task
return pubsub.asyncIterator([`TASK_UPDATED_${taskId}`]);
}
}
},
Mutation: {
updateTask: async (_, { taskId, input }, { pubsub, db }) => {
// Aktualizuj task v DB
const updatedTask = await db.tasks.update(taskId, input);
// Publikuj event
await pubsub.publish(`TASK_UPDATED_${taskId}`, {
taskUpdated: updatedTask
});
return updatedTask;
}
}
};3. WebSocket connection lifecycle
// graphql-ws library (moderná implementácia)
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'ws://localhost:4000/graphql',
// Authentication
connectionParams: async () => {
const token = await getAuthToken();
return {
authorization: token ? `Bearer ${token}` : ""
};
},
// Reconnect logic
retryAttempts: 5,
retryWait: (retries) => Math.min(1000 * 2 ** retries, 30000),
// Heartbeat (ping/pong)
keepAlive: 10000
});Implementácia: Praktický príklad
Backend (Node.js + Apollo Server)
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { createServer } from 'http';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
// Schema
const typeDefs = `#graphql
type Task {
id: ID!
title: String!
status: TaskStatus!
assignee: User
updatedAt: String!
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
type User {
id: ID!
name: String!
}
type Query {
task(id: ID!): Task
}
type Mutation {
updateTaskStatus(id: ID!, status: TaskStatus!): Task!
}
type Subscription {
taskUpdated(taskId: ID!): Task!
taskStatusChanged(projectId: ID!): Task!
}
`;
// Resolvers
const resolvers = {
Subscription: {
taskUpdated: {
subscribe: (_, { taskId }) =>
pubsub.asyncIterator([`TASK_${taskId}`])
},
taskStatusChanged: {
subscribe: (_, { projectId }) =>
pubsub.asyncIterator([`PROJECT_${projectId}_TASKS`])
}
},
Mutation: {
updateTaskStatus: async (_, { id, status }, { db }) => {
const task = await db.tasks.update(id, { status });
// Publikuj na všetky relevantné channels
await pubsub.publish(`TASK_${id}`, { taskUpdated: task });
await pubsub.publish(
`PROJECT_${task.projectId}_TASKS`,
{ taskStatusChanged: task }
);
return task;
}
}
};
// Server setup
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = createServer(app);
// WebSocket server pre subscriptions
const wsServer = new WebSocketServer({
server: httpServer,
path: '/graphql'
});
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', cors(), json(), expressMiddleware(server));
httpServer.listen(4000, () => {
console.log(`🚀 Server ready at http://localhost:4000/graphql`);
console.log(`🔌 Subscriptions ready at ws://localhost:4000/graphql`);
});Frontend (React + Apollo Client)
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
// HTTP link pre queries a mutations
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql'
});
// WebSocket link pre subscriptions
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
connectionParams: {
authToken: localStorage.getItem('token')
}
})
);
// Split traffic: subscriptions → WS, queries/mutations → HTTP
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache()
});
// Component
function TaskBoard({ projectId }) {
const { data: initialData } = useQuery(GET_TASKS, {
variables: { projectId }
});
const { data: subscriptionData } = useSubscription(
TASK_STATUS_CHANGED,
{ variables: { projectId } }
);
// Merge initial data + real-time updates
const tasks = subscriptionData?.taskStatusChanged
? updateTaskInList(initialData.tasks, subscriptionData.taskStatusChanged)
: initialData?.tasks;
return <TaskList tasks={tasks} />;
}Backend implementácia: Java/Spring Boot
@Configuration
public class GraphQLSubscriptionConfig {
@Bean
public Executor subscriptionExecutor() {
return Executors.newCachedThreadPool();
}
}
@Component
public class TaskSubscription {
private final Sinks.Many<Task> taskSink = Sinks.many()
.multicast()
.onBackpressureBuffer();
@SubscriptionMapping
public Flux<Task> taskUpdated(@Argument String taskId) {
return taskSink.asFlux()
.filter(task -> task.getId().equals(taskId));
}
@MutationMapping
public Task updateTaskStatus(
@Argument String id,
@Argument TaskStatus status
) {
Task task = taskService.updateStatus(id, status);
// Emit event
taskSink.tryEmitNext(task);
return task;
}
}Optimalizácie a Best Practices
1. Batching a Deduplication
Ak viacero userov upravuje ten istý záznam:
// Naivná implementácia: 10 updates = 10 broadcast messages
for (let i = 0; i < 10; i++) {
await pubsub.publish('TASK_123', { taskUpdated });
}
// Optimalizovaná: debounce + batch
const debouncedPublish = debounce(async (taskId, task) => {
await pubsub.publish(`TASK_${taskId}`, { taskUpdated: task });
}, 100);2. Connection Pooling
Redis PubSub pre distribuované systémy:
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const options = {
host: 'localhost',
port: 6379,
retryStrategy: (times) => Math.min(times * 50, 2000)
};
const pubsub = new RedisPubSub({
publisher: new Redis(options),
subscriber: new Redis(options)
});3. Authorization na úrovni subscription
const resolvers = {
Subscription: {
taskUpdated: {
subscribe: withFilter(
(_, { taskId }, { pubsub }) =>
pubsub.asyncIterator([`TASK_${taskId}`]),
// Filter: Pošli len ak má user permissions
async (payload, variables, context) => {
const task = payload.taskUpdated;
const user = context.user;
return await hasAccess(user, task);
}
)
}
}
};4. Cleanup pri disconnect
const wsServer = new WebSocketServer({ server: httpServer });
useServer({
schema,
context: (ctx) => {
// Track active subscriptions
return {
connectionId: ctx.connectionParams?.id,
cleanup: () => {
console.log(`Client ${ctx.connectionParams?.id} disconnected`);
// Cleanup resources
}
};
},
onDisconnect: (ctx) => {
ctx.cleanup?.();
}
}, wsServer);Real-world Use Cases
1. Collaborative Text Editor
subscription DocumentChanged($documentId: ID!) {
documentChanged(documentId: $documentId) {
content
cursor {
userId
position
}
version
}
}2. Live Chat
subscription NewMessage($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
text
author {
id
name
avatar
}
timestamp
}
}3. Stock Ticker / Trading Platform
subscription StockPriceUpdates($symbols: [String!]!) {
stockPrices(symbols: $symbols) {
symbol
price
change
timestamp
}
}4. Multiplayer Game State
subscription GameState($gameId: ID!) {
gameUpdated(gameId: $gameId) {
players {
id
position { x y }
health
}
items {
id
type
position { x y }
}
}
}5. IoT Device Monitoring
subscription DeviceMetrics($deviceId: ID!) {
metricsUpdated(deviceId: $deviceId) {
temperature
humidity
batteryLevel
status
timestamp
}
}GraphQL Subscriptions vs Alternatives
| Feature | Polling | SSE | WebSockets | GraphQL Subs |
|---|---|---|---|---|
| Latencia | 2-5s | ~100ms | ~50ms | ~50ms |
| Server load | ❌ Vysoký | ✅ Nízky | ✅ Nízky | ✅ Nízky |
| Bidirectional | ❌ | ❌ | ✅ | ✅ |
| Type safety | ⚠️ | ⚠️ | ❌ | ✅ |
| Selective fields | ⚠️ | ❌ | ❌ | ✅ |
| Auto reconnect | ✅ | ✅ | ⚠️ | ✅ |
| Browser support | ✅ | ✅ | ✅ | ✅ |
| Complexity | 🟢 Jednoduché | 🟡 Stredné | 🔴 Zložité | 🟡 Stredné |
Kedy použiť GraphQL Subscriptions?
✅ Použite ak:
-
Potrebujete real-time updates medzi viacerými klientmi
- Collaborative tools (Figma, Google Docs)
- Project management (Jira, Asana)
- Chat aplikácie
-
Dáta sa menia nepredvídateľne
- Stock prices
- IoT sensor data
- Live sports scores
-
Chcete redukovať server load
- Nahradiť polling tisícov klientov
- Posielať len zmeny, nie celé datasety
-
Potrebujete type-safe real-time
- TypeScript/Flow projekty
- Chcete autocomplete pre subscription queries
❌ Nepoužívajte ak:
-
Jednoduché aplikácie bez multi-user interakcií
- Personal dashboard, ktorý vidí len jeden user
-
Dáta sa menia veľmi zriedka
- Konfigurácia, settings
- Statický content
-
SEO/crawling je priorita
- WebSockets nie sú crawlable
- Použite SSR + HTTP
-
Nemáte kapacitu na maintain komplexnosť
- GraphQL Subscriptions vyžadujú dobrú infraštruktúru
- PubSub, Redis, WebSocket management
Záver
GraphQL Subscriptions riešia fundamentálny problém moderných web aplikácií: ako efektívne synchronizovať dáta medzi viacerými klientmi v reálnom čase.
Kľúčové výhody:
- ✅ Event-driven namiesto throttlingu servera pollingom
- ✅ Selektívne dáta - dopyt len to, čo potrebuješ
- ✅ Type-safe a autocomplete
- ✅ Bidirectional communication cez WebSockets
- ✅ Škálovateľné s Redis PubSub
Kedy to dáva zmysel: Ak máte multi-user aplikáciu, kde zmeny jedného používateľa musia byť okamžite viditeľné pre ostatných (collaborative tools, dashboardy, chat, real-time analytics), GraphQL Subscriptions sú to pravé riešenie.
Ak len chcete periodicky refresh dát a nemáte požiadavky na real-time, klasický polling alebo server-side rendering môže stačiť.
Pamätajte: GraphQL Subscriptions nie sú silver bullet, ale pre správne use cases dokážu dramaticky zlepšiť UX a redukovať server load.
Pre viac info navštívte graphql.org/blog/subscriptions.