AWS DynamoDB · 2026-09-10 · DynamoDB arc
AWS DynamoDB for MCP Servers: Session Store, TTL, Streams, Transactions, and DAX — Five Production Patterns
DynamoDB is a natural fit for MCP session state — each session maps to a single partition key, reads and writes are fast single-item operations, and TTL handles cleanup automatically. But MCP servers are concurrent by design: parallel tool calls, agent retries, and multi-tab users all write to the same session item simultaneously. Without the right patterns, concurrent writes silently overwrite each other, expired sessions are returned as valid, and Lambda retries create duplicate tool call records. Five patterns address the production failure modes that emerge when you put a real MCP workload on DynamoDB.
TL;DR
Use a version attribute + ConditionExpression: "#v = :expected" on every session metadata update to prevent concurrent corruption. Store TTL as epoch seconds, not milliseconds, and filter expired items in application code. Enable StreamViewType: NEW_AND_OLD_IMAGES and set bisectBatchOnError: true on the Lambda event source mapping. Use ClientRequestToken: toolCallId on TransactWriteItems for 10-minute idempotency. Use DAX for read-heavy sessions; never route TransactWriteItems through DAX (it's not supported).
Pattern 1: Session store with optimistic locking
A DynamoDB session table for MCP servers works best with a single-table design that separates mutable session metadata from immutable tool call records using sort key prefixes:
pk=session#abc,sk=metadata— mutable session state: status, accumulated context messages, version counter, TTLpk=session#abc,sk=tool#<ISO8601>#<toolCallId>— immutable tool call records, written once per tool invocation
The critical design decision: every write to the metadata item must use optimistic locking. MCP servers receive parallel tool calls — an agent fires three tools concurrently in the same session. Without locking, all three read version=4, all three write with SET version=5, and two writes silently overwrite the first. The result: lost context messages, dropped tool results, corrupted session state.
Optimistic locking with a version attribute:
- Read the session item with
ConsistentRead: true(stale reads would give a stale version) - Include
ConditionExpression: "#v = :expectedVersion"andSET #v = #v + :incin everyUpdateItem - On
ConditionalCheckFailedException: re-read, re-compute the change, retry (up to 3 times with 50ms exponential backoff)
For context accumulation (appending new messages), avoid the read-modify-write pattern entirely. Use DynamoDB's list_append function: SET context = list_append(if_not_exists(context, :empty), :newMessages). This is atomic — two concurrent list_append updates to the same list both succeed, each appending its messages independently. Combine with the version attribute for updates that need to also mutate other fields.
Tool call records are written with ConditionExpression: "attribute_not_exists(pk)" to make them idempotent. A Lambda retry writes the same sk=tool#<ts>#<callId> — the condition check catches the duplicate and returns a ConditionalCheckFailedException that you catch and swallow. Use the tool call ID in the sort key, not a random UUID, so retries produce the same key.
Full guide: MCP Server DynamoDB Session Store — versioning, optimistic locking, conditional writes
Pattern 2: TTL for session lifecycle management
DynamoDB TTL deletes items automatically, but the deletion is eventual — expired items can linger for up to 48 hours after their TTL timestamp. For MCP session management, this creates a gap: a user whose session expired 20 minutes ago may still have their session item in the table and get a valid response from a GetItem call.
The TTL attribute must be a Unix epoch timestamp in seconds. This is the most common setup mistake: Date.now() returns milliseconds; DynamoDB interprets a millisecond timestamp as a date in the year 2258 and the item never expires. The correct value: Math.floor(Date.now() / 1000) + sessionDurationSeconds.
Application-level expiry filtering is mandatory. On every GetItem that returns a session:
const nowSec = Math.floor(Date.now() / 1000);
if (item.ttl && item.ttl <= nowSec) {
return null; // expired — treat as not found
}
This filtering is belt-and-suspenders: DynamoDB will eventually delete the item, but your application must not serve expired sessions in the meantime.
Sliding session windows: call UpdateItem to extend the TTL on every tool call. Use a ConditionExpression that rejects extends on already-expired sessions: ConditionExpression: "attribute_exists(pk) AND #ttl > :now". Without this guard, a race condition between a session expiry event and a tool call extension could briefly resurrect a session.
GSI on the TTL attribute: add a GSI with status as partition key and ttl as sort key. This enables Query operations that find sessions expiring in the next 15 minutes — useful for pre-expiry warnings, warm-up, or graceful drain before your MCP server restarts.
DynamoDB Streams integration: TTL-triggered deletions appear as REMOVE events in the stream with record.userIdentity.principalId === "dynamodb.amazonaws.com". Wire a Lambda stream processor to detect TTL expiry and trigger cleanup side effects (revoking auth tokens, notifying downstream systems, logging session summaries).
Full guide: MCP Server DynamoDB TTL — session cleanup, expiry lifecycle, GSI on TTL attribute
Pattern 3: DynamoDB Streams for session audit logs
Every write to the session table — session creation, tool call completion, status transition, TTL expiry — becomes a stream record. Wire a Lambda to the stream to build a complete audit trail without coupling the write path to the audit path.
StreamViewType must be NEW_AND_OLD_IMAGES for audit purposes. NEW_IMAGE alone cannot reconstruct what changed — you need the before and after to detect field-level diffs. Setting this cannot be changed on an existing stream without disabling and re-enabling streams (which resets the 24-hour retention window). Set it at table creation.
The most common production incident with DynamoDB Streams: a poison-pill record blocks the entire shard. If your Lambda handler throws an unhandled exception, DynamoDB retries the batch. Without BisectBatchOnFunctionError: true on the event source mapping, DynamoDB retries the same failing batch for up to 24 hours — blocking all newer records on the same shard for the duration. Enable bisection: DynamoDB halves the batch, isolates the bad record, and sends it to the dead-letter queue while processing the rest normally.
Critical settings for the Lambda event source mapping:
StartingPosition: TRIM_HORIZON— process all existing records when the trigger is createdBisectBatchOnFunctionError: true— isolate bad recordsRetryAttempts: 3— prevent infinite retry loopsOnFailure: SqsDlq— send unprocessable records to a DLQ for manual inspection
The Lambda handler must be idempotent. Lambda delivers stream records at-least-once — the same record may arrive twice on retry. Use the stream record's SequenceNumber as an idempotency key: write the audit record with ConditionExpression: "attribute_not_exists(sequenceNumber)" and catch the resulting ConditionalCheckFailedException as a silent success (not an error).
Ordering: records within a shard are in strict write order per partition key. A session's events arrive in the order they were written. Records across shards have no ordering guarantee — if your audit log needs global ordering, use a DynamoDB Streams → Kinesis Data Streams bridge (which supports enhanced fan-out and per-shard ordering).
Full guide: MCP Server DynamoDB Streams — audit trails, session event sourcing, Lambda triggers
Pattern 4: Transactions for idempotent multi-step tool calls
TransactWriteItems lets an MCP tool call atomically write to multiple items — the session metadata, a tool call record, and an audit entry — in a single all-or-nothing operation. If the Lambda times out after the transaction commits but before returning a response, a retry with the same idempotency token gets the original result without re-executing.
The ClientRequestToken parameter is the key to idempotency. Pass the MCP tool call ID: ClientRequestToken: toolCallId. DynamoDB remembers this token for 10 minutes — retries within that window return the original result without re-running the transaction. After 10 minutes, the token expires and a new submission with the same token would execute a new transaction (guard this with a ConditionExpression on the primary item as belt-and-suspenders).
Include a ConditionCheck item in the transaction to assert preconditions:
- Assert the session is alive (not expired):
ConditionExpression: "attribute_exists(pk) AND #ttl > :nowSec" - Assert the resource doesn't already exist:
ConditionExpression: "attribute_not_exists(pk)"on the resource Put - Assert the expected version for optimistic locking:
ConditionExpression: "#v = :expectedVersion"
Error handling: only TransactionConflictException should be retried (two transactions modified the same items concurrently — retry with 50ms exponential backoff up to 3 times). TransactionCanceledException with a ConditionalCheckFailed reason means a business logic precondition failed (session expired, resource already exists, version mismatch) — do not retry, surface the specific failure to the MCP caller by inspecting err.CancellationReasons[i].Code.
Limits: 25 items maximum per transaction across all tables. 2× write capacity units per item compared to individual writes. DAX does not support TransactWriteItems — transactional writes must go directly to DynamoDB, not through the DAX client. Plan your DynamoDB client architecture so the MCP server can use DAX for reads and non-transactional writes, but falls back to the raw DynamoDB client for transactions.
Full guide: MCP Server DynamoDB Transactions — idempotent multi-step tool calls, TransactWriteItems
Pattern 5: DAX caching for read-heavy MCP sessions
DAX adds an in-memory cache in front of DynamoDB. Item reads that hit the cache return in microseconds instead of single-digit milliseconds. For MCP servers where the same session is read on every tool call in a long conversation, DAX can dramatically reduce latency and DynamoDB read costs.
DAX maintains two independent caches: the item cache (5-minute TTL by default) serves individual GetItem results keyed by primary key; the query cache (1-minute TTL) serves Query result sets keyed by the full query parameters. For MCP session management, the item cache is the valuable one — most reads are GetItem pk=session#abc, sk=metadata, and a hot session gets the same key repeatedly.
DAX is write-through: writes via the DAX client update the item cache atomically. The cache stays consistent as long as all writes go through the DAX client. Writes that bypass DAX — the AWS Console, the CLI, migration scripts, the raw DynamoDB client used for transactions — leave stale items in the cache for up to the item cache TTL.
The consistency trap: ConsistentRead: true on a GetItem through the DAX client bypasses the cache and reads directly from DynamoDB. If your MCP server needs post-write read-after-write consistency (common in interactive tool calls), it must either use ConsistentRead: true (losing the DAX benefit) or accept the small window of cache staleness after a write-through update.
DAX is VPC-only — the cluster has no public internet endpoint. Your MCP server Lambda or ECS task must be in the same VPC with outbound TCP 9111 to the DAX cluster security group. The DAX client (npm: amazon-dax-client) is API-compatible with the DynamoDB Document client — swap the constructor, keep all command objects unchanged.
When DAX is not the right choice: write-heavy sessions (every tool call writes a new record — the item cache is continuously invalidated); low traffic volumes (the cluster minimum is ~$0.27/hr for dax.t2.small); workloads dominated by TransactWriteItems (not supported); sessions updated by multiple services outside the DAX client (cache coherency across codebases is operationally complex).
Full guide: MCP Server DynamoDB DAX — in-memory caching, item cache vs query cache, consistency
Combined failure mode reference
| Pattern | Symptom | Cause | Fix |
|---|---|---|---|
| Session store | Context messages disappear under concurrent tool calls | Read-modify-write on context list; last write wins overwrites earlier concurrent writes | Use list_append in UpdateExpression; combine with version attribute for writes that also mutate other fields |
| Session store | Version counter never matches; all updates fail with ConditionalCheckFailed | Not re-reading the session before each retry; retrying with stale expected version | Always re-read with ConsistentRead: true before each retry attempt; use the freshly read version as :expectedVersion |
| TTL | Sessions never expire; items accumulate indefinitely | TTL stored in milliseconds (Date.now()) instead of seconds | Always use Math.floor(Date.now() / 1000) + durationSec |
| TTL | Expired sessions returned as valid | DynamoDB TTL deletion is eventual (up to 48h after expiry) | Filter in application: check item.ttl <= Math.floor(Date.now()/1000) on every read |
| Streams | Audit stream stops processing for hours | Poison-pill record; no bisectBatchOnError; entire shard blocked | Set BisectBatchOnFunctionError: true; add DLQ; monitor IteratorAge CloudWatch metric |
| Streams | Audit records are duplicated | Lambda retry delivers the same stream record twice | Use SequenceNumber as idempotency key; condition write on attribute_not_exists(sequenceNumber) |
| Transactions | Tool call creates duplicate resources on Lambda retry | No ClientRequestToken; each retry executes a new transaction | Pass ClientRequestToken: toolCallId; add ConditionExpression: "attribute_not_exists(pk)" as belt-and-suspenders |
| Transactions | TransactionCanceledException retried indefinitely | Business logic failure (ConditionalCheckFailed) mistakenly treated as retriable | Only retry TransactionConflictException; parse CancellationReasons to surface the correct error to the caller |
| Transactions | Unsupported operation error from DAX client | TransactWriteItems called through DAX client (not supported) | Keep a separate raw DynamoDBClient for transactional writes; use DAX client only for non-transactional reads and writes |
| DAX | Stale session data after write from Console or CLI | Write bypassed DAX write-through; cache has old value for up to item cache TTL (5 min) | Route all writes through DAX client; for operations that must be immediately consistent post-write, use ConsistentRead: true on the subsequent read |
| DAX | Connection timeout from Lambda to DAX | Lambda not in same VPC; or SG missing TCP 9111 outbound rule | Add Lambda VPC config; add outbound TCP 9111 from Lambda SG to DAX SG |
Architecture decision checklist
Use this checklist when choosing which patterns to apply for a DynamoDB-backed MCP server:
- Always use optimistic locking (version attribute + ConditionExpression) if concurrent tool calls write to the same session metadata item.
- Always use epoch seconds for TTL, and always filter expired items in application code regardless of whether DynamoDB has deleted them.
- Enable Streams with
NEW_AND_OLD_IMAGESfrom table creation if you need audit logs, TTL expiry side effects, or change data capture. - Use
TransactWriteItemswhen a tool call must atomically write to multiple items with precondition assertions. - Use DAX if the session read-to-write ratio is >5:1 and your MCP server runs in the same VPC. Do not use DAX if transactions are the primary write pattern.
- Do not use DAX for transactional writes — always keep a raw DynamoDB client for
TransactWriteItems.