Guide · AWS ElastiCache

MCP Server ElastiCache Keyspace Notifications — TTL expiry events, session cleanup

ElastiCache keyspace notifications let your MCP server react to Redis events — including key expiry — without polling. Three constraints shape how you use them: ElastiCache does not allow CONFIG SET notify-keyspace-events at runtime — you must set notify-keyspace-events in a custom parameter group before cluster creation (or apply a parameter group change, which requires a reboot); a subscribed ioredis connection cannot issue regular commands — once you call SUBSCRIBE or PSUBSCRIBE, the connection enters subscribe-only mode; create a dedicated subscriber connection separate from your main Redis client; and delivery is at-most-once — if your subscriber is disconnected when a key expires, the notification is dropped; expired-key cleanup that must be reliable should use lazy cleanup (check TTL on access) or a scheduled SCAN sweep, not keyspace notifications alone.

TL;DR

Enable notify-keyspace-events = Ex in your ElastiCache parameter group (E = keyevent notifications, x = expired events). Create a separate ioredis connection for subscribing — never mix subscribe and command traffic on the same connection. Subscribe to __keyevent@0__:expired to receive the key name when a session TTL fires. Do not rely solely on notifications for critical cleanup; add a lazy cleanup check on session access as a safety net.

Enabling keyspace notifications

The notify-keyspace-events parameter controls which events Redis publishes to keyspace notification channels. On ElastiCache, this parameter must be set in a custom parameter group — CONFIG SET is blocked at runtime.

The value is a string of flag characters:

FlagWhat it enables
KKeyspace events, published to __keyspace@<db>__:<key> channels (one channel per key)
EKeyevent events, published to __keyevent@<db>__:<event> channels (one channel per event type)
gGeneric commands: DEL, EXPIRE, RENAME, COPY, etc.
$String commands: SET, GETSET, INCR, etc.
xExpired events — when a key TTL reaches zero and the key is deleted
dModule key type events
tStream commands

For MCP session cleanup, enable only Ex — keyevent notifications for expired events. This gives you the lowest overhead: one pub/sub message per expired key, published to a predictable channel name.

// CDK: parameter group with keyspace notifications enabled
import * as elasticache from "aws-cdk-lib/aws-elasticache";

const paramGroup = new elasticache.CfnParameterGroup(this, "RedisParamGroup", {
  cacheParameterGroupFamily: "redis7",
  description: "MCP server Redis — keyspace notifications for session expiry",
  properties: {
    "maxmemory-policy": "volatile-lru",
    // E = keyevent notifications, x = expired events only
    "notify-keyspace-events": "Ex",
    "hz": "20",                          // Higher hz = more responsive expiry detection
    "lazyfree-lazy-expire": "yes",
  },
});

After changing the parameter group, ElastiCache requires a reboot of each cache cluster node to apply the change (unlike some other parameter changes that apply immediately). Plan for a brief connection interruption when applying this to an existing cluster.

Subscribing to expiry events

When a key with a TTL expires, ElastiCache publishes to the channel __keyevent@0__:expired with the expired key name as the message body. Database index is always 0 on ElastiCache (multiple databases are not supported).

import Redis from "ioredis";

// Dedicated subscriber connection — do NOT reuse the main Redis client
const subscriber = new Redis({
  host: process.env.ELASTICACHE_PRIMARY_ENDPOINT,
  port: 6379,
  password: process.env.REDIS_AUTH_TOKEN,
  tls: {},
  // Subscriber connections can stay idle — disable timeout
  enableReadyCheck: true,
  lazyConnect: false,
});

// Subscribe to keyevent expired notifications (database 0)
await subscriber.subscribe("__keyevent@0__:expired");

subscriber.on("message", async (channel: string, expiredKey: string) => {
  if (channel !== "__keyevent@0__:expired") return;

  // expiredKey is the full key name that just expired
  // For MCP sessions stored as "session:{sessionId}:state", extract the session ID:
  const match = expiredKey.match(/^session:([^:]+):/);
  if (!match) return;

  const sessionId = match[1];
  console.log(`Session expired: ${sessionId}`);

  // Clean up any associated server-side state
  await cleanupSession(sessionId);
});

subscriber.on("error", (err) => {
  console.error("Subscriber connection error:", err.message);
  // ioredis auto-reconnects — notifications missed during disconnect are gone
});

The subscriber connection enters subscribe-only mode after subscribe() is called. You cannot call GET, SET, or any other non-subscribe command on this connection. Keep a separate redisClient for regular commands.

Pattern subscribe for multiple key types

If your MCP server stores multiple types of expirable keys (sessions, rate limit counters, tool call locks), use psubscribe with a glob pattern to match all relevant key prefixes:

// Subscribe to expired events for keys matching specific prefixes
await subscriber.psubscribe("__keyevent@0__:expired");

subscriber.on("pmessage", (pattern: string, channel: string, expiredKey: string) => {
  if (expiredKey.startsWith("session:")) {
    handleSessionExpiry(expiredKey);
  } else if (expiredKey.startsWith("lock:tool:")) {
    handleLockExpiry(expiredKey);
  } else if (expiredKey.startsWith("ratelimit:")) {
    handleRateLimitExpiry(expiredKey);
  }
});

// Alternatively, use keyspace notifications to watch a specific key:
// __keyspace@0__:{session:abc123}:state publishes the event name ("expired", "set", "del")
// when the key {session:abc123}:state changes
await subscriber.subscribe("__keyspace@0__:{session:abc123}:state");
subscriber.on("message", (channel: string, event: string) => {
  if (event === "expired") {
    // The specific session key just expired
  }
});

Delivery guarantees and reliability patterns

Keyspace notifications are at-most-once. Redis publishes the notification at the moment a key expires, using its pub/sub mechanism. If no subscriber is connected at that moment, the notification is lost — there is no retry or persistence.

For MCP session cleanup that must be reliable, combine keyspace notifications with a safety net:

Pattern 1: Lazy cleanup on access

export async function getSession(sessionId: string) {
  const key = `session:${sessionId}:state`;
  const raw = await redisClient.get(key);
  if (!raw) {
    // Key expired (or never existed) — trigger cleanup idempotently
    await cleanupSession(sessionId);
    return null;
  }
  return JSON.parse(raw);
}

Pattern 2: Scheduled SCAN sweep

// Run periodically (e.g., every 5 minutes) to clean up orphaned state
async function sweepExpiredSessions(): Promise {
  // SCAN over session state keys that no longer have a corresponding session key
  let cursor = "0";
  do {
    const [nextCursor, keys] = await redisClient.scan(
      cursor,
      "MATCH",
      "session:*:metadata",  // Sweep metadata keys associated with sessions
      "COUNT",
      100
    );
    cursor = nextCursor;

    for (const key of keys) {
      const sessionId = key.replace(/^session:|:metadata$/g, "");
      const sessionExists = await redisClient.exists(`session:${sessionId}:state`);
      if (!sessionExists) {
        // Session state expired but metadata was not cleaned up via notification
        await cleanupSession(sessionId);
      }
    }
  } while (cursor !== "0");
}

Keyspace notifications work best for soft cleanup (logging, metrics, warming caches) rather than hard cleanup (releasing locks, freeing resources) where reliability is critical.

Cluster mode and keyspace notifications

In ElastiCache cluster mode enabled (CME), each shard publishes its own keyspace notifications independently. A subscriber connected to one shard only receives notifications for keys owned by that shard. To receive expiry notifications for all keys across all shards, you must subscribe to each shard's primary endpoint separately.

import Redis from "ioredis";

// In cluster mode, subscribe to each shard separately for expiry notifications
const clusterForDiscovery = new Redis.Cluster(
  [{ host: process.env.ELASTICACHE_CONFIG_ENDPOINT!, port: 6379 }],
  { redisOptions: { password: process.env.REDIS_AUTH_TOKEN, tls: {} } }
);

// Get all master nodes from the cluster
const nodes = clusterForDiscovery.nodes("master");

// Create a subscriber per shard
const subscribers = nodes.map((node) => {
  const sub = new Redis({
    host: node.options.host,
    port: node.options.port,
    password: process.env.REDIS_AUTH_TOKEN,
    tls: {},
  });

  sub.subscribe("__keyevent@0__:expired");
  sub.on("message", (_channel: string, expiredKey: string) => {
    handleExpiredKey(expiredKey);
  });
  return sub;
});

This is significantly more complex than single-node subscriptions. For most MCP deployments, prefer a non-cluster (cluster mode disabled) ElastiCache replication group for session state, which is simpler to subscribe to and sufficient for most throughput requirements.

Common failure modes

SymptomCauseFix
No expiry notifications received even though keys expirenotify-keyspace-events is empty string (disabled) in the parameter group, or the parameter group was not applied (requires reboot)Set notify-keyspace-events = Ex in the parameter group; reboot the cluster nodes; verify with redis-cli CONFIG GET notify-keyspace-events from within the VPC
ERR Command not allowed with subscribed connectionRegular Redis command (GET, SET, etc.) sent on the same connection that issued SUBSCRIBECreate a dedicated subscriber connection for SUBSCRIBE/PSUBSCRIBE; use a separate ioredis instance for regular commands
Notifications received for some expiries but not othersAt-most-once delivery — notifications missed during subscriber disconnect are droppedAdd lazy cleanup (check on access) and/or a periodic SCAN sweep as a reliability safety net; do not rely solely on notifications for critical cleanup
Expiry notifications arrive significantly after the TTL firesRedis expires keys lazily — a key may not be deleted until it is accessed or the active expiry cycle runs; hz parameter controls active expiry frequencyIncrease hz to 20 or higher in the parameter group for faster active expiry; or use volatile-lru eviction which promotes active cleanup; accept that notifications may lag by up to 1000ms / hz
In cluster mode, only receiving expiry notifications for some sessionsOnly subscribed to one shard; other shards publish independentlySubscribe to each shard's primary node separately, or use a cluster-mode-disabled replication group for session state that requires notification subscriptions