Back to Blog

GraphQL Subscriptions in Production: Real-Time APIs with Redis Pub/Sub and Pothos

Build real-time GraphQL subscriptions with WebSockets, Redis pub/sub, and the Pothos schema builder. Includes authentication, scaling across instances, and cursor-based pagination.

Viprasol Tech Team
July 21, 2026
13 min read

GraphQL Subscriptions in Production: Real-Time APIs with Redis Pub/Sub and Pothos

GraphQL subscriptions are the right tool for a narrow set of problems: live dashboard updates, collaborative editing presence, order status tracking, and chat. For most data-fetching needs, queries and mutations are simpler and more reliable. But when you genuinely need server-to-client push, subscriptions deliver it within the GraphQL contract your frontend already understands.

The challenge is scaling. A single server maintains WebSocket connections in memory โ€” fine for one instance, broken when you add a second. Redis pub/sub bridges the gap: any server can publish an event, and all servers with subscribers for that event receive and forward it.

This post covers a production-ready setup with GraphQL Yoga, Pothos type-safe schema builder, and Redis-backed pub/sub that scales horizontally.


When to Use GraphQL Subscriptions (vs. Alternatives)

Use CaseSubscriptionsSSEPollingWebSocket (raw)
Order status updates (user-specific)โœ…โœ…โš ๏ธ (wasteful)โš ๏ธ (over-engineered)
Live dashboard metricsโœ…โœ…โš ๏ธโš ๏ธ
Collaborative editing (bi-directional)โœ…โŒโŒโœ…
Chat messagesโœ…โœ…โŒโœ…
Notification bell countโŒโœ… simplerโœ…โŒ
Already using REST, not GraphQLโŒโœ…โœ…โœ…

If your app already uses GraphQL, subscriptions are the natural fit. If it doesn't, SSE is simpler for server-push.


Stack

  • GraphQL Yoga โ€” production-ready GraphQL server built on Fetch API (works with Fastify, Node.js, Cloudflare Workers)
  • Pothos โ€” code-first, type-safe GraphQL schema builder (no code generation needed)
  • graphql-ws โ€” WebSocket subprotocol (graphql-transport-ws) โ€” the 2026 standard
  • Redis โ€” pub/sub backbone for multi-instance scaling
  • ioredis โ€” Redis client with reliable reconnection

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Schema Definition with Pothos

// src/schema/builder.ts
import SchemaBuilder from '@pothos/core';
import PrismaPlugin from '@pothos/plugin-prisma';
import type PrismaTypes from '@pothos/plugin-prisma/generated';
import { db } from '@/db/prisma';

export const builder = new SchemaBuilder<{
  PrismaTypes: PrismaTypes;
  Context: {
    userId: string;
    tenantId: string;
  };
  Scalars: {
    DateTime: { Input: Date; Output: Date };
    JSON: { Input: unknown; Output: unknown };
  };
}>({
  plugins: [PrismaPlugin],
  prisma: { client: db },
});

builder.queryType({});
builder.mutationType({});
builder.subscriptionType({});
// src/schema/order/types.ts
import { builder } from '../builder';

export const OrderStatus = builder.enumType('OrderStatus', {
  values: ['PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED', 'CANCELLED'] as const,
});

export const OrderType = builder.prismaObject('Order', {
  fields: (t) => ({
    id: t.exposeID('id'),
    status: t.expose('status', { type: OrderStatus }),
    total: t.exposeFloat('total'),
    createdAt: t.expose('createdAt', { type: 'DateTime' }),
    customer: t.relation('customer'),
    items: t.relation('items'),
  }),
});

export const OrderEventType = builder.objectType('OrderEvent', {
  fields: (t) => ({
    orderId: t.exposeString('orderId'),
    status: t.expose('status', { type: OrderStatus }),
    timestamp: t.expose('timestamp', { type: 'DateTime' }),
    message: t.exposeString('message', { nullable: true }),
  }),
});

Redis Pub/Sub Layer

// src/pubsub/redis-pubsub.ts
import Redis from 'ioredis';
import { EventEmitter } from 'events';

type EventPayload = Record<string, unknown>;

export class RedisPubSub {
  private publisher: Redis;
  private subscriber: Redis;
  private emitter = new EventEmitter();

  constructor(redisUrl: string) {
    const opts = {
      maxRetriesPerRequest: null,
      enableReadyCheck: false,
      retryStrategy: (times: number) => Math.min(times * 100, 3000),
    };

    this.publisher = new Redis(redisUrl, opts);
    this.subscriber = new Redis(redisUrl, opts);

    // Forward Redis messages to local EventEmitter
    this.subscriber.on('message', (channel, message) => {
      try {
        const payload = JSON.parse(message);
        this.emitter.emit(channel, payload);
      } catch {
        // Ignore malformed messages
      }
    });
  }

  async publish(channel: string, payload: EventPayload): Promise<void> {
    await this.publisher.publish(channel, JSON.stringify(payload));
  }

  async subscribe(channel: string): Promise<void> {
    await this.subscriber.subscribe(channel);
  }

  async unsubscribe(channel: string): Promise<void> {
    await this.subscriber.unsubscribe(channel);
  }

  on(channel: string, listener: (payload: EventPayload) => void): void {
    this.emitter.on(channel, listener);
  }

  off(channel: string, listener: (payload: EventPayload) => void): void {
    this.emitter.off(channel, listener);
  }

  // Create an async generator for use in GraphQL subscriptions
  async *asyncIterator<T extends EventPayload>(
    channel: string,
  ): AsyncGenerator<T> {
    await this.subscribe(channel);

    const queue: T[] = [];
    let resolve: (() => void) | null = null;
    let done = false;

    const listener = (payload: T) => {
      queue.push(payload);
      resolve?.();
      resolve = null;
    };

    this.on(channel, listener as (p: EventPayload) => void);

    try {
      while (!done) {
        if (queue.length > 0) {
          yield queue.shift()!;
        } else {
          await new Promise<void>((r) => { resolve = r; });
        }
      }
    } finally {
      this.off(channel, listener as (p: EventPayload) => void);
      await this.unsubscribe(channel);
    }
  }
}

export const pubsub = new RedisPubSub(process.env.REDIS_URL!);

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Subscription Resolvers

// src/schema/order/subscriptions.ts
import { builder } from '../builder';
import { pubsub } from '@/pubsub/redis-pubsub';
import { OrderEventType } from './types';
import { verifyOrderAccess } from '@/services/authorization';

interface OrderEvent {
  orderId: string;
  status: string;
  timestamp: Date;
  message?: string;
}

builder.subscriptionField('orderUpdated', (t) =>
  t.field({
    type: OrderEventType,
    args: {
      orderId: t.arg.string({ required: true }),
    },
    // Auth check before subscribing
    async authScopes(_, { orderId }, { userId }) {
      return verifyOrderAccess(userId, orderId);
    },
    subscribe: async function* (_, { orderId }, { userId }) {
      const channel = `order:${orderId}:events`;

      // Yield current order status immediately (no missed events)
      const order = await db.order.findUnique({ where: { id: orderId } });
      if (order) {
        yield {
          orderId: order.id,
          status: order.status,
          timestamp: new Date(),
          message: 'Current status',
        } satisfies OrderEvent;
      }

      // Then stream future events from Redis
      yield* pubsub.asyncIterator<OrderEvent>(channel);
    },
    resolve: (event) => event,
  }),
);

// Subscribe to ALL orders for a tenant (admin dashboard)
builder.subscriptionField('tenantOrderFeed', (t) =>
  t.field({
    type: OrderEventType,
    authScopes: { isAdmin: true },
    subscribe: async function* (_, __, { tenantId }) {
      yield* pubsub.asyncIterator<OrderEvent>(`tenant:${tenantId}:orders`);
    },
    resolve: (event) => event,
  }),
);

Publishing Events from Business Logic

// src/services/orders.ts
import { pubsub } from '@/pubsub/redis-pubsub';
import { db } from '@/db/prisma';

export async function shipOrder(
  orderId: string,
  trackingNumber: string,
): Promise<void> {
  const order = await db.order.update({
    where: { id: orderId },
    data: { status: 'SHIPPED', trackingNumber },
    include: { customer: true },
  });

  const event = {
    orderId,
    status: 'SHIPPED',
    timestamp: new Date(),
    message: `Your order has shipped. Tracking: ${trackingNumber}`,
  };

  // Publish to order-specific channel (watched by customer)
  await pubsub.publish(`order:${orderId}:events`, event);

  // Also publish to tenant feed (watched by admin dashboard)
  await pubsub.publish(`tenant:${order.tenantId}:orders`, event);
}

GraphQL Yoga Server with WebSocket Support

// src/server.ts
import Fastify from 'fastify';
import { createYoga } from 'graphql-yoga';
import { useServer } from 'graphql-ws/lib/use/fastify-websocket';
import fastifyWebsocket from '@fastify/websocket';
import { schema } from './schema';
import { createContext } from './context';

const app = Fastify({ logger: true });
await app.register(fastifyWebsocket);

const yoga = createYoga({
  schema,
  context: createContext,
  graphqlEndpoint: '/graphql',
  subscriptionsProtocol: 'WS',
  logging: app.log,
});

// HTTP + WebSocket on same endpoint
app.route({
  url: yoga.graphqlEndpoint,
  method: ['GET', 'POST', 'OPTIONS'],
  handler: async (req, reply) => {
    const response = await yoga.handleNodeRequestAndResponse(req, reply, {
      req,
      reply,
    });
    return response;
  },
  wsHandler: useServer(
    {
      schema,
      context: async (ctx) => {
        // Authenticate WebSocket connection via connectionParams
        const token = ctx.connectionParams?.authorization as string;
        if (!token) throw new Error('Unauthorized');

        const user = await verifyJWT(token);
        return { userId: user.sub, tenantId: user.tenantId };
      },
      onConnect: (ctx) => {
        app.log.info({ ip: ctx.extra.socket.remoteAddress }, 'WS connected');
      },
      onDisconnect: (ctx) => {
        app.log.info({ ip: ctx.extra.socket.remoteAddress }, 'WS disconnected');
      },
    },
    app.websocketServer,
  ),
});

await app.listen({ port: 4000, host: '0.0.0.0' });

React Client with urql

// src/providers/graphql.tsx
import { createClient, subscriptionExchange, cacheExchange, fetchExchange } from 'urql';
import { createClient as createWSClient } from 'graphql-ws';

const wsClient = createWSClient({
  url: process.env.NEXT_PUBLIC_WS_URL ?? 'wss://api.myapp.com/graphql',
  connectionParams: async () => ({
    // JWT from auth store
    authorization: await getAuthToken(),
  }),
  retryAttempts: 5,
  on: {
    error: (err) => console.error('WS error:', err),
  },
});

export const gqlClient = createClient({
  url: process.env.NEXT_PUBLIC_API_URL ?? 'https://api.myapp.com/graphql',
  exchanges: [
    cacheExchange,
    fetchExchange,
    subscriptionExchange({
      forwardSubscription: (request) => ({
        subscribe: (sink) => ({
          unsubscribe: wsClient.subscribe(request as any, sink),
        }),
      }),
    }),
  ],
});
// src/components/OrderTracker.tsx
import { useSubscription } from 'urql';
import { gql } from 'urql';

const ORDER_UPDATED = gql`
  subscription OrderUpdated($orderId: String!) {
    orderUpdated(orderId: $orderId) {
      orderId
      status
      timestamp
      message
    }
  }
`;

export function OrderTracker({ orderId }: { orderId: string }) {
  const [result] = useSubscription({
    query: ORDER_UPDATED,
    variables: { orderId },
  });

  const event = result.data?.orderUpdated;

  return (
    <div className="order-tracker">
      {result.fetching && <p>Connecting...</p>}
      {result.error && <p className="text-red-600">Connection error</p>}
      {event && (
        <div>
          <StatusBadge status={event.status} />
          <p className="text-sm text-gray-500">
            {new Date(event.timestamp).toLocaleTimeString()}
          </p>
          {event.message && <p>{event.message}</p>}
        </div>
      )}
    </div>
  );
}

Scaling: Connection Counts and Infrastructure

Concurrent ConnectionsSetupInfrastructure
<1,000Single server, in-memory1ร— Node.js instance (1 vCPU)
1,000โ€“10,000Redis pub/sub, sticky sessions2โ€“4 Node.js instances + Redis
10,000โ€“100,000Redis Cluster, horizontal scale8โ€“16 instances + Redis Cluster
>100,000Dedicated WS tier (separate from HTTP)Autoscaling group + ElastiCache

WebSocket connections are stateful โ€” each connection stays open. At 10,000 concurrent connections per instance (Node.js limit varies by memory/CPU), plan capacity accordingly.

Load balancer config for sticky sessions (AWS ALB):

resource "aws_alb_target_group" "graphql_ws" {
  name             = "graphql-ws"
  port             = 4000
  protocol         = "HTTP"
  target_type      = "ip"

  stickiness {
    type            = "lb_cookie"
    cookie_duration = 86400   # 24h โ€” WebSocket reconnects go to same instance
    enabled         = true
  }

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 10
  }
}

Working With Viprasol

Our team builds real-time GraphQL APIs for collaborative SaaS products โ€” from live dashboards to order tracking to multiplayer features โ€” with Redis-backed scaling and production-tested WebSocket infrastructure.

What we deliver:

  • GraphQL Yoga + Pothos type-safe schema with subscriptions
  • Redis pub/sub layer with reliable reconnection
  • Authentication for WebSocket connections (JWT via connectionParams)
  • Horizontal scaling setup with sticky sessions
  • React/Next.js client integration with urql or Apollo

โ†’ Discuss your real-time API requirements โ†’ Web development services


Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.