Loading learning content...
You've built a banking application on DynamoDB. A user deposits $500, then immediately checks their balance. The read returns $0—the old balance. The user panics, calls support, and posts on Twitter about your "losing" their money.
Of course, the deposit wasn't lost. It was written successfully to the leader replica. But the user's read was served by a follower replica that hadn't yet received the update. You used eventually consistent reads for a use case that demanded strong consistency.
This scenario illustrates why consistency models matter. DynamoDB offers both eventual and strong consistency, each with distinct characteristics, costs, and appropriate use cases. Choosing correctly is essential for building systems that behave as users expect.
By the end of this page, you will understand the technical difference between eventual and strong consistency in DynamoDB, how DynamoDB's replication architecture enables both models, the performance and cost trade-offs of each approach, patterns for choosing the right consistency level, and techniques for handling eventual consistency gracefully when it's the right choice.
To understand consistency models, we must first understand how DynamoDB replicates data. Each partition in DynamoDB is stored across three replicas in different Availability Zones (AZs). These replicas form a replication group with one designated leader and two followers.
Write Path:
This synchronous-commit-to-two-of-three design ensures durability (data survives any single AZ failure) while maintaining low latency (we don't wait for all three replicas).
12345678910111213141516171819202122232425262728293031323334
┌─────────────────────────────────────────────────────────────────────┐│ Partition Replication Group │└─────────────────────────────────────────────────────────────────────┘ ┌─────────────────┐ Write Request ──────► │ Leader Replica │ (AZ-A) │ ████████████ │ └────────┬────────┘ │ ┌─────────────┴─────────────┐ │ Synchronous Replication │ │ (wait for 1 of 2) │ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ │ Follower Replica│ (AZ-B) │ Follower Replica│ (AZ-C) │ ████████████ │ │ ████████░░░░ │ ← may be └─────────────────┘ └─────────────────┘ slightly behind Write acknowledged when: Leader + at least 1 Follower commitThird follower catches up asynchronously (typically < 100ms) ────────────────────────────────────────────────────────────────────── READ PATHS: Eventually Consistent Read: Strongly Consistent Read:┌─────────────────┐ ┌─────────────────┐│ Any Replica │ ◄─── Request │ Leader Only │ ◄─── Request│ ████████░░░░ │ │ ████████████ │└─────────────────┘ └─────────────────┘• Lower latency (nearest AZ) • Higher latency (leader AZ)• Higher throughput (3 replicas) • Lower throughput (1 replica)• May return stale data • Always returns latest write• 1 RCU per 8 KB • 1 RCU per 4 KB (2x cost)The time between a write completing and all replicas reflecting that write is called the consistency window or replication lag. In DynamoDB, this is typically under 100 milliseconds and often single-digit milliseconds. But under load or during transient issues, it can occasionally stretch longer. Your application must decide: is this window acceptable for each read?
Eventually consistent reads are DynamoDB's default behavior. When you read an item without specifying consistency, DynamoDB can serve the read from any of the three replicas—whichever responds fastest.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
import { DynamoDBDocumentClient, GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; const docClient = DynamoDBDocumentClient.from(dynamoDBClient); // ============================================// Eventually Consistent GetItem (default)// ============================================async function getProduct(productId: string): Promise<Product | null> { const result = await docClient.send(new GetCommand({ TableName: "Products", Key: { productId }, // ConsistentRead defaults to false (eventually consistent) })); return result.Item as Product | null;} // ============================================// Explicitly Eventually Consistent Query// ============================================async function getRecentOrders(customerId: string): Promise<Order[]> { const result = await docClient.send(new QueryCommand({ TableName: "Orders", KeyConditionExpression: "customerId = :cid", ExpressionAttributeValues: { ":cid": customerId }, ConsistentRead: false, // Explicit (same as default) Limit: 10, ScanIndexForward: false // Newest first })); return result.Items as Order[];} // ============================================// GSI Query (always eventually consistent)// ============================================async function getOrdersByProduct(productId: string): Promise<Order[]> { const result = await docClient.send(new QueryCommand({ TableName: "Orders", IndexName: "ProductOrders-Index", KeyConditionExpression: "productId = :pid", ExpressionAttributeValues: { ":pid": productId }, // Note: GSI queries cannot use ConsistentRead: true // They are ALWAYS eventually consistent })); return result.Items as Order[];}A critical constraint: Global Secondary Index queries cannot use strong consistency. GSIs are updated asynchronously from the base table, introducing their own replication lag independent of the base table's replica synchronization. If you need strong consistency for a particular access pattern, you must query the base table directly.
Strongly consistent reads guarantee that the response reflects all writes that completed before the read request was received. DynamoDB achieves this by routing the read request exclusively to the leader replica, which always has the most recent data.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
import { DynamoDBDocumentClient, GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; const docClient = DynamoDBDocumentClient.from(dynamoDBClient); // ============================================// Strongly Consistent GetItem// ============================================async function getAccountBalance(accountId: string): Promise<number> { const result = await docClient.send(new GetCommand({ TableName: "Accounts", Key: { accountId }, ConsistentRead: true, // Ensure we see latest balance ProjectionExpression: "balance" })); if (!result.Item) { throw new Error(`Account ${accountId} not found`); } return result.Item.balance as number;} // ============================================// Strongly Consistent Query// ============================================async function getLatestUserSession(userId: string): Promise<Session | null> { const result = await docClient.send(new QueryCommand({ TableName: "Sessions", KeyConditionExpression: "userId = :uid", ExpressionAttributeValues: { ":uid": userId }, ConsistentRead: true, // Must see recently created sessions Limit: 1, ScanIndexForward: false // Most recent first })); return (result.Items?.[0] as Session) ?? null;} // ============================================// Pattern: Write-Then-Read with Strong Consistency// ============================================async function updateAndConfirmInventory( productId: string, quantityChange: number): Promise<{ newQuantity: number; success: boolean }> { // Step 1: Update inventory await docClient.send(new UpdateCommand({ TableName: "Inventory", Key: { productId }, UpdateExpression: "ADD quantity :delta", ExpressionAttributeValues: { ":delta": quantityChange } })); // Step 2: Read back with strong consistency to confirm const result = await docClient.send(new GetCommand({ TableName: "Inventory", Key: { productId }, ConsistentRead: true // CRITICAL: Must see the write we just made })); return { newQuantity: result.Item?.quantity ?? 0, success: true };}| Aspect | Eventually Consistent | Strongly Consistent |
|---|---|---|
| Data freshness | May be slightly stale (usually <100ms) | Guaranteed latest |
| RCU cost | 1 RCU per 8 KB | 1 RCU per 4 KB (2x) |
| Latency | Lower (any replica) | Higher (leader only) |
| Throughput | Higher (3 replicas) | Lower (1 replica) |
| GSI support | Yes (only option) | No |
| Transaction support | Only for read-only transactions | Yes, for all transactions |
| Configuration | Default behavior | Explicit opt-in |
The choice between eventual and strong consistency isn't one-size-fits-all. Different operations within the same application may require different consistency levels. Here's a framework for deciding:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// ============================================// Real Application: E-commerce Order System// ============================================ class OrderService { // ✅ EVENTUAL: Browsing order history (not time-sensitive) async getOrderHistory(customerId: string): Promise<Order[]> { return this.query({ TableName: "Orders", KeyConditionExpression: "customerId = :cid", ExpressionAttributeValues: { ":cid": customerId }, ConsistentRead: false, // OK if missing last few seconds of orders Limit: 50, ScanIndexForward: false }); } // ✅ STRONG: Checking order status for fulfillment async getOrderForFulfillment(orderId: string): Promise<Order | null> { return this.get({ TableName: "Orders", Key: { orderId }, ConsistentRead: true // MUST have current status before shipping }); } // ✅ STRONG: Read-after-write for order confirmation async placeOrder(order: Order): Promise<OrderConfirmation> { // Write the order await this.put({ TableName: "Orders", Item: order }); // Immediately read back with strong consistency // User expects to see their order right away const confirmed = await this.get({ TableName: "Orders", Key: { orderId: order.orderId }, ConsistentRead: true }); return { order: confirmed, confirmationNumber: order.orderId }; } // ✅ EVENTUAL: Product recommendations (OK if slightly stale) async getRecommendedProducts(customerId: string): Promise<Product[]> { const preferences = await this.get({ TableName: "UserPreferences", Key: { customerId }, ConsistentRead: false // Preferences update slowly anyway }); return this.computeRecommendations(preferences); } // ✅ STRONG: Inventory check before order placement async checkInventoryAvailable(productId: string, quantity: number): Promise<boolean> { const inventory = await this.get({ TableName: "Inventory", Key: { productId }, ConsistentRead: true // MUST see current inventory to avoid overselling }); return (inventory?.available ?? 0) >= quantity; }}Ask yourself: 'What happens if this read returns data that's 100ms stale?' If the answer is 'minor inconvenience' or 'user can refresh,' use eventual consistency. If the answer is 'data corruption,' 'financial error,' or 'security vulnerability,' use strong consistency.
When you choose eventual consistency (for cost or performance reasons), you can still provide a consistent user experience through careful application design. Here are proven patterns:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
// ============================================// PATTERN 1: Optimistic UI with Client-Side Cache// ============================================// Don't rely on DynamoDB to show the user their own data immediately// Use local/client state for instant feedback class OptimisticOrderService { private pendingWrites = new Map<string, Order>(); async createOrder(order: Order): Promise<void> { // Add to local cache immediately this.pendingWrites.set(order.orderId, order); // Write to DynamoDB (async, eventually consistent world will catch up) await this.saveToDb(order); // Could remove from pending writes after a delay setTimeout(() => this.pendingWrites.delete(order.orderId), 5000); } async getOrders(customerId: string): Promise<Order[]> { // Get from DynamoDB (eventually consistent is fine) const dbOrders = await this.queryFromDb(customerId); // Merge with pending writes (client-side truth) const pendingForCustomer = [...this.pendingWrites.values()] .filter(o => o.customerId === customerId); // Deduplicate: pending writes override DB version const orderMap = new Map(dbOrders.map(o => [o.orderId, o])); pendingForCustomer.forEach(o => orderMap.set(o.orderId, o)); return [...orderMap.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt) ); }} // ============================================// PATTERN 2: Write-Through with Version Tokens// ============================================// Return a token from write operations that clients can use to ensure// they're seeing at least that version interface WriteResult { success: boolean; version: string; // Timestamp or version number} class VersionedService { async updateProfile(userId: string, updates: Partial<Profile>): Promise<WriteResult> { const version = Date.now().toString(); await docClient.send(new UpdateCommand({ TableName: "Profiles", Key: { userId }, UpdateExpression: "SET #name = :name, #v = :version", ExpressionAttributeNames: { "#name": "name", "#v": "version" }, ExpressionAttributeValues: { ":name": updates.name, ":version": version } })); return { success: true, version }; } async getProfileWithMinVersion( userId: string, minVersion?: string ): Promise<Profile> { // Try eventually consistent first (cheaper) let profile = await this.getProfileEc(userId); // If version requirement not met, retry with strong consistency if (minVersion && profile.version < minVersion) { profile = await this.getProfileSc(userId); // If STILL not met, the write hasn't fully propagated // This is rare but handle it gracefully if (profile.version < minVersion) { throw new StaleDataError("Profile update still propagating"); } } return profile; }} // ============================================// PATTERN 3: Session Consistency via Sticky Routing// ============================================// If your architecture allows, route all requests in a user session// to the same region/endpoint, reducing cross-region consistency issues class SessionAwareClient { private sessionRegion: string; constructor(userId: string) { // Hash user ID to pick a consistent region // All their requests go to same DynamoDB regional endpoint this.sessionRegion = this.hashToRegion(userId); } private hashToRegion(userId: string): string { const hash = simpleHash(userId) % 3; const regions = ["us-east-1", "us-west-2", "eu-west-1"]; return regions[hash]; } // Within same region, replication is faster // User sees their own writes consistently}The most common consistency complaint is 'I just wrote this and now I can't see it.' The patterns above don't fix DynamoDB's consistency—they create the illusion of consistency through application logic. The user sees their own writes because you show them from local state, not because DynamoDB changed. This is an accepted and recommended pattern.
DynamoDB transactions provide serializable isolation—the strongest isolation level—which has specific consistency implications.
TransactWriteItems:
All items in a TransactWriteItems are written atomically. Either all succeed or none do. The writes are always strongly consistent with each other—you'll never see a partial transaction.
TransactGetItems:
TransactGetItems provides a serializable, strongly consistent snapshot of all requested items. This means:
This makes TransactGetItems more expensive than parallel GetItem calls but guarantees consistency across related items.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
import { TransactWriteCommand, TransactGetCommand } from "@aws-sdk/lib-dynamodb"; // ============================================// TransactWriteItems: Atomic Multi-Item Write// ============================================async function transferFunds( fromAccount: string, toAccount: string, amount: number): Promise<void> { await docClient.send(new TransactWriteCommand({ TransactItems: [ { Update: { TableName: "Accounts", Key: { accountId: fromAccount }, UpdateExpression: "SET balance = balance - :amount", ConditionExpression: "balance >= :amount", ExpressionAttributeValues: { ":amount": amount } } }, { Update: { TableName: "Accounts", Key: { accountId: toAccount }, UpdateExpression: "SET balance = balance + :amount", ExpressionAttributeValues: { ":amount": amount } } } ] })); // BOTH updates succeed or NEITHER does // No consistency window between them} // ============================================// TransactGetItems: Consistent Multi-Item Read// ============================================async function getOrderWithDetails(orderId: string): Promise<OrderWithDetails> { const result = await docClient.send(new TransactGetCommand({ TransactItems: [ { Get: { TableName: "Orders", Key: { orderId } } }, { Get: { TableName: "OrderItems", Key: { orderId } } }, { Get: { TableName: "ShippingInfo", Key: { orderId } } } ] })); // All three reads reflect the SAME point in time // No concurrent writes visible between them const [order, items, shipping] = result.Responses!; return { order: order.Item as Order, items: items.Item as OrderItems, shipping: shipping.Item as ShippingInfo };} // ============================================// IMPORTANT: Transaction Reads from Base Table Only// ============================================// This will FAIL:const badTransactionRead = { TransactItems: [ { Get: { TableName: "Orders", IndexName: "ProductOrders-Index", // ❌ Cannot read from GSI Key: { productId: "PROD-123" } } } ]};// Error: Transactions only support GetItem on base table, not GSI queriesTransactions are powerful but expensive. TransactWriteItems costs 2x the WCU of individual writes. TransactGetItems costs 2x the RCU of individual reads. Maximum 100 items per transaction. Use transactions when atomicity or multi-item consistency is required, not as a default.
DynamoDB Global Tables replicate data across multiple AWS regions for global availability. This introduces additional consistency considerations.
Global Tables Replication Model:
Global Tables use active-active replication—all regions accept writes simultaneously. When you write to one region:
Consistency Implications:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// ============================================// Pattern: Regional Affinity for Consistency// ============================================// Route users to consistent region for their data operations class RegionallyAffineService { private getPreferredRegion(userId: string): string { // User's primary region stored in their profile // All writes go here; other regions are read replicas return this.userPrimaryRegion.get(userId) ?? "us-east-1"; } async readWithConsistency(userId: string, itemKey: any): Promise<any> { const targetRegion = this.getPreferredRegion(userId); const client = this.getClientForRegion(targetRegion); // Reading from primary region enables strong consistency return client.send(new GetCommand({ TableName: "UserData", Key: itemKey, ConsistentRead: true })); } async writeWithConsistency(userId: string, item: any): Promise<void> { const targetRegion = this.getPreferredRegion(userId); const client = this.getClientForRegion(targetRegion); // Writing to primary region ensures predictable replication await client.send(new PutCommand({ TableName: "UserData", Item: item })); }} // ============================================// Pattern: Last-Writer-Wins Conflict Awareness// ============================================// When writes can happen in multiple regions, design for conflict resolution interface ConflictAwareItem { pk: string; sk: string; data: any; lastModified: string; // ISO timestamp with high precision modifiedBy: string; // Track which region/user made the change version: number; // Monotonic version for conflict detection} // Use conditional writes to detect conflictsasync function safeUpdate(item: ConflictAwareItem): Promise<{ success: boolean; conflicted: boolean }> { try { await docClient.send(new UpdateCommand({ TableName: "GlobalData", Key: { pk: item.pk, sk: item.sk }, UpdateExpression: "SET #data = :data, lastModified = :ts, modifiedBy = :by, version = :newV", ConditionExpression: "version = :oldV OR attribute_not_exists(version)", ExpressionAttributeNames: { "#data": "data" }, ExpressionAttributeValues: { ":data": item.data, ":ts": new Date().toISOString(), ":by": process.env.AWS_REGION, ":newV": item.version + 1, ":oldV": item.version } })); return { success: true, conflicted: false }; } catch (error) { if (error.name === "ConditionalCheckFailedException") { // Another region updated first - conflict detected return { success: false, conflicted: true }; } throw error; }}DynamoDB transactions in Global Tables are regionally scoped. A transaction in us-east-1 only provides atomicity for items in us-east-1. Cross-region atomic operations are not supported. If you need cross-region consistency, use application-level patterns like saga patterns or accept eventual consistency.
Consistency is a nuanced topic in distributed systems. Let's consolidate the key insights for DynamoDB:
What's Next
With consistency models understood, we explore DynamoDB Streams—the mechanism that enables real-time reactions to data changes. Streams power event-driven architectures, materialized views, cross-service synchronization, and auditing. They're the bridge between DynamoDB-as-database and DynamoDB-as-event-source.
You now understand DynamoDB's consistency models—when each is appropriate, their cost and performance implications, and patterns for handling eventual consistency gracefully. You can make informed decisions about consistency that balance correctness, performance, and cost for each operation in your application.