Read-before-write anti-pattern¶
What is it?¶
The read-before-write anti-pattern occurs when developers read an item from DynamoDB, modify it in application code, and then write it back—when they could have used conditional writes or update expressions instead. This pattern doubles capacity consumption and introduces race conditions.
Why is it a problem?¶
Read-before-write creates multiple issues:
- Double Capacity Cost: Consumes RCU for read + WCU for write
- Race Conditions: Another process can modify the item between read and write
- Data Corruption: Lost updates when multiple processes write simultaneously
- Higher Latency: Two round trips instead of one
- Complexity: More code to maintain and test
- Scalability: Doesn't scale well under concurrent load
The race condition¶
Process A: Read item (count = 10)
Process B: Read item (count = 10)
Process A: Write item (count = 11)
Process B: Write item (count = 11) ← Lost update! Should be 12
Visual representation¶
Read-Before-Write vs Conditional Write
sequenceDiagram
participant App as Application
participant DB as DynamoDB
Note over App,DB: ❌ Read-Before-Write (2 operations)
App->>DB: 1. GetItem (1 RCU)
DB-->>App: { count: 10 }
Note over App: Modify in memory
App->>DB: 2. PutItem (1 WCU)
DB-->>App: Success
Note over App,DB: Race condition window!
Note over App,DB: ✅ Conditional Write (1 operation)
App->>DB: UpdateItem with condition (1 WCU)
DB-->>App: Success
Note over App,DB: Atomic operation, no race! Example of the problem¶
❌ anti-pattern: read-before-write¶
import { TableClient } from '@ddb-lib/client'
const table = new TableClient({
tableName: 'Products',
// ... config
})
// BAD: Read-before-write for incrementing counter
async function incrementViewCount(productId: string) {
// Step 1: Read the item (1 RCU)
const result = await table.get({
pk: `PRODUCT#${productId}`,
sk: 'METADATA'
})
const product = result.item
if (!product) {
throw new Error('Product not found')
}
// Step 2: Modify in memory
const newCount = (product.viewCount || 0) + 1
// Step 3: Write back (1 WCU)
await table.put({
pk: `PRODUCT#${productId}`,
sk: 'METADATA',
...product,
viewCount: newCount
})
// Problem: If two requests run simultaneously,
// one increment will be lost!
}
❌ common scenarios¶
// BAD: Read-before-write for conditional logic
async function updateIfActive(userId: string, updates: any) {
// Read to check status
const user = await table.get({
pk: `USER#${userId}`,
sk: 'PROFILE'
})
if (user.item?.status !== 'ACTIVE') {
throw new Error('User not active')
}
// Write with updates
await table.update({
pk: `USER#${userId}`,
sk: 'PROFILE',
updates
})
// Race condition: status could change between read and write!
}
// BAD: Read-before-write for existence check
async function createIfNotExists(id: string, data: any) {
// Read to check existence
const existing = await table.get({
pk: `ITEM#${id}`,
sk: 'DATA'
})
if (existing.item) {
throw new Error('Item already exists')
}
// Write new item
await table.put({
pk: `ITEM#${id}`,
sk: 'DATA',
...data
})
// Race condition: item could be created between read and write!
}
// BAD: Read-before-write for array operations
async function addToList(userId: string, item: string) {
// Read current list
const result = await table.get({
pk: `USER#${userId}`,
sk: 'FAVORITES'
})
const favorites = result.item?.items || []
// Modify in memory
favorites.push(item)
// Write back
await table.put({
pk: `USER#${userId}`,
sk: 'FAVORITES',
items: favorites
})
// Race condition: concurrent additions will be lost!
}
The solution¶
✅ solution 1: use update expressions¶
// GOOD: Atomic increment with update expression
async function incrementViewCount(productId: string) {
await table.update({
pk: `PRODUCT#${productId}`,
sk: 'METADATA',
updates: {
viewCount: { increment: 1 }
}
})
// Single operation (1 WCU)
// Atomic - no race conditions
// Works even if attribute doesn't exist
}
✅ solution 2: use conditional expressions¶
// GOOD: Conditional update without read
async function updateIfActive(userId: string, updates: any) {
try {
await table.update({
pk: `USER#${userId}`,
sk: 'PROFILE',
updates,
condition: {
status: { eq: 'ACTIVE' }
}
})
} catch (error) {
if (error.name === 'ConditionalCheckFailedException') {
throw new Error('User not active')
}
throw error
}
// Single operation
// Atomic check and update
// No race condition
}
✅ solution 3: use conditional put¶
// GOOD: Create only if not exists
async function createIfNotExists(id: string, data: any) {
try {
await table.put({
pk: `ITEM#${id}`,
sk: 'DATA',
...data,
condition: {
pk: { attributeNotExists: true }
}
})
} catch (error) {
if (error.name === 'ConditionalCheckFailedException') {
throw new Error('Item already exists')
}
throw error
}
// Single operation
// Atomic existence check and create
}
✅ solution 4: use list append¶
// GOOD: Atomic list append
async function addToList(userId: string, item: string) {
await table.update({
pk: `USER#${userId}`,
sk: 'FAVORITES',
updates: {
items: { append: [item] }
}
})
// Single operation
// Atomic append
// No lost updates
}
✅ solution 5: optimistic locking¶
// GOOD: Optimistic locking with version number
async function updateWithOptimisticLock(
userId: string,
updates: any,
expectedVersion: number
) {
try {
await table.update({
pk: `USER#${userId}`,
sk: 'PROFILE',
updates: {
...updates,
version: { increment: 1 }
},
condition: {
version: { eq: expectedVersion }
}
})
} catch (error) {
if (error.name === 'ConditionalCheckFailedException') {
throw new Error('Item was modified by another process')
}
throw error
}
// Detects concurrent modifications
// Allows application to retry with fresh data
}
// Usage with read-then-update (when necessary)
async function safeUpdate(userId: string) {
// Read with version
const result = await table.get({
pk: `USER#${userId}`,
sk: 'PROFILE'
})
const user = result.item
const currentVersion = user.version || 0
// Complex business logic that requires reading
const updates = performComplexCalculation(user)
// Update with version check
await updateWithOptimisticLock(userId, updates, currentVersion)
// If this fails, another process modified the item
// Application can retry with fresh data
}
✅ solution 6: transactions for multiple items¶
// GOOD: Atomic multi-item updates
async function transferBalance(fromUserId: string, toUserId: string, amount: number) {
await table.transactWrite([
{
update: {
pk: `USER#${fromUserId}`,
sk: 'ACCOUNT',
updates: {
balance: { increment: -amount }
},
condition: {
balance: { gte: amount } // Ensure sufficient funds
}
}
},
{
update: {
pk: `USER#${toUserId}`,
sk: 'ACCOUNT',
updates: {
balance: { increment: amount }
}
}
}
])
// Both updates succeed or both fail
// No read required
// Atomic across items
}
Performance impact¶
Capacity consumption¶
| Operation | RCU | WCU | Total Cost |
|---|---|---|---|
| Read-Before-Write | 1 | 1 | 2 units |
| Conditional Write | 0 | 1 | 1 unit |
| Savings | - | - | 50% |
For 1 million operations per month: - Read-before-write: 2 million capacity units - Conditional write: 1 million capacity units - Cost savings: $0.25/million = $250/month
Latency impact¶
| Operation | Round Trips | Typical Latency |
|---|---|---|
| Read-Before-Write | 2 | 20-40ms |
| Conditional Write | 1 | 10-20ms |
| Improvement | - | 50% faster |
Concurrency impact¶
Under concurrent load (100 simultaneous requests):
| Pattern | Success Rate | Lost Updates |
|---|---|---|
| Read-Before-Write | 60% | 40% |
| Conditional Write | 100% | 0% |
| Optimistic Locking | 95%* | 0% |
*With retry logic
Detection¶
The anti-pattern detector can identify read-before-write patterns:
import { StatsCollector, AntiPatternDetector } from '@ddb-lib/stats'
const stats = new StatsCollector()
const detector = new AntiPatternDetector(stats)
// After running operations
const issues = detector.detectReadBeforeWrite()
for (const issue of issues) {
console.log(issue.message)
// "Detected read-before-write pattern: GetItem followed by PutItem on same key"
// "Consider using conditional writes or update expressions"
}
Warning signs¶
You might have this anti-pattern if:
- You see GetItem followed by PutItem/UpdateItem in logs
- You have race condition bugs in production
- You see "lost update" issues under load
- Your capacity costs are higher than expected
- You have complex locking logic in application code
When read-before-write is necessary¶
Sometimes you genuinely need to read before writing:
Acceptable use cases¶
// ✅ Complex business logic requiring multiple attributes
async function calculateDiscount(userId: string) {
// Need to read multiple attributes for complex calculation
const user = await table.get({
pk: `USER#${userId}`,
sk: 'PROFILE'
})
// Complex calculation based on multiple fields
const discount = calculateComplexDiscount(
user.item.purchaseHistory,
user.item.membershipLevel,
user.item.referralCount
)
// Use optimistic locking for the update
await table.update({
pk: `USER#${userId}`,
sk: 'PROFILE',
updates: {
currentDiscount: discount,
version: { increment: 1 }
},
condition: {
version: { eq: user.item.version }
}
})
}
// ✅ Returning old and new values
async function incrementAndReturn(productId: string) {
// Read current value
const result = await table.get({
pk: `PRODUCT#${productId}`,
sk: 'METADATA'
})
const oldCount = result.item?.viewCount || 0
// Update with optimistic lock
await table.update({
pk: `PRODUCT#${productId}`,
sk: 'METADATA',
updates: {
viewCount: { increment: 1 },
version: { increment: 1 }
},
condition: {
version: { eq: result.item?.version || 0 }
}
})
return {
oldCount,
newCount: oldCount + 1
}
}
Key point: When you must read-before-write, use optimistic locking to detect concurrent modifications.
Related resources¶
Summary¶
The Problem: Reading an item before updating it doubles capacity consumption and introduces race conditions that can cause data corruption.
The Solution: Use update expressions, conditional expressions, and transactions to perform atomic operations without reading first.
The Impact: Conditional writes reduce capacity consumption by 50%, eliminate race conditions, and improve latency by 50%.
Remember: DynamoDB is designed for atomic operations. Use update expressions and conditional writes to leverage this power instead of reading, modifying, and writing back in application code.