Skip to content

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.

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.