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:
| Limit | Default | Description |
|---|---|---|
max_active_total | 20,000 | Total active subscriptions across all clients |
max_active_per_tenant | 2,000 | Per-tenant subscription cap |
max_active_per_ip | 200 | Per-IP subscription cap |
max_active_per_connection | 50 | Per-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:
- Detects
@pollin the query - Strips the directive (subgraphs never see it)
- Upgrades the response to SSE (
Content-Type: text/event-stream) - Re-executes the clean query every N seconds (through the full pipeline: auth, rate limits, PII masking, etc.)
- Sends each result as an SSE event
Directive Options
| Option | Type | Default | Description |
|---|---|---|---|
interval | Duration | 5s | Polling interval (2s, 500ms, 1m) |
maxUpdates | Integer | ∞ | Stop after N updates |
maxDuration | Duration | ∞ | Stop 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}
| Event | Meaning |
|---|---|
connected | Stream opened, includes poll config |
next | Query result (same shape as a normal response) |
error | Transient execution error (stream continues) |
complete | Stream 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
| Client | How |
|---|---|
fetch() | response.body.getReader() |
curl | curl -N (no buffering) |
| urql | Custom exchange that reads SSE |
| Relay | Custom network layer |
Python requests | stream=True + iterate lines |
@poll vs Subscriptions
@poll | Subscriptions | |
|---|---|---|
| Use case | Dashboards, status pages, periodic refresh | Chat, notifications, real-time push |
| Server state | None (stateless) | WebSocket per client |
| Memory | O(1) per stream | O(connections) |
| Protocol | SSE over HTTP POST | SSE or WebSocket |
| Backend changes | None — works with any query | Requires subscription resolvers |
| Data freshness | Every N seconds | Instant push |
| Client complexity | Replace data on each event | Handle 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 Code | HTTP Status | Meaning |
|---|---|---|
POLL_DISABLED | 400 | poll.enabled is false in config |
POLL_PARSE_ERROR | 400 | Malformed @poll directive |
POLL_LIMIT_EXCEEDED | 429 | Global 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
- Incremental Delivery —
@deferand@streamfor progressive loading - Configuration — Full subscription and poll config
- Plugins & Coprocessors — Extend request lifecycle