Guide · Amazon ElastiCache · Caching Patterns · MCP Server Tools
ElastiCache Caching Patterns for MCP Servers — Cache-Aside, Write-Through, TTL Strategy, Stampede Prevention
MCP server tools that query the same data repeatedly — user profiles, feature flags, product catalogs, LLM context embeddings — spend most of their latency budget on database round trips. ElastiCache eliminates repeat queries by keeping hot data in memory at sub-millisecond latency. The challenge is not adding a Redis call: it is choosing the right pattern so cached data stays correct when the source changes, peak traffic does not cause cache stampedes, and expired entries rebuild quickly without overwhelming the database. This guide covers the four caching patterns you will actually use in production MCP tools, how to pick TTLs by data volatility, how to prevent thundering herds with a mutex lock, and how to bulk-warm the cache at startup.
TL;DR
Use cache-aside (lazy loading) for data you read far more than you write. Use write-through for data where stale reads are unacceptable and write frequency is low. Set TTL by data volatility — user preferences 10 min, product catalog 1 hour, exchange rates 10 seconds. Prevent thundering herds on popular keys by acquiring a short-lived mutex lock (SET lock:key 1 NX PX 5000) before hitting the database. Add random jitter to TTLs (baseTTL + Math.random() * jitter) so a large batch of cache entries loaded together do not all expire at the same second.
Cache-aside (lazy loading)
Cache-aside is the most common pattern for MCP tools. On a cache miss, the application fetches from the database, writes to cache, and returns the value. The cache is populated on demand — only keys that are actually read end up in memory.
import Redis from 'ioredis';
import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb';
const redis = new Redis({
host: process.env.ELASTICACHE_PRIMARY_ENDPOINT,
port: 6379,
tls: {},
password: process.env.ELASTICACHE_AUTH_TOKEN,
lazyConnect: true,
});
type UserProfile = { userId: string; name: string; tier: string; createdAt: string };
async function getUserProfile(userId: string): Promise {
const cacheKey = `user:profile:${userId}`;
// 1. Try cache first
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached);
}
// 2. Cache miss — fetch from database
const item = await ddb.send(new GetCommand({
TableName: process.env.USERS_TABLE!,
Key: { userId },
}));
if (!item.Item) {
// Cache negative result to avoid thundering herd on non-existent keys
await redis.set(cacheKey, 'null', 'EX', 60); // 60s negative TTL
return null;
}
const profile = item.Item as UserProfile;
// 3. Write to cache with TTL
const ttl = 600 + Math.floor(Math.random() * 60); // 10 min + jitter
await redis.set(cacheKey, JSON.stringify(profile), 'EX', ttl);
return profile;
}
Negative caching: cache 'null' (or a sentinel string) for keys that do not exist in the database. Without negative caching, every lookup for a non-existent userId hits the database — an easy vector for cache bypass attacks or simply wasted queries for deleted accounts.
TTL jitter: Math.floor(Math.random() * 60) adds up to 60 seconds of random spread. If you load 10,000 user profiles at startup with a base TTL of 600 seconds, all 10,000 would expire simultaneously without jitter — causing 10,000 concurrent database queries. Jitter spreads expiries across a 60-second window.
Write-through caching
Write-through updates cache synchronously on every database write. The cache always reflects the latest state — there is no stale window. The tradeoff is that every write costs two round trips (database + cache), and cache entries for infrequently-read data consume memory unnecessarily.
async function updateUserProfile(
userId: string,
updates: Partial,
): Promise {
// 1. Fetch current item (needed for merge)
const current = await getUserProfile(userId);
if (!current) throw new Error(`User ${userId} not found`);
const updated = { ...current, ...updates };
// 2. Write to database first (source of truth)
await ddb.send(new PutCommand({
TableName: process.env.USERS_TABLE!,
Item: updated,
ConditionExpression: 'attribute_exists(userId)', // prevent recreating deleted users
}));
// 3. Write to cache immediately (write-through)
const cacheKey = `user:profile:${userId}`;
const ttl = 600 + Math.floor(Math.random() * 60);
await redis.set(cacheKey, JSON.stringify(updated), 'EX', ttl);
return updated;
}
async function deleteUserProfile(userId: string): Promise {
// 1. Delete from database
await ddb.send(new DeleteCommand({
TableName: process.env.USERS_TABLE!,
Key: { userId },
}));
// 2. Delete from cache immediately — do not let it linger until TTL expiry
await redis.del(`user:profile:${userId}`);
}
Always delete on hard deletes. If you delete a record from the database but only let the cache expire naturally, MCP tools may continue returning data for deleted users until the TTL fires. Explicit DEL on the cache key is mandatory for delete operations.
Write-through vs cache-aside decision: use write-through when reads and writes are balanced, stale data is unacceptable (financial balances, permissions), and you can afford the extra write latency. Use cache-aside when reads are 10× more frequent than writes, some data is never read (saves memory), and brief staleness (seconds to minutes) is tolerable.
TTL strategy by data volatility
Picking TTLs is a business decision disguised as a technical one. Too short — you evict items before they are reused, defeating the cache. Too long — users see stale data. Use volatility (how often data changes) as your guide:
| Data type | Change frequency | Recommended TTL | Notes |
|---|---|---|---|
| Exchange rates, real-time metrics | Every few seconds | 10–30 seconds | Cache to absorb burst reads between upstream fetches |
| Inventory levels, prices | Minutes | 60–300 seconds | Accept brief staleness; pair with write-through on explicit updates |
| User preferences, profile | Hours | 600–1800 seconds | Jitter ±10% to spread expirations |
| Product catalog, documentation | Daily | 3600–86400 seconds | Invalidate explicitly on content publish events |
| Feature flags | Rare, explicit | 300 seconds max + explicit invalidation | Always invalidate on flag change — never rely solely on TTL |
| LLM embeddings for search | Never (content hash) | No TTL (persistent) or 7+ days | Key by content hash; never expires unless content changes |
| Rendered HTML fragments | On content update | Explicit invalidation + 3600s fallback TTL | Fallback TTL prevents infinite staleness if invalidation event is missed |
Always set a fallback TTL even when you plan to use explicit invalidation. If the invalidation event is lost (message queue failure, deploy race), the fallback TTL bounds the staleness window.
Thundering herd prevention with mutex lock
When a popular cache key expires, every concurrent request that finds the cache empty races to rebuild it — all hitting the database simultaneously. For a key serving 500 requests/second, the expiry moment generates 500 database queries in a 1-millisecond window. This is the thundering herd (or cache stampede) problem.
The standard Redis solution is a short-lived mutex lock: the first caller sets a lock key with SET NX PX (not exists, millisecond expiry), does the database fetch, populates the cache, and releases the lock. All other callers that find the lock set wait briefly and then read from the now-populated cache.
const LOCK_TTL_MS = 5000; // lock expires after 5 seconds (even if holder crashes)
const WAIT_POLL_MS = 50; // poll interval while waiting for lock release
const MAX_WAIT_MS = 3000; // maximum time to wait for a lock
async function getWithMutex(
cacheKey: string,
buildValue: () => Promise,
cacheTTL: number,
): Promise {
// Fast path: cache hit
const cached = await redis.get(cacheKey);
if (cached !== null) return JSON.parse(cached);
const lockKey = `lock:${cacheKey}`;
const lockValue = `${Date.now()}-${Math.random()}`;
// Try to acquire the mutex
const acquired = await redis.set(lockKey, lockValue, 'PX', LOCK_TTL_MS, 'NX');
if (acquired === 'OK') {
// We hold the lock — build and cache the value
try {
const value = await buildValue();
const ttl = cacheTTL + Math.floor(Math.random() * Math.floor(cacheTTL * 0.1));
await redis.set(cacheKey, JSON.stringify(value), 'EX', ttl);
return value;
} finally {
// Release lock only if we still own it (Lua for atomic check-and-delete)
await redis.eval(
`if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end`,
1, lockKey, lockValue,
);
}
}
// Another caller holds the lock — wait for cache to be populated
const deadline = Date.now() + MAX_WAIT_MS;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, WAIT_POLL_MS));
const result = await redis.get(cacheKey);
if (result !== null) return JSON.parse(result);
}
// Fallback: bypass cache if wait timed out
console.warn(`[cache] mutex wait timeout for ${cacheKey}, bypassing cache`);
return buildValue();
}
Lock release atomicity: the GET lockKey === lockValue check before DEL prevents releasing a lock you no longer own. If your process took longer than LOCK_TTL_MS to build the value, the lock has already expired and another process may have acquired it. Without the atomic check-and-delete, you would delete someone else's lock.
Staggered TTL extension (probabilistic expiry): an alternative to locks is to start rebuilding the cache slightly before it expires — the XFetch algorithm. Each read checks if the remaining TTL is below a threshold and, if so, probabilistically rebuilds the cache in the background while still serving the stale value. This avoids the lock entirely but requires tracking the rebuild time and is more complex to implement correctly.
Cache warming at startup
Cold cache = every request misses until the cache fills — a traffic spike on startup drives maximum database load at the worst possible moment. For predictable hot keys (top N users, global config, feature flags), pre-warm the cache during the deployment phase before traffic shifts to the new instance.
async function warmCache(): Promise {
console.log('[cache] warming started');
const pipeline = redis.pipeline();
// Warm feature flags (small, predictable set)
const flags = await fetchAllFeatureFlags(); // from DB
for (const [key, value] of Object.entries(flags)) {
pipeline.set(`flag:${key}`, JSON.stringify(value), 'EX', 300);
}
// Warm top 1000 user profiles by activity
const topUsers = await fetchTopUsers(1000);
for (const user of topUsers) {
const ttl = 600 + Math.floor(Math.random() * 60);
pipeline.set(`user:profile:${user.userId}`, JSON.stringify(user), 'EX', ttl);
}
// Execute all SET commands in one pipeline round trip
await pipeline.exec();
console.log(`[cache] warmed ${flags.length + topUsers.length} entries`);
}
// In Lambda: call warmCache() inside the module-level IIFE (runs once per cold start)
// In ECS: call before registering the task as healthy in the target group
Use ioredis pipeline() to batch all SET commands into a single TCP round trip. Warming 1,000 entries with individual SET calls at 0.3 ms/call takes 300 ms. With pipeline, the same 1,000 entries take 2–5 ms.
Tag-based cache invalidation
When a database record changes, you need to invalidate every cached key that derived its value from that record. Product pages cache SEO snippets, user views, related-items lists — all derived from the same product record. Tag-based invalidation tracks which cache keys belong to a logical tag, so a single product update invalidates all of them.
const INVALIDATION_SCRIPT = `
local tag_key = KEYS[1]
local keys = redis.call('SMEMBERS', tag_key)
if #keys > 0 then
redis.call('DEL', unpack(keys))
end
redis.call('DEL', tag_key)
return #keys
`;
async function setCacheWithTag(
cacheKey: string,
tagKey: string,
value: unknown,
ttl: number,
): Promise {
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(value), 'EX', ttl);
pipeline.sadd(tagKey, cacheKey); // register key under tag
pipeline.expire(tagKey, ttl + 60); // tag TTL slightly longer than entries
await pipeline.exec();
}
async function invalidateByTag(tagKey: string): Promise {
const deleted = await redis.eval(INVALIDATION_SCRIPT, 1, tagKey) as number;
console.log(`[cache] invalidated ${deleted} keys for tag ${tagKey}`);
return deleted;
}
// Usage: when product p-456 is updated
await setCacheWithTag(
`product:seo:p-456:en`,
`tag:product:p-456`,
seoData,
3600,
);
await setCacheWithTag(
`product:view:p-456:desktop`,
`tag:product:p-456`,
viewData,
3600,
);
// On product update — invalidates both keys above
await invalidateByTag(`tag:product:p-456`);
The Lua script deletes all member keys atomically then deletes the tag set itself. The unpack(keys) call passes all members as separate arguments to DEL, which accepts multiple keys in one command. For very large tag sets (thousands of members), this Lua script may hit the 5-second Lua execution timeout — use SCAN to process in batches for tag sets with hundreds of thousands of keys.
Eviction policy and maxmemory
Always configure maxmemory and maxmemory-policy in ElastiCache parameter groups. Without them, an unbounded cache will fill instance memory, eventually triggering OOM errors. The correct policy depends on your data:
| Policy | What gets evicted | Use when |
|---|---|---|
volatile-lru | Least recently used keys with TTL set | Default for most caches — protects persistent data (no-TTL keys) |
allkeys-lru | Least recently used keys across all keys | All keys have similar importance; memory efficiency over data safety |
volatile-ttl | Keys with the shortest remaining TTL | You prefer to evict keys that were expiring soon anyway |
noeviction | No eviction — returns OOM error on new writes | Rate limit counters, session tokens — eviction would be worse than OOM |
For MCP tool caches, use volatile-lru: it only evicts keys that have a TTL set, leaving any keys you explicitly stored without a TTL (like pre-computed static data) untouched.
Related guides
- ElastiCache for Redis: VPC setup, parameter groups, ioredis connection
- ElastiCache session state: distributed locking, WATCH/MULTI/EXEC
- ElastiCache rate limiting: Lua scripts, sliding window, token bucket
- ElastiCache keyspace notifications: TTL expiry events, dedicated subscriber
- ElastiCache Global Datastore: cross-region replication and failover