Every multi-turn coding agent accumulates junk between messages: partial file diffs, last tool JSON, embedding of the open buffer, a short-lived grant for shell_exec. Stuffing all of that into DynamoDB works until p99 latency and RCU cost make the hot path ugly. ElastiCache for Redis is the right scratchpad: millisecond GETs, TTL that actually means TTL, and Lua/pipelines for idempotent tool-result keys. Use DynamoDB for durable session truth; use Redis for the working set that dies with the turn.
⚡ TL;DR: Put scratchpads and tool-result caches in ElastiCache Redis with short TTLs and idempotency keys. Reuse Redis connections across Lambda invokes (module-level client). Keep durable session + WebSocket connection IDs in DynamoDB — see DynamoDB Streams session expiry hooks and API Gateway WebSockets for multi-turn coding agents. Gate tool availability with AppConfig kill switches.
What belongs in Redis vs DynamoDB
| Data | Store | Why |
|---|---|---|
WebSocket connectionId, tenant, auth subject |
DynamoDB | Durable, streamable on TTL delete |
| Last N tool results for the current turn | Redis | Hot, discardable, high QPS |
| Scratchpad (model “notes”, open files index) | Redis TTL 15–60m | Evict freely |
| Idempotency key → tool result | Redis SET NX + TTL | Fast dedupe under retry |
| Billing / audit of tool calls | DynamoDB / S3 | Must survive cache flush |
// ❌ Everything in DynamoDB — durable but slow for scratch
await ddb.send(new PutCommand({
TableName: "sessions",
Item: {
pk: `sess#${sessionId}`,
scratchpad: giantJson, // rewritten every tool hop
toolResults: [...previous, latest],
},
}));
✅ Split: DynamoDB holds the session envelope; Redis holds scratch + tool-result cache.
TTL scratchpads and key layout
Design keys so a flush of one session never touches another tenant.
agent:{tenantId}:{sessionId}:scratch → HASH or STRING, TTL 1800
agent:{tenantId}:{sessionId}:tool:{idemKey} → STRING (JSON result), TTL 900
agent:{tenantId}:{sessionId}:turn:{n}:ctx → STRING, TTL 1800
// ✅ Scratchpad helpers with ioredis (Node 20 Lambda)
import Redis from "ioredis";
const redis =
(globalThis as any).__agentRedis ??
new Redis({
host: process.env.REDIS_HOST!,
port: Number(process.env.REDIS_PORT ?? 6379),
tls: process.env.REDIS_TLS === "1" ? {} : undefined,
maxRetriesPerRequest: 2,
enableReadyCheck: true,
lazyConnect: false,
});
(globalThis as any).__agentRedis = redis;
function scratchKey(tenantId: string, sessionId: string) {
return `agent:${tenantId}:${sessionId}:scratch`;
}
export async function patchScratch(
tenantId: string,
sessionId: string,
patch: Record<string, string>,
ttlSec = 1800
) {
const key = scratchKey(tenantId, sessionId);
const pipe = redis.pipeline();
for (const [field, value] of Object.entries(patch)) {
pipe.hset(key, field, value);
}
pipe.expire(key, ttlSec);
await pipe.exec();
}
export async function getScratch(tenantId: string, sessionId: string) {
return redis.hgetall(scratchKey(tenantId, sessionId));
}
Module-level (or globalThis) client reuse is mandatory on Lambda: establishing TLS to Redis on every cold invoke is a tax; establishing it on every warm invoke is negligence.
Idempotent tool-result keys
Agent runtimes retry. API Gateway times out. Step Functions Map items retry. Without idempotency you double-apply patches.
// ✅ SET NX tool result — first writer wins
export async function cacheToolResult(
tenantId: string,
sessionId: string,
idempotencyKey: string,
result: unknown,
ttlSec = 900
): Promise<{ stored: boolean; value: unknown }> {
const key = `agent:${tenantId}:${sessionId}:tool:${idempotencyKey}`;
const payload = JSON.stringify(result);
const ok = await redis.set(key, payload, "EX", ttlSec, "NX");
if (ok === "OK") return { stored: true, value: result };
const existing = await redis.get(key);
return { stored: false, value: existing ? JSON.parse(existing) : null };
}
export async function runToolOnce(
tenantId: string,
sessionId: string,
call: { name: string; args: unknown; idempotencyKey: string }
) {
const key = `agent:${tenantId}:${sessionId}:tool:${call.idempotencyKey}`;
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const result = await executeTool(call); // side-effecting
const { value } = await cacheToolResult(
tenantId,
sessionId,
call.idempotencyKey,
result
);
return value;
}
❌ Using only DynamoDB conditional writes for every tool hop is correct but slower. Prefer Redis for the hot cache; optionally mirror final results to DynamoDB for audit. Align keys with AI Coding Agent Tool Schemas.
Connection reuse, AUTH, and VPC
ElastiCache lives in a VPC. Your agent Lambdas need:
- VPC config (subnets + SG that allow 6379/6380 to the cluster SG)
- Redis AUTH token or IAM auth (Redis 7+) in Secrets Manager
- Prefer cluster mode disabled for simple scratchpads unless you shard by tenant deliberately
- TLS in transit for anything that might hold code snippets or secrets
// ❌ new Redis() inside the handler — handshake every invoke
export async function handler() {
const r = new Redis({ host: process.env.REDIS_HOST! });
await r.get("x");
r.disconnect();
}
// ✅ reuse across warm invokes; handle reconnect
redis.on("error", (err) => console.error("redis_error", err));
For multi-AZ, use ElastiCache replication groups with automatic failover. Accept that a failover can drop in-flight scratch — design agents to rebuild scratch from the last durable session snapshot in DynamoDB.
Contrast with DynamoDB session store
Use DynamoDB Streams TTL hooks (session expiry post) to close WebSockets and revoke grants when the session dies. Redis TTL alone will not notify you to close a socket — pair both stores:
- DynamoDB item TTL fires → Stream REMOVE → Lambda closes API Gateway connection, deletes Redis
agent:{tenant}:{session}:* - Redis TTL alone expires scratch quietly (fine)
// ✅ Session teardown also purges Redis namespace
export async function purgeSessionCache(tenantId: string, sessionId: string) {
const pattern = `agent:${tenantId}:${sessionId}:*`;
let cursor = "0";
do {
const [next, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
cursor = next;
if (keys.length) await redis.del(...keys);
} while (cursor !== "0");
}
Wire public agent endpoints behind rate limits (API Gateway WebSockets) so a flood cannot fill Redis with scratch keys.
Operational checklist
- [ ] Split durable session (DynamoDB) from scratch/tool cache (Redis)
- [ ] Key by
tenantId+sessionId; never shared global scratch keys - [ ] SET NX + TTL for tool idempotency keys
- [ ] Module-level Redis client on Lambda; TLS + AUTH
- [ ] On DynamoDB session expiry, SCAN/DEL the Redis namespace
- [ ] Cap scratch size (reject HSET if payload > N KB)
- [ ] Alert on Redis CPU / evictions — eviction of tool results causes re-exec side effects
Related reading
- DynamoDB Streams Session Expiry Hooks for Multi-Turn Coding Agents
- API Gateway WebSockets for Multi-Turn Coding Agents
- AppConfig Feature Flags: Kill Switches for Agent Tools Without Redeploy
- AI Coding Agent Tool Schemas: Strict JSON Contracts That Survive Retries
Last updated on September 20, 2026
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- LLM context windows: Tokens, Context Windows, and Why Models Forget Mid-Task
- Zero-Copy Node Streams: Pipe Large S3 Objects Without Buffering
- SQL Joins Explained: INNER, LEFT, RIGHT, FULL, CROSS, and Self Joins
- PostgreSQL Performance Tuning: The Configuration Changes That Actually Matter
Newly added
- API Gateway + WAF: Rate-Limit Public Coding Agent Endpoints
- AWS Verified Permissions: Cedar Policies That Authorize Agent Tools
- ADOT OpenTelemetry: Trace Multi-Hop Agent Tool Calls Across Lambda
- ElastiCache Redis: Scratchpads and Tool-Result Cache for Multi-Turn Coding Agents
- Step Functions: Orchestrate Multi-Step Coding Agent Graphs Without Recursive Chaos
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.