Guide · Amazon ElastiCache · Pub/Sub · Real-Time Messaging · MCP Server Tools

ElastiCache Pub/Sub for MCP Servers — Real-Time Fan-Out, Channel Patterns, Reconnect Handling

MCP server tools often need to broadcast updates to multiple subscribers — a tool completion event that multiple UI sessions should receive, a feature flag change that all running MCP instances should apply immediately, or a cache invalidation signal that every server in a fleet should act on. Redis Pub/Sub provides a lightweight, real-time messaging channel that ElastiCache makes available without any additional infrastructure. Unlike Streams, Pub/Sub is ephemeral: messages are delivered to connected subscribers and immediately discarded — there is no persistence and no acknowledgment. This makes Pub/Sub the right tool for fan-out notifications where losing a message during a brief reconnect is acceptable, and the wrong tool when every message must be processed exactly once.

TL;DR

Use a dedicated Redis connection for Pub/Sub — a subscribed connection cannot run other commands. Use SUBSCRIBE for exact channel names and PSUBSCRIBE for glob patterns. Set up auto-reconnect and re-subscribe after disconnect: ioredis fires 'reconnecting' and 'ready' events. In cluster mode, all subscribers and publishers must be on the same shard for a channel — use a single-node channel key or accept that PSUBSCRIBE does not work across shards. Pub/Sub delivers at-most-once; for at-least-once with replay, use Redis Streams instead.

Dedicated subscriber connection

Once a connection enters subscribe mode (SUBSCRIBE or PSUBSCRIBE), it can only run subscribe-family commands: SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PING, and QUIT. Any other command (GET, SET, PUBLISH) returns ERR Command not allowed in subscribe mode. You must use a separate connection for all regular Redis operations.

import Redis from 'ioredis';

// Regular connection for GET/SET/PUBLISH/etc.
const redis = new Redis({
  host: process.env.ELASTICACHE_PRIMARY_ENDPOINT,
  port: 6379,
  tls: {},
  password: process.env.ELASTICACHE_AUTH_TOKEN,
});

// Dedicated connection for subscriptions only
const subscriber = new Redis({
  host: process.env.ELASTICACHE_PRIMARY_ENDPOINT,
  port: 6379,
  tls: {},
  password: process.env.ELASTICACHE_AUTH_TOKEN,
  lazyConnect: true,
});

// Subscribe to channels after connection is established
await subscriber.connect();

// Message handler — fires for every published message
subscriber.on('message', (channel: string, message: string) => {
  console.log(`[pubsub] channel=${channel} message=${message}`);
  // Parse and dispatch by channel
  handleMessage(channel, message);
});

await subscriber.subscribe('tool:completions', 'config:updates');
console.log('[pubsub] subscribed to 2 channels');

ioredis will automatically re-subscribe to the same channels after a reconnect. The channels you subscribed to are tracked in the client's internal state — you do not need to re-subscribe manually in the 'ready' event handler. However, messages published while the connection was down are permanently lost (at-most-once delivery).

SUBSCRIBE vs PSUBSCRIBE

SUBSCRIBE channel1 channel2 subscribes to exact channel names. PSUBSCRIBE pattern subscribes to all channels matching a glob pattern. Pattern subscriptions are more flexible but have higher CPU cost on the server because every publish checks all active patterns.

// SUBSCRIBE — exact channel names, cheapest
await subscriber.subscribe(
  'tool:search:complete',
  'tool:summarize:complete',
  'config:feature-flags',
);

// PSUBSCRIBE — glob patterns
// h?llo matches hello, hallo, hxllo
// h*llo matches hllo, heeello, h123llo
// h[ae]llo matches hello, hallo (not hillo)
await subscriber.psubscribe(
  'tool:*:complete',   // all tool completion events
  'user:*:event',      // all per-user events
);

// Different event names for SUBSCRIBE vs PSUBSCRIBE
subscriber.on('message', (channel, message) => {
  // Fired by SUBSCRIBE
  console.log('exact match:', channel, message);
});

subscriber.on('pmessage', (pattern, channel, message) => {
  // Fired by PSUBSCRIBE: pattern = 'tool:*:complete', channel = 'tool:search:complete'
  console.log('pattern match:', pattern, channel, message);
});

Performance note: PSUBSCRIBE with many active subscribers and high publish rate is CPU-intensive on the Redis node — each publish scans all registered patterns. If you have 1,000 subscribers each using PSUBSCRIBE tool:*:complete and you publish 10,000 messages/second, the pattern matching runs 10 million times per second. Use exact SUBSCRIBE when you can enumerate the channels; fall back to PSUBSCRIBE only when channel names are dynamically generated and unpredictable.

Publishing messages

Use the regular (non-subscriber) connection to publish. PUBLISH returns the number of subscribers that received the message — useful for debugging but not for confirmation (the return value is 0 for messages sent to empty channels, not an error).

type ToolCompletionEvent = {
  toolCallId: string;
  toolName: string;
  result: unknown;
  durationMs: number;
  userId: string;
};

async function publishToolCompletion(event: ToolCompletionEvent): Promise {
  const channel = `tool:${event.toolName}:complete`;
  const payload = JSON.stringify(event);

  const receiverCount = await redis.publish(channel, payload);
  // receiverCount = number of subscribers who received it right now
  // 0 is NOT an error — it means no one was subscribed at this moment
  if (receiverCount === 0) {
    // Expected if no active UI sessions; log if unexpected for a heavily-subscribed channel
    console.debug(`[pubsub] published to ${channel} with 0 receivers`);
  }
}

// Subscriber side
function handleMessage(channel: string, rawMessage: string): void {
  try {
    const event = JSON.parse(rawMessage) as ToolCompletionEvent;
    // Dispatch to waiting tool call handlers
    toolCallRegistry.resolve(event.toolCallId, event.result);
  } catch (err) {
    console.error(`[pubsub] failed to parse message on ${channel}:`, err);
  }
}

Reconnect handling and re-subscription

Network partitions, ElastiCache maintenance events, and primary/replica failovers will disconnect your subscriber. ioredis handles reconnection automatically, but you need to understand what happens to in-flight messages during the disconnect window.

const subscriber = new Redis({
  host: process.env.ELASTICACHE_PRIMARY_ENDPOINT,
  port: 6379,
  tls: {},
  password: process.env.ELASTICACHE_AUTH_TOKEN,
  retryStrategy(times: number) {
    // Exponential backoff: 100ms, 200ms, 400ms, 800ms, ... capped at 5s
    return Math.min(times * 100, 5000);
  },
  reconnectOnError(err: Error) {
    // Reconnect on READONLY errors (thrown when connecting to a replica during failover)
    return err.message.includes('READONLY');
  },
});

subscriber.on('reconnecting', (delay: number) => {
  console.warn(`[pubsub] reconnecting in ${delay}ms — messages during disconnect are LOST`);
});

subscriber.on('ready', () => {
  console.log('[pubsub] connection ready — ioredis auto-resubscribes to previous channels');
  // ioredis automatically re-runs SUBSCRIBE/PSUBSCRIBE for all prior subscriptions
  // You do NOT need to call subscriber.subscribe(...) again here
});

subscriber.on('error', (err: Error) => {
  // Log but do not crash — ioredis will reconnect
  console.error('[pubsub] redis error:', err.message);
});

ioredis stores the list of subscribed channels/patterns in memory and re-runs the subscription commands automatically after each reconnection. The gap between disconnect and reconnect is a message loss window — there is no replay. If you need guaranteed delivery across reconnects, use Redis Streams with consumer groups.

Fan-out patterns for MCP server architectures

Three common fan-out patterns used in MCP server deployments:

Pattern 1: Broadcast to all server instances (config updates)

Publish to a single channel. Every MCP server instance subscribes to it and updates its local in-memory config — no request required from each instance.

// Publisher (admin service or deployment script)
await redis.publish('config:reload', JSON.stringify({
  version: '2026-11-12-001',
  featureFlags: { newSearchAlgorithm: true, legacyParser: false },
}));

// Every MCP server instance subscribes
subscriber.on('message', (channel, message) => {
  if (channel === 'config:reload') {
    const config = JSON.parse(message);
    configStore.update(config); // in-memory update, no DB query
    console.log('[config] reloaded to version', config.version);
  }
});

Pattern 2: Per-user event delivery

Use user-scoped channels like user:${userId}:events. Only the MCP server instance handling that user's session subscribes to their channel — avoids fanning out to all instances.

// When a background tool completes, notify the user's session
async function notifyUser(userId: string, toolCallId: string, result: unknown) {
  await redis.publish(
    `user:${userId}:events`,
    JSON.stringify({ type: 'tool_complete', toolCallId, result }),
  );
}

// In the MCP server instance handling this user's WebSocket session
await subscriber.subscribe(`user:${userId}:events`);

subscriber.on('message', (channel, message) => {
  const event = JSON.parse(message);
  if (event.type === 'tool_complete') {
    wsSession.send(JSON.stringify(event)); // push to WebSocket
  }
});

// IMPORTANT: unsubscribe when user disconnects to avoid memory leak
wsSession.on('close', async () => {
  await subscriber.unsubscribe(`user:${userId}:events`);
});

Pattern 3: Cache invalidation broadcast

Publish a cache-invalidation message to a shared channel. Every server instance that has the key cached deletes it on receipt. This is simpler than tag-based invalidation for cases where every instance caches the same keys.

// Publisher: when a product is updated
await redis.publish('cache:invalidate', JSON.stringify({
  key: `product:seo:p-456:en`,
  reason: 'product_updated',
}));

// Every MCP server instance
subscriber.on('message', (channel, message) => {
  if (channel === 'cache:invalidate') {
    const { key } = JSON.parse(message);
    redis.del(key); // delete from this instance's shared ElastiCache
    localCache?.delete(key); // also purge any in-process cache
  }
});

Cluster mode restrictions

ElastiCache cluster mode distributes keys and channels across multiple shards. Pub/Sub channels are shard-local — a message published to a channel only reaches subscribers connected to the same shard. If your publisher connects to shard A and your subscriber connects to shard B, the subscriber never receives the message even though both are in the same cluster. See cluster mode for shard routing details.

// In cluster mode, use ioredis Cluster with scaleReads:'master'
// and ensure publisher and subscriber connect to the same node

import { Cluster } from 'ioredis';

const cluster = new Cluster([
  { host: process.env.ELASTICACHE_CONFIG_ENDPOINT, port: 6379 },
], {
  redisOptions: { tls: {}, password: process.env.ELASTICACHE_AUTH_TOKEN },
  scaleReads: 'master',
});

// ioredis Cluster routes PUBLISH to the slot-responsible shard for the channel name
// The subscriber must also be on that shard — ioredis handles this transparently
const subCluster = new Cluster([
  { host: process.env.ELASTICACHE_CONFIG_ENDPOINT, port: 6379 },
], {
  redisOptions: { tls: {}, password: process.env.ELASTICACHE_AUTH_TOKEN },
});

// PSUBSCRIBE is NOT supported in cluster mode via ioredis Cluster
// — patterns can match channels on different shards (each slot belongs to one shard)
// Use SUBSCRIBE with exact channel names in cluster mode

// Workaround for broadcast (all-shard fanout):
// Pin all pub/sub channels to a single shard using a hash tag:
const broadcastChannel = `{pubsub}:config:reload`; // {pubsub} forces same slot always

The common workaround for cluster-mode broadcast is to prefix all pub/sub channel names with a fixed hash tag like {pubsub}. This forces all channels to hash to the same slot, keeping them on a single shard. The tradeoff: that shard handles all pub/sub load, so it can become a hotspot. For moderate pub/sub volume this is acceptable; for high-volume fan-out, consider a dedicated non-clustered ElastiCache node for pub/sub.

Pub/Sub vs Streams: choosing the right primitive

Characteristic Pub/Sub Redis Streams
PersistenceNo — ephemeral, fire-and-forgetYes — persisted in sorted log
DeliveryAt-most-once (missed if not subscribed)At-least-once with ACK
ReplayNoYes — read from any position
Consumer groupsNoYes — competing consumers, balanced delivery
Message backpressureNone — slow subscribers fall behind and disconnectBounded via MAXLEN
Cluster modeShard-local (workaround: hash tag)Key-routed (hash tag for co-location)
Use forReal-time notifications, config broadcast, cache invalidationEvent sourcing, job queues, audit logs, durable fan-out

Use Pub/Sub when: you are broadcasting to online listeners, message loss during reconnect is tolerable, and you want zero persistence overhead. Use Streams when: subscribers may be offline, you need replay, you want competing consumers to load-balance work, or message acknowledgment is required.

Common errors and fixes

ErrorCauseFix
ERR Command not allowed in subscribe modeRunning GET/SET/PUBLISH on the subscriber connectionUse a separate Redis connection for non-subscribe commands
Subscriber never receives messagesPublisher and subscriber on different shards in cluster modeUse hash tag {pubsub} prefix on all channels, or use non-clustered node
PSUBSCRIBE returns no matches in cluster modePSUBSCRIBE not supported across cluster shardsUse exact SUBSCRIBE, or pin channels to single shard with hash tag
Message storm after reconnectNo such thing in Pub/Sub — messages during disconnect are lostIf you need replay, switch to Streams with consumer groups
High CPU on pub/sub nodeMany PSUBSCRIBE patterns evaluated on every publishSwitch to exact SUBSCRIBE where possible; reduce pattern complexity
Memory leak: too many subscriptionsNot calling UNSUBSCRIBE when sessions endAlways unsubscribe in session cleanup / WebSocket close handler

Related guides