HyperRoute

Subscriptions & Polling

HyperRoute supports two approaches to real-time data: traditional GraphQL subscriptions (SSE and WebSocket) and the @poll directive — a unique feature that turns any query into a real-time stream with zero backend changes.


GraphQL Subscriptions

Server-Sent Events (SSE)

The default transport. Lightweight, HTTP-based, works through proxies and load balancers:

curl -N http://localhost:4000/graphql/stream \
  -H "Content-Type: application/json" \
  -d '{"query": "subscription { messageAdded { id text } }"}'

Response stream:

event: connected
data: {"subscriptionId":"550e8400-e29b-41d4-a716-446655440000"}

event: next
data: {"data":{"messageAdded":{"id":"1","text":"Hello"}}}

event: next
data: {"data":{"messageAdded":{"id":"2","text":"World"}}}

event: complete
data: {}

WebSocket

For bidirectional communication. Uses the graphql-ws protocol:

import { createClient } from 'graphql-ws';

const client = createClient({
  url: 'ws://localhost:4000/graphql',
});

const unsubscribe = client.subscribe(
  { query: 'subscription { messageAdded { id text } }' },
  {
    next: (data) => console.log('Received:', data),
    error: (err) => console.error('Error:', err),
    complete: () => console.log('Subscription complete'),
  }
);

Subscription Limits

Configurable limits prevent resource exhaustion:

LimitDefaultDescription
max_active_total20,000Total active subscriptions across all clients
max_active_per_tenant2,000Per-tenant subscription cap
max_active_per_ip200Per-IP subscription cap
max_active_per_connection50Per-connection subscription cap

Configuration

subscriptions:
  enabled: true
  transport: sse          # sse | websocket | both
  path: /graphql/stream
  max_active_total: 20000
  max_active_per_tenant: 2000
  max_active_per_ip: 200
  max_active_per_connection: 50

@poll Directive — Real-time Polling

Turn any query into a real-time polling stream with a single directive. No subscription resolvers, no WebSocket setup, no backend changes.

How It Works

Add @poll to any query — the router re-executes it at the specified interval and streams results back over SSE:

query Dashboard @poll(interval: 5s) {
  activeUsers
  revenue
  orders { count status }
}

The router:

  1. Detects @poll in the query
  2. Strips the directive (subgraphs never see it)
  3. Upgrades the response to SSE (Content-Type: text/event-stream)
  4. Re-executes the clean query every N seconds (through the full pipeline: auth, rate limits, PII masking, etc.)
  5. Sends each result as an SSE event

Directive Options

OptionTypeDefaultDescription
intervalDuration5sPolling interval (2s, 500ms, 1m)
maxUpdatesIntegerStop after N updates
maxDurationDurationStop after elapsed time (30s, 5m)
# Stop after 60 updates
query OrderStatus @poll(interval: 2s, maxUpdates: 60) {
  order(id: "abc") { status }
}

# Stop after 5 minutes
query LiveFeed @poll(interval: 10s, maxDuration: 5m) {
  latestPosts { title author }
}

# Minimal — uses default 5s interval
query Health @poll {
  __typename
}

Endpoint

@poll works on the same /graphql endpoint as regular queries. No separate URL needed.

curl -N -X POST http://localhost:4000/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "query @poll(interval: 3s, maxUpdates: 5) { __typename }"}'

SSE Protocol

event: connected
data: {"type":"poll","interval_ms":5000,"max_updates":null}

event: next
data: {"data":{"activeUsers":42,"revenue":1234.56}}

event: next
data: {"data":{"activeUsers":43,"revenue":1235.00}}

event: complete
data: {"reason":"maxUpdates reached","total_updates":60}
EventMeaning
connectedStream opened, includes poll config
nextQuery result (same shape as a normal response)
errorTransient execution error (stream continues)
completeStream ended (maxUpdates, maxDuration, or server shutdown)

Client Integration

Vanilla JavaScript

const response = await fetch('/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `query Dashboard @poll(interval: 5s) {
      activeUsers
      revenue
    }`
  })
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  const text = decoder.decode(value);
  for (const line of text.split('\n')) {
    if (line.startsWith('data: ')) {
      const data = JSON.parse(line.slice(6));
      updateDashboard(data);
    }
  }
}

Apollo Client

Apollo's HttpLink calls response.json() — it doesn't know how to read an SSE stream. Use PollLink:

Step 1: Create src/poll-link.ts with the following content:

/**
 * HyperRoute PollLink for Apollo Client
 *
 * Enables the @poll directive with Apollo Client.
 * Copy this file into your project and add PollLink to your link chain.
 *
 * Setup:
 *
 *   import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client';
 *   import { PollLink } from './poll-link';
 *
 *   const client = new ApolloClient({
 *     link: ApolloLink.from([
 *       new PollLink(),
 *       new HttpLink({ uri: '/graphql' }),
 *     ]),
 *     cache: new InMemoryCache(),
 *   });
 *
 * Then just add @poll to any query:
 *
 *   const { data } = useQuery(gql`
 *     query Dashboard @poll(interval: 5s) {
 *       activeUsers
 *       revenue
 *     }
 *   `);
 *
 * No extra dependencies. No endpoint changes. Same /graphql URL.
 */

import {
  ApolloLink,
  Observable,
  Operation,
  FetchResult,
} from '@apollo/client/core';
import { hasDirectives } from '@apollo/client/utilities';
import { print } from 'graphql';

export class PollLink extends ApolloLink {
  private uri: string;
  private headers: Record<string, string>;

  constructor(opts: { uri?: string; headers?: Record<string, string> } = {}) {
    super();
    this.uri = opts.uri ?? '/graphql';
    this.headers = opts.headers ?? {};
  }

  request(operation: Operation): Observable<FetchResult> | null {
    if (!hasDirectives(['poll'], operation.query)) {
      return null; // not a @poll query — pass to next link
    }

    return new Observable<FetchResult>((observer) => {
      const abort = new AbortController();

      this.startStream(operation, abort.signal, observer).catch((err) => {
        if (err.name !== 'AbortError') observer.error(err);
      });

      return () => abort.abort();
    });
  }

  private async startStream(
    operation: Operation,
    signal: AbortSignal,
    observer: ZenObservable.SubscriptionObserver<FetchResult>,
  ): Promise<void> {
    const response = await fetch(this.uri, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'text/event-stream',
        ...this.headers,
        ...operation.getContext().headers,
      },
      body: JSON.stringify({
        query: print(operation.query),
        variables: operation.variables,
        operationName: operation.operationName,
      }),
      signal,
    });

    const ct = response.headers.get('content-type') ?? '';
    if (!response.ok || ct.includes('application/json')) {
      const err = await response.json();
      observer.error(
        new Error(err.errors?.[0]?.message ?? response.statusText),
      );
      return;
    }

    const reader = response.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const parts = buffer.split('\n\n');
      buffer = parts.pop() ?? '';

      for (const part of parts) {
        let eventType = 'message';
        let data = '';
        for (const line of part.split('\n')) {
          if (line.startsWith('event:')) eventType = line.slice(6).trim();
          else if (line.startsWith('data:')) data += line.slice(5).trim();
        }
        if (!data) continue;

        if (eventType === 'next') {
          observer.next(JSON.parse(data) as FetchResult);
        } else if (eventType === 'error') {
          observer.error(
            new Error(JSON.parse(data).errors?.[0]?.message ?? 'Poll error'),
          );
          return;
        } else if (eventType === 'complete') {
          observer.complete();
          return;
        }
      }
    }

    observer.complete();
  }
}

Step 2: Add PollLink to your link chain:

import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client';
import { PollLink } from './poll-link';

const client = new ApolloClient({
  link: ApolloLink.from([
    new PollLink(),                     // intercepts @poll → reads SSE
    new HttpLink({ uri: '/graphql' }),  // everything else → reads JSON
  ]),
  cache: new InMemoryCache(),
});

Step 3: Use @poll in your queries — useQuery just works:

import { gql, useQuery } from '@apollo/client';

const DASHBOARD = gql`
  query Dashboard @poll(interval: 5s, maxDuration: 10m) {
    activeUsers
    revenue
    orders { count status }
  }
`;

function Dashboard() {
  const { data, loading, error } = useQuery(DASHBOARD);

  if (loading) return <p>Connecting…</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>{data.activeUsers} active users</h1>
      <p>Revenue: ${data.revenue}</p>
    </div>
  );
}

Other Clients

ClientHow
fetch()response.body.getReader()
curlcurl -N (no buffering)
urqlCustom exchange that reads SSE
RelayCustom network layer
Python requestsstream=True + iterate lines

@poll vs Subscriptions

@pollSubscriptions
Use caseDashboards, status pages, periodic refreshChat, notifications, real-time push
Server stateNone (stateless)WebSocket per client
MemoryO(1) per streamO(connections)
ProtocolSSE over HTTP POSTSSE or WebSocket
Backend changesNone — works with any queryRequires subscription resolvers
Data freshnessEvery N secondsInstant push
Client complexityReplace data on each eventHandle WS lifecycle

@poll Configuration

poll:
  enabled: true
  min_interval_secs: 1      # Floor (prevents abuse)
  max_interval_secs: 300    # Ceiling (5 minutes max)
  default_interval_secs: 5  # When @poll has no interval arg
  max_per_connection: 5     # Concurrent polls per connection
  max_global: 5000          # Global cap across all connections

Error Codes

Error CodeHTTP StatusMeaning
POLL_DISABLED400poll.enabled is false in config
POLL_PARSE_ERROR400Malformed @poll directive
POLL_LIMIT_EXCEEDED429Global poll stream limit reached

Transient errors during a poll tick are sent as event: error — the stream continues and the next tick retries.

Playground

The built-in playground at /graphql supports @poll out of the box. Paste a @poll query, click Run Query, and the response panel shows events as they arrive. Click Stop Stream to cancel.


Next Steps