DynamoDB Conditional Write in Node.js (AWS SDK v3)

The interesting part of a conditional write in AWS SDK v3 is not the ConditionExpression, which works the same everywhere and is covered in DynamoDB condition expressions. It is the failure path: v3 hands you the losing item on the thrown error, if you asked for it, and gives you nothing if you did not.

Code

import {DynamoDBClient, UpdateItemCommand} from '@aws-sdk/client-dynamodb';

const client = new DynamoDBClient({region: 'us-east-1'});

// Update the item only if nobody changed it since we read version 7.
const command = new UpdateItemCommand({
  TableName: 'Music',
  Key: {
    Artist: {S: 'Arturo Sandoval'},
    SongTitle: {S: 'Cubano Chant'}
  },
  UpdateExpression: 'SET #upd0 = :updValue0, #version = :newVersion',
  ConditionExpression: 'attribute_exists(#cond0) AND #version = :expectedVersion',
  ExpressionAttributeNames: {
    '#upd0': 'Genre',
    '#version': 'Version',
    '#cond0': 'Artist'
  },
  ExpressionAttributeValues: {
    ':updValue0': {S: 'Latin Jazz'},
    ':expectedVersion': {N: '7'},
    ':newVersion': {N: '8'}
  },
  ReturnValuesOnConditionCheckFailure: 'ALL_OLD'
});

try {
  await client.send(command);
  console.log('Updated to version 8');
} catch (err) {
  if (err.name === 'ConditionalCheckFailedException') {
    // With ReturnValuesOnConditionCheckFailure: 'ALL_OLD', the current item
    // rides back on the exception — no extra read to see what beat you.
    console.log('Lost the race — item is now:', err.Item);
  } else {
    throw err;
  }
}

Explanation

  • The failed check is a thrown error, not a status field. v3 rejects the promise, so the write path and the lost-race path are different branches. err.name === 'ConditionalCheckFailedException' is the discriminator; anything else has to be re-thrown, which is what the else in the fence is for. Swallow the whole catch and you have silently turned a throttle into a no-op.
  • ReturnValuesOnConditionCheckFailure is the only way to see who beat you. Without it, the error carries the message and nothing else, and you are back to a GetItem you did not need. The API reference sets its valid values as ALL_OLD | NONE and confirms it consumes no read capacity.
  • err.Item is a raw AttributeValue map, the same shape as the Key you sent, not plain JavaScript. Run it through unmarshall from @aws-sdk/util-dynamodb before you compare Version to a number, or you will be comparing against {N: '9'}.
  • The failed write is still billed. The Developer Guide is explicit that a condition evaluating to false still consumes write capacity, sized on the larger of the old and new item. A retry loop on a hot key is a real line on the bill, so cap the attempts.
  • Every name in the fence is aliased (#versionVersion, #cond0Artist) because the Expression Builder that generated it aliases unconditionally. That is heavier than necessary here and never wrong, which is the trade it makes.

Reading the loser's copy off the exception

Set the stored Version to 9 and run the fence, which expects 7. DynamoDB Local 3.3.0 throws, and the caught error carries:

err.name     ConditionalCheckFailedException
err.message  The conditional request failed
err.$metadata.httpStatusCode  400
err.Item     {
               Artist:     { S: 'Arturo Sandoval' },
               Year:       { N: '1994' },
               Version:    { N: '9' },
               SongTitle:  { S: 'Cubano Chant' },
               AlbumTitle: { S: 'Danzon' }
             }

That Version: 9 is the whole point. The retry can go straight back through the update with :expectedVersion set to 9, with no extra read and no window in which a third writer slips in between your GetItem and your retry.

Delete ReturnValuesOnConditionCheckFailure from the same command and re-run it. Same name, same message, same 400, and err.Item is undefined. Nothing warns you: the parameter is optional, its absence is not an error, and the code that reads err.Item just starts logging undefined in production.

Note also that a 400 here does not mean a malformed request. ValidationException and ConditionalCheckFailedException share the status code, and only one of them is a bug, which is why the branch is on err.name and never on the status.

To watch a condition succeed and fail against your own data, with the expression written for you rather than typed, download DynoTable.

References

Last verified 2026-07-28 against the official AWS documentation linked above.

Work with DynamoDB without the Console

A fast DynamoDB desktop client that runs the real SQL DynamoDB can’t — JOINs, GROUP BY, aggregates — with visual editing and an AI agent on your own Bedrock keys.

Free 30-day trial, no credit card — then the Free plan with no time limit.