Notification Connection Modes
The notification system supports two transport modes for delivering in-app notifications: WebSocket subscriptions (primary) and HTTP polling (fallback). The notificationStore automatically attempts WebSocket first and degrades gracefully to polling if the subscription server is unreachable.
IMPORTANT
WebSocket transport is now active. The WEBSOCKET_READY flag is set to true. Since the Hive Gateway migration (April 2026), subscriptions flow through the federated gateway on port 30000 โ there is no longer a separate WS server.
Architecture Overview
โโโโโโโโโโโโโโโ graphql-ws โโโโโโโโโโโโโโโโโโโโ
โ Frontend โ โโโโโโโโโโโโโโบโ Hive Gateway โ
โ NotifStore โ onNotificationโ (port 30000) โ
โ โ (Subscription)โ GraphQL Yoga โ
โ ๐ข live โ โโโโโโโโโโฌโโโโโโโโโโ
โโโโโโโโฌโโโโโโโ โ
โ federation
โ HTTP queries/mutations โ
โ โผ
โ โโโโโโโโโโโโโโโโโโโโ
โ โ Alerts Subgraph โ
โ โ (port 3025) โ
โโโโโโโโโโโโโโโโโโโโโโโโโบโ YogaFederation โ
โ Driver โ
โโโโโโโโโโโโโโโโโโโโ
Unified Server Architecture
With the migration to Hive Gateway and the YogaFederationDriver, GraphQL subscriptions are natively supported through the federated subgraph. The previous dual-server architecture (federation on port 3025 + standalone WS on port 3027) has been eliminated.
| Property | Value |
|---|---|
| Client endpoint | ws://localhost:30000/graphql |
| Federation driver | YogaFederationDriver (native subscription support) |
| Gateway | Hive Gateway (GraphQL Yoga) |
| PubSub | In-process PubSub from graphql-subscriptions |
The frontend connects to the same /graphql endpoint for both HTTP queries and WebSocket subscriptions. The Vite dev proxy forwards both protocols to the gateway on port 30000.
Primary Mode: WebSocket Subscriptions
Backend: PubSub Pipeline
When AlertDeliveryService.sendInApp() creates an IN_APP notification:
// alert-delivery.service.ts
await this.dlqService.markDelivered(log.id);
// Publish to WebSocket subscribers for real-time delivery
const enrichedLog = await this.dlqService.findById(log.id);
if (enrichedLog) {
notificationPubSub.publish(NOTIFICATION_ADDED, {
onNotification: enrichedLog,
});
}
The notificationPubSub is an in-process PubSub instance from graphql-subscriptions. The subscription resolver on the WS server filters events by userId:
// notification-subscription.resolver.ts
@Subscription(() => AlertLog, {
filter: (payload, _variables, context) => {
const subscribedUserId = context.req?.headers?.['x-user-id'];
return payload.onNotification.userId === subscribedUserId;
},
})
onNotification(): AsyncIterator<AlertLog> {
return notificationPubSub.asyncIterator(NOTIFICATION_ADDED);
}
Frontend: graphql-ws Client
The notificationStore connects to the gateway via the standard GraphQL endpoint. No explicit auth configuration is needed โ the browser automatically sends the session cookie on the WebSocket upgrade request:
// WS through the same /graphql endpoint as queries
const WS_URL = `ws://${window.location.host}/graphql`;
const wsClient = createClient({
url: WS_URL,
// No connectionParams needed โ session cookie is sent automatically
// on same-origin WebSocket upgrade requests
retryAttempts: MAX_WS_RETRIES, // 3
retryWait: async (retries) => {
const delay = Math.min(1000 * Math.pow(2, retries), 30_000);
await new Promise((r) => setTimeout(r, delay));
},
});
When a notification arrives via the subscription, it is immediately prepended to the local notifications array with deduplication by ID.
Subscription Document
subscription OnNotification {
onNotification {
id
title
subject
eventType
href
readAt
createdAt
payload
}
}
Fallback Mode: HTTP Polling
If the WebSocket connection fails after 3 retries, the store degrades to 30-second HTTP polling:
startPolling()
โ fetchNotifications() (initial)
โ upgradeToWebSocket()
โ SUCCESS โ connectionMode = 'websocket', stop polling
โ FAIL (3 retries) โ fallbackToPolling()
โ connectionMode = 'polling'
โ setInterval(fetchUnreadCount, 30_000)
Connection State Machine
type ConnectionMode = 'polling' | 'websocket';
type ConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
startPolling() upgradeToWebSocket()
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ
โ polling + โ โ websocket + โ
โ connected โโโโโโ connected โ
โโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ
โฒ โ โฒ
fallbackToPolling() ws error โ reconnect
โ โ
โผ โ
โโโโโโโโโโโโโโโโโโโโโ
โ websocket + โ
โ reconnecting โ
โโโโโโโโโโโโโโโโโโโโโ
โ
max retries (3)
โ
โผ
fallbackToPolling()
Authentication Model
IMPORTANT
Production-ready: Session Cookie Auth. The gateway's graphql-ws server validates session cookies during the WebSocket handshake via an onConnect hook.
How It Works
- The browser sends the session cookie automatically on the WebSocket upgrade request (same-origin)
- The gateway's
onConnecthook (ws-auth.ts) extracts thecookieheader from the initial HTTP upgrade request - The cookie is validated against the auth service (
POST /auth/validate) - Valid session โ connection accepted, user identity stored in
ctx.extra.user - Invalid/missing session โ connection rejected at the transport layer (4403 Forbidden)
The subscription resolver then filters events using the x-user-id header, which is populated by the gateway's auth context from the validated session โ identical to the HTTP GraphQL auth flow.
// ws-auth.ts โ onConnect hook
onConnect: async (ctx) => {
const cookie = ctx.extra?.request?.headers?.cookie;
if (!cookie) return false; // reject: no cookie
const user = await validateWsSession(cookie, authBaseUrl);
if (!user) return false; // reject: invalid session
ctx.extra.user = user; // store for subscription context
return true;
}
NOTE
No connectionParams are sent by the frontend. The browser's automatic cookie forwarding eliminates any client-side auth configuration.
Scaling Path
The current in-process PubSub works for a single alerts instance. For horizontal scaling (multiple replicas):
- Replace
graphql-subscriptionsPubSubwithgraphql-redis-subscriptions - Point it at the existing Redis infra (
REDIS_URL) - All instances will share the event bus
UI: Connection Indicator
The NotificationCenter.svelte dropdown footer displays a connection mode badge:
| Mode | Badge | Tooltip |
|---|---|---|
websocket |
๐ข Live | "Real-time WebSocket" |
polling |
๐ก 30s poll | "Polling every 30s" |
Feature Flag
// notificationStore.svelte.ts
export const WEBSOCKET_READY = true;
To disable WebSocket mode (e.g., during debugging), set WEBSOCKET_READY = false. The store will immediately fall back to HTTP polling.
Testing
| Test | Suite | Count |
|---|---|---|
| PubSub delivery + subscription filter | notification.pubsub.spec.ts |
6 |
| E2E: schema, pipeline, isolation, concurrency | notification-subscription.e2e-spec.ts |
4 |
| Frontend connection modes | tests/notificationConnectionMode.test.ts |
6 |
Migration History
The original architecture (pre-April 2026) used a dual-server topology because Apollo Federation Driver (@nestjs/apollo ApolloFederationDriver) did not support GraphQL subscriptions. A separate non-federated WS server ran on port 3027 using the standard ApolloDriver.
With the migration to Hive Gateway + YogaFederationDriver, subscriptions are natively supported through the federated subgraph, and the standalone WS server has been retired. See Gateway (Hive Gateway) for the full migration details.
Related
- Mobile Push Infrastructure โ In-house APNs/FCM push dispatch and native push registration flows
- Alerts Resilience โ Backend DLQ, AlertLog entity, and delivery orchestration
- Alerts & Notifications โ Notification center UI, toast system, and preference management
- Invite Notification Pipeline โ Organization invite โ notification flow