[Redis Beyond Caching] Part 2: Real-time Pub/Sub with GraphQL Subscriptions

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

comments powered by Disqus