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 |
|---|---|---|
| Persistence | No — ephemeral, fire-and-forget | Yes — persisted in sorted log |
| Delivery | At-most-once (missed if not subscribed) | At-least-once with ACK |
| Replay | No | Yes — read from any position |
| Consumer groups | No | Yes — competing consumers, balanced delivery |
| Message backpressure | None — slow subscribers fall behind and disconnect | Bounded via MAXLEN |
| Cluster mode | Shard-local (workaround: hash tag) | Key-routed (hash tag for co-location) |
| Use for | Real-time notifications, config broadcast, cache invalidation | Event 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
| Error | Cause | Fix |
|---|---|---|
ERR Command not allowed in subscribe mode | Running GET/SET/PUBLISH on the subscriber connection | Use a separate Redis connection for non-subscribe commands |
| Subscriber never receives messages | Publisher and subscriber on different shards in cluster mode | Use hash tag {pubsub} prefix on all channels, or use non-clustered node |
| PSUBSCRIBE returns no matches in cluster mode | PSUBSCRIBE not supported across cluster shards | Use exact SUBSCRIBE, or pin channels to single shard with hash tag |
| Message storm after reconnect | No such thing in Pub/Sub — messages during disconnect are lost | If you need replay, switch to Streams with consumer groups |
| High CPU on pub/sub node | Many PSUBSCRIBE patterns evaluated on every publish | Switch to exact SUBSCRIBE where possible; reduce pattern complexity |
| Memory leak: too many subscriptions | Not calling UNSUBSCRIBE when sessions end | Always unsubscribe in session cleanup / WebSocket close handler |
Related guides
- ElastiCache for Redis: VPC setup, parameter groups, ioredis connection
- ElastiCache cluster mode: hash slots, MOVED/ASK redirects, CROSSSLOT
- ElastiCache keyspace notifications: TTL expiry events, dedicated subscriber
- Redis Streams for MCP servers: consumer groups, at-least-once delivery
- ElastiCache caching patterns: cache-aside, write-through, TTL strategy