Build GraphQL Subscriptions over WebSockets
A complete GraphQL subscription service: WebSocket transport, auth on connect, pub-sub integration, backpressure, and delivery tests.
Project goal
Let clients subscribe to live updates over WebSockets, authenticate at connection time, and deliver only the events each subscriber is entitled to see.
Final features
- Authenticate on connect
- Topic subscription
- Per-event authorization
- Multi-instance fan-out
- Backpressure
Architecture
A GraphQL WebSocket server authenticates on connect, tracks each subscriber with its user and topic, and bridges a pub/sub channel into the async iterator. Authorization runs per event so a subscriber never receives another user updates.
Prerequisites
- Node.js 20 LTS or later
- Redis 7+ for multi-instance fan-out
- A GraphQL client that supports subscriptions
Dependencies
- graphql-yoga ^5.6.0 — HTTP and WebSocket GraphQL transport
- graphql-ws ^5.16.0 — Subscription protocol
- ioredis ^5.4.1 — Pub/sub fan-out
Directory structure
graphql-subscriptions/
├── src/server.ts
├── src/auth.ts
├── src/pubsub.ts
├── src/resolvers.ts
├── tests/subscription.test.ts
└── package.json
Phases
1. Authenticate on connect
Resolve the user from the connection params before accepting.
const user = await verify(connectionParams.token);
if (!user) return false; // close the socket
Expected behavior: An unauthenticated socket must not reach resolvers.
2. Topic subscription
Map each subscription to a topic and an async iterator.
const iterator = pubsub.subscribe('order:' + orderId);
Expected behavior: Topics decouple event producers from subscribers.
3. Per-event authorization
Verify the subscriber may see each event before yielding.
for await (const e of iterator) { if (allowed(user, e)) yield e; }
Expected behavior: A shared topic can carry events a given user must not see.
4. Multi-instance fan-out
Publish and subscribe through Redis so instances share events.
await publisher.publish('order:' + id, JSON.stringify(event));
Expected behavior: In-memory pub/sub only reaches clients on the same instance.
5. Backpressure
Drop or coalesce events for a slow subscriber.
if (socket.bufferedAmount > MAX) return coalesce(event);
Expected behavior: A slow consumer must not grow the send buffer without bound.
Testing
Connect an authenticated subscriber, publish to its topic, and assert receipt; connect a second user and assert it does not receive another user events.
Troubleshooting
User receives another user events
Authorization ran at subscribe time only; re-check per event.
Events stop in production
The proxy closed idle sockets; add a ping and raise the idle timeout.
Deployment
Terminate TLS at the proxy and forward Upgrade headers. Scale horizontally with a shared pub/sub and remove in-memory bridges. Set idle timeouts above the ping interval.
Known limitations
- Subscription payloads are single objects, not streams.
- No persisted replay for missed events.
- One transport; no SSE fallback.
Project overview
A subscription is a long-lived connection with a security model that must hold for its whole lifetime, not just at the handshake. This build authenticates on connect and authorizes every event, so a subscriber only ever sees what it is entitled to.
You will authenticate the WebSocket at connection time, track each subscriber with its user and topic, bridge a pub/sub channel into an async iterator, and check authorization on each event rather than only on subscribe.
Authorizing per event is the point: a subscription can outlive the permission that created it, and an implementation that only checks at subscribe leaks updates after a role changes.
The service is deliberately stateless per instance, with fan-out through a shared channel, so the connection count scales horizontally without a membership protocol.
Each phase adds one guarantee: authentication, topic tracking, per-event authorization, multi-instance fan-out, and backpressure, so the stream is safe at every stage.
The service exists because a subscription is a session, and sessions outlive the check that created them. It treats the connection as a potentially untrusted stream and filters every event server-side, so a revoked or mis-scoped subscriber cannot receive data it should no longer see.
It is aimed at teams adding live updates to an existing GraphQL API, where the schema already exists but the connection lifecycle and its authorization story do not.
What you will build
The service provides five capabilities, each with a test that uses two real connections:
- Authenticate on connect
- Topic subscription
- Per-event authorization
- Multi-instance fan-out
- Backpressure
Authentication on connect establishes identity once; topic tracking maps connections to subjects; per-event authorization enforces entitlement continuously; fan-out through a shared channel spans instances; and backpressure drops slow clients rather than stalling the broadcaster.
The design assumes subscribers are the untrusted side of the connection, so every event is filtered server-side rather than relying on the client to ignore what it should not see.
Architecture and data model
A GraphQL WebSocket server authenticates on connect, tracks each subscriber with its user and topic, and bridges a pub/sub channel into the async iterator. Authorization runs per event so a subscriber never receives another user updates.
A GraphQL WebSocket server authenticates the connection during initialization, then tracks each subscriber with its user, topic, and iterator. A pub/sub bridge feeds events from the application into the iterators.
Authorization runs per event: before yielding an event to a subscriber, the server rechecks that the subscriber is entitled to it under the current rules.
Fan-out uses a shared pub/sub channel so an event raised on one instance reaches subscribers connected to any instance. The in-memory path is the single-instance case and is documented as such.
Backpressure is handled by bounding the per-subscriber queue and dropping or closing a connection that cannot keep up, so one slow client cannot stall the broadcaster.
The connection carries an identity for its whole life and the registry maps topics to connections, so removing a subscription or revoking a user takes effect at the next event rather than at the next reconnect.
The GraphQL layer is only the transport; the authorization decision is data rather than schema, so a new field added later cannot accidentally bypass the per-event check.
Prerequisites and dependencies
You need a GraphQL server with WebSocket support and a pub/sub channel shared across instances.
- Node.js 20 LTS or later
- Redis 7+ for multi-instance fan-out
- A GraphQL client that supports subscriptions
The dependencies cover the schema, the WebSocket layer, and the shared channel:
graphql-yoga(^5.6.0) — HTTP and WebSocket GraphQL transportgraphql-ws(^5.16.0) — Subscription protocolioredis(^5.4.1) — Pub/sub fan-out
pnpm init
pnpm add graphql-yoga graphql-ws ioredis
pnpm add -D typescript tsx vitest
Set the idle timeout above the ping interval and keep the authentication secret in a secret manager, since a subscription that stays open for hours needs both timeouts and key rotation to be deliberate.
Directory structure
Separate the connection authentication, topic tracking, event authorization, fan-out, and backpressure so each is testable with two fake connections:
graphql-subscriptions/
├── src/server.ts
├── src/auth.ts
├── src/pubsub.ts
├── src/resolvers.ts
├── tests/subscription.test.ts
└── package.json
Phase 1: Authenticate on connect
Resolve the user from the connection params before accepting.
const user = await verify(connectionParams.token);
if (!user) return false; // close the socket
An unauthenticated socket must not reach resolvers.
Authenticate during the WebSocket handshake and attach the resolved identity to the connection, so the rest of the lifetime has a subject to authorize against.
Phase 2: Topic subscription
Map each subscription to a topic and an async iterator.
const iterator = pubsub.subscribe('order:' + orderId);
Topics decouple event producers from subscribers.
Track each subscription with its topic and user, and maintain a map so fan-out can find the relevant connections without broadcasting to everyone.
Phase 3: Per-event authorization
Verify the subscriber may see each event before yielding.
for await (const e of iterator) { if (allowed(user, e)) yield e; }
A shared topic can carry events a given user must not see.
Authorize on every event before yielding it, because a connection outlives the permission check that admitted it and entitlements change.
Phase 4: Multi-instance fan-out
Publish and subscribe through Redis so instances share events.
await publisher.publish('order:' + id, JSON.stringify(event));
In-memory pub/sub only reaches clients on the same instance.
Fan out through a shared channel so multiple instances deliver events consistently, and keep the in-memory path only for single-instance use.
Phase 5: Backpressure
Drop or coalesce events for a slow subscriber.
if (socket.bufferedAmount > MAX) return coalesce(event);
A slow consumer must not grow the send buffer without bound.
Bound the per-subscriber queue and close a connection that falls behind, since an unbounded queue turns one slow client into a memory leak.
Testing
Connect an authenticated subscriber, publish to its topic, and assert receipt; connect a second user and assert it does not receive another user events.
Connect an authenticated subscriber, publish to its topic, and assert receipt. Use a real WebSocket rather than a mocked one, so framing and lifecycle are exercised.
Connect a second user and assert it does not receive the first user’s events, which is the authorization guarantee expressed as a test.
Revoke a permission after subscribing and assert the next event is filtered, because this is the case that only per-event authorization handles.
Fill a subscriber’s queue and assert the connection is closed rather than the broadcaster blocked, which pins the backpressure behavior.
Use two real WebSocket connections and drive events through the actual server, because a subscription’s security properties live in the framing, the handshake, and the authorization step, none of which a unit test of the resolver would exercise.
Assert that an unauthenticated connection cannot register a subscription at all, because refusing early is clearer than accepting and then yielding nothing.
pnpm vitest run
Deployment and operations
Terminate TLS at the proxy and forward Upgrade headers. Scale horizontally with a shared pub/sub and remove in-memory bridges. Set idle timeouts above the ping interval.
Terminate TLS at the proxy and forward Upgrade headers, since a misconfigured proxy silently drops WebSocket connections in a way that is hard to diagnose from logs.
Scale horizontally with a shared pub/sub and remove any in-memory bridge, because an in-memory bridge works locally and fails silently across instances.
Set idle timeouts above the ping interval so a quiet subscription is kept alive rather than reconnected constantly.
Track connection count per instance and fan-out latency, because a growing fan-out delay means the channel is the bottleneck rather than the connections.
TLS termination at the proxy must forward the Upgrade header and must not buffer the stream, because a proxy that reformats a long-lived response breaks subscriptions in a way that looks like a client bug.
Every instance must share the pub/sub channel, since an in-memory bridge works locally and fails only under horizontal scale; record the number of instances subscribed to each channel so a partial fan-out is visible as a configuration mistake rather than a code bug.
Observe the connection count per instance during a rolling deploy, because a proxy that keeps old connections alive on a draining instance shows a skew worth understanding before it becomes a capacity surprise.
Security considerations
Authenticate on connect, authorize per event, and cap the number of concurrent subscriptions per user. Never include secrets in subscription payloads.
Authenticate before any subscription is registered, and never accept a topic from an unauthenticated connection, since an anonymous subscription can leak data without an obvious request.
Authorize per event and re-check ownership rather than caching a decision for the connection’s lifetime.
Validate the topic a client requests against its entitlement at subscribe time as well, so an unauthorized subscription is refused early rather than silently yielding nothing.
Limit the number of subscriptions per connection and the total per user, so a client cannot exhaust server memory by opening many subscriptions.
Authenticate before any subscription is registered and re-evaluate authorization per event, because a long-lived connection is the natural place for an entitlement to become stale; validate the requested topic against entitlement at subscribe time as well, so an unauthorized subscription is refused early rather than silently yielding nothing.
Reject oversized or malformed subscription payloads before processing them, so the subscription path cannot be used as an amplification target against the server.
Observability
Track active subscriptions, events per second, authorization denials, and dropped events. Alert on a growing buffer or denial rate, which suggests a broken client.
Track active connections, subscriptions per connection, and dropped-client count, because the drop rate is the clearest signal of a backpressure problem.
Measure fan-out latency and the number of instances receiving each event, since an inconsistent fan-out is often a shared-channel configuration mistake.
Log authorization rejections per topic, because a rising count may indicate a permission change that clients have not caught up with.
Chart reconnect rate, because a high rate usually points at an idle timeout shorter than the ping interval rather than at client behavior.
Watch active connections, subscriptions per connection, and the dropped-client rate, because the drop rate is the clearest sign of backpressure and a per-connection subscription spike is the clearest sign of an abusive client.
Log authorization rejections per topic with the subject, so a permission change that clients have not caught up with is visible as a rejection spike rather than as silent missing data.
Track fan-out latency and instance coverage together, since a delay that affects only some instances points at a per-instance channel rather than at the broadcaster.
Troubleshooting
User receives another user events. Authorization ran at subscribe time only; re-check per event.
Events stop in production. The proxy closed idle sockets; add a ping and raise the idle timeout.
If subscribers miss events after a deploy, check the pub/sub channel configuration on the new instances, since an in-memory bridge works until the traffic lands elsewhere.
If a connection closes repeatedly, compare the idle timeout with the ping interval; the timeout must be the larger of the two.
If subscribers miss events after a deploy, check the pub/sub configuration on the new instances before suspecting the code, because an in-memory bridge silently works until traffic lands elsewhere.
Limitations and next steps
- Subscription payloads are single objects, not streams.
- No persisted replay for missed events.
- One transport; no SSE fallback.
Delivery is at-most-once per connection; there is no replay of missed events after a reconnect, which a durable stream would provide.
Authorization is re-evaluated per event from the current rules, but there is no push to the client when entitlement changes, so a revoked user learns of it at the next event.
Backpressure drops slow clients rather than buffering indefinitely, which is a deliberate choice that bounds memory at the cost of losing the slowest consumers.
There is no per-topic ordering guarantee across instances, only within the channel’s delivery order.
Delivery is at-most-once per connection with no replay after a reconnect, so a client that drops briefly will miss events; a durable stream with a resume token is the next step.
Backpressure drops slow clients rather than buffering, which bounds memory but means the slowest consumers lose events without an error; a bounded buffer with an overflow signal would let them detect and recover instead of silently lagging.