How to build a reactive architecture that pushes real-time updates to the frontendβcombining GraphQL Subscriptions with Redis as the cross-server event bus.
π The Evolution: From Polling to Push
The Polling Problem
Traditional web apps rely on client-side polling to fetch the latest state:
1
2
3
4
5
6
7
8
9
10
11
|
Client Server
β β
βββ GET /status ββββββββββββββββΆβ
ββββ { progress: 10% } ββββββββββ
β (wait 3 seconds) β
βββ GET /status ββββββββββββββββΆβ
ββββ { progress: 10% } ββββββββββ β wasted request, no change
β (wait 3 seconds) β
βββ GET /status ββββββββββββββββΆβ
ββββ { progress: 45% } ββββββββββ
...
|
Problems:
- β Wasted server resources on unchanged data
- β Latency gap between actual change and client awareness
- β Doesn’t scaleβ10,000 clients = 10,000 requests every N seconds
The Push Model
Modern apps need server-initiated pushβwhen backend state changes, the update is delivered to clients in milliseconds:
1
2
3
4
5
6
7
8
|
Client Server
β β
βββ WebSocket handshake ββββββββΆβ
ββββ connection established βββββ
β β
β (server-side event occurs) β
ββββ { progress: 45% } ββββββββββ β instant push
ββββ { progress: 100% } βββββββββ β instant push
|
ποΈ Architecture: GraphQL Subscriptions + Redis
Why GraphQL Subscriptions?
GraphQL has three operation types:
- Query β Read data
- Mutation β Write data
- Subscription β Stream real-time updates over WebSocket
Subscriptions provide a standardized, type-safe way to push data to clients.
The Single-Server Limitation
A vanilla GraphQL server with subscriptions works fine on a single instance. But in production:
1
2
3
4
5
6
7
8
9
10
11
|
βββββββββββββββββββ βββββββββββββββββββ
β Pod A β β Pod B β
β GraphQL β β GraphQL β
β Server β β Server β
β βββββββββββββ β β βββββββββββββ β
β βIn-Memory β β β βIn-Memory β β
β βPubSub β β β βPubSub β β
β βββββββββββββ β β βββββββββββββ β
ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ
β β
User A connected User B connected
|
Problem: If a Mutation runs on Pod B, User A (connected to Pod A) never receives the update. This is the “Island Effect”.
Redis as Cross-Server Event Bus
Redis Pub/Sub solves this by acting as a broadcast station that all pods listen to:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
βββββββββββββββββββ βββββββββββββββββββ
β Pod A β β Pod B β
β GraphQL β β GraphQL β
β Server β β Server β
ββββββββββ¬βββββββββ ββββββββββ¬βββββββββ
β β
β subscribe β subscribe
βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Redis Pub/Sub β
β β
β Channel: "TASK_UPDATED" β
β Channel: "NOTIFICATION" β
βββββββββββββββββββββββββββββββββββββββββββ
β²
β publish
β
ββββββββββ΄βββββββββ
β Worker β
β (any pod) β
βββββββββββββββββββ
|
Now when any service publishes to Redis, all pods receive the message and push to their connected clients.
β οΈ Limitation: This is best-effort fan-out for currently online clients only. No backpressure, no persistence, no replay.
π» Implementation
Setup: graphql-redis-subscriptions
1
2
3
4
5
6
7
|
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';
const pubsub = new RedisPubSub({
publisher: new Redis({ host: 'redis' }),
subscriber: new Redis({ host: 'redis' }),
});
|
Publisher: Any Backend Service
1
2
3
4
5
6
7
8
9
10
|
// Worker, API, Cron Job β anyone can publish
async function onTaskComplete(taskId: string, result: any) {
await pubsub.publish('TASK_UPDATED', {
taskUpdated: {
id: taskId,
status: 'completed',
result,
},
});
}
|
Subscriber: GraphQL Resolver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// GraphQL Schema
const typeDefs = `
type Task {
id: ID!
status: String!
result: JSON
}
type Subscription {
taskUpdated(taskId: ID!): Task
}
`;
// Resolver
const resolvers = {
Subscription: {
taskUpdated: {
subscribe: withFilter(
() => pubsub.asyncIterator('TASK_UPDATED'),
(payload, variables) => payload.taskUpdated.id === variables.taskId
),
},
},
};
|
Client: Apollo Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import { useSubscription, gql } from '@apollo/client';
const TASK_SUBSCRIPTION = gql`
subscription OnTaskUpdated($taskId: ID!) {
taskUpdated(taskId: $taskId) {
id
status
result
}
}
`;
function TaskProgress({ taskId }) {
const { data, loading } = useSubscription(TASK_SUBSCRIPTION, {
variables: { taskId },
});
if (loading) return <Spinner />;
return <ProgressBar value={data?.taskUpdated?.progress} />;
}
|
β οΈ Production Caveats
Redis Pub/Sub is Fire-and-Forget
| Feature |
Redis Pub/Sub |
Redis Stream |
| Message Persistence |
β No |
β
Yes |
| Replay on Reconnect |
β No |
β
Yes |
| Acknowledgment |
β No |
β
Yes |
| Delivery Guarantee |
At-most-once |
At-least-once |
π If a subscriber disconnects, messages are lost. Pub/Sub is suitable for:
- UI updates where missing one is tolerable
- Signals that trigger a re-fetch
- Non-critical notifications
For critical messaging, consider Redis Streams or a dedicated message broker (Kafka, RabbitMQ).
WebSocket Connection Management
- Connection limits β Each pod can only handle so many WebSocket connections
- Sticky sessions β Required only if subscription state is kept in-memory without a shared pub/sub layer. With Redis Pub/Sub, sticky sessions are optional.
- Heartbeat/ping β Detect stale connections and clean up
Filtering at Scale
The example above broadcasts to all subscribers. In production:
1
2
3
4
|
subscribe: withFilter(
() => pubsub.asyncIterator('TASK_UPDATED'),
(payload, variables) => payload.taskUpdated.id === variables.taskId
)
|
Use withFilter to ensure clients only receive events they care aboutβotherwise you’re wasting bandwidth.
Advanced: MQ + Pub/Sub Separation
In high-reliability systems, you may add a message queue between internal services and Redis Pub/Sub:
1
2
3
4
|
ββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ βββββββββββ
β Worker ββββββΆβ Message Queue ββββββΆβ GraphQL Server ββββββΆβ Clients β
β β β (RabbitMQ) β β (Redis Pub/Sub) β β β
ββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ βββββββββββ
|
Why this pattern?
- MQ provides message persistenceβif the GraphQL server is restarting, messages wait in queue
- Separates internal service communication (reliable) from client broadcasting (best-effort)
- Easier to scale each layer independently
π Summary
- Polling β Push β Eliminates wasted requests, provides instant UI updates
- Redis as Event Bus β Solves the “Island Effect” in multi-pod deployments
- GraphQL Subscriptions β Standardized, type-safe real-time API
- Trade-off β Pub/Sub is fire-and-forget; use Streams for durability
With Redis as the only additional infrastructure component, you get horizontally scalable real-time capabilities.
References