Obsah článku / Contents
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:

1

Používateľ A

(project manager) zmení stav tasku z "In Progress" na "Done"
2

Používateľ B

(developer) má otvorený ten istý task a stále vidí stav "In Progress"
3

Používateľ B začne task upravovať, nevedomý že už je označený ako hotový

4

Konflikt!

Jeden z update-ov sa prepíše alebo aplikácia musí riešiť merge conflict

Ď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:

javascript
// 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:

javascript
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:

javascript
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:

javascript
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:

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:

javascript
// 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: ~50ms

3. Inteligentné filterovanie

Môžeš sa prihlásiť len na špecifické eventy:

graphql
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

javascript
// 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

javascript
// 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

javascript
// 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)

javascript
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)

javascript
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

java
@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:

javascript
// 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:

javascript
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

javascript
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

javascript
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

graphql
subscription DocumentChanged($documentId: ID!) {
  documentChanged(documentId: $documentId) {
    content
    cursor {
      userId
      position
    }
    version
  }
}

2. Live Chat

graphql
subscription NewMessage($channelId: ID!) {
  messageAdded(channelId: $channelId) {
    id
    text
    author {
      id
      name
      avatar
    }
    timestamp
  }
}

3. Stock Ticker / Trading Platform

graphql
subscription StockPriceUpdates($symbols: [String!]!) {
  stockPrices(symbols: $symbols) {
    symbol
    price
    change
    timestamp
  }
}

4. Multiplayer Game State

graphql
subscription GameState($gameId: ID!) {
  gameUpdated(gameId: $gameId) {
    players {
      id
      position { x y }
      health
    }
    items {
      id
      type
      position { x y }
    }
  }
}

5. IoT Device Monitoring

graphql
subscription DeviceMetrics($deviceId: ID!) {
  metricsUpdated(deviceId: $deviceId) {
    temperature
    humidity
    batteryLevel
    status
    timestamp
  }
}

GraphQL Subscriptions vs Alternatives

FeaturePollingSSEWebSocketsGraphQL Subs
Latencia2-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:

  1. Potrebujete real-time updates medzi viacerými klientmi

    • Collaborative tools (Figma, Google Docs)
    • Project management (Jira, Asana)
    • Chat aplikácie
  2. Dáta sa menia nepredvídateľne

    • Stock prices
    • IoT sensor data
    • Live sports scores
  3. Chcete redukovať server load

    • Nahradiť polling tisícov klientov
    • Posielať len zmeny, nie celé datasety
  4. Potrebujete type-safe real-time

    • TypeScript/Flow projekty
    • Chcete autocomplete pre subscription queries

❌ Nepoužívajte ak:

  1. Jednoduché aplikácie bez multi-user interakcií

    • Personal dashboard, ktorý vidí len jeden user
  2. Dáta sa menia veľmi zriedka

    • Konfigurácia, settings
    • Statický content
  3. SEO/crawling je priorita

    • WebSockets nie sú crawlable
    • Použite SSR + HTTP
  4. 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.

Všetky články