DynamoDB optimistic locking sounds like database theory, but it solves a very practical problem: two requests read the same item, both work with the state they have read, and the second one silently overwrites the change of the first when it saves. Nobody sees an error message, and still the data is wrong. Tests rarely reveal this so-called lost update.
I care about this topic because Lambda makes such collisions more likely, not less. Every request gets its own execution environment, dozens of instances run in parallel, and a retry from SQS or from the client happily triggers the same change a second time. In this part of the series I build a small bike rental service that prevents lost updates with a version number, a conditional write and a retry that reads again. I also cover ETag and If-Match for edit forms.
In short
Optimistic locking stores a version number in every item and only writes if it still matches the state that was read – otherwise DynamoDB rejects the write with a ConditionalCheckFailedException. You only need the pattern for read, decide, write flows; simple changes are covered by a condition directly in the update. The biggest pitfall is a retry that does not read again and simply saves the old decision once more.
What you will learn in this article
- When a lost update happens and why Lambda makes it more likely
- Implementing a version number with a conditional PutItem in TypeScript
- Resolving conflicts with retry and jitter without saving stale decisions
- Using ETag and If-Match so forms do not overwrite anything
- Spotting when a simple condition or a transaction fits better
What is optimistic locking in DynamoDB?
Imagine a made-up bike rental service called “Radstation”. Bike b-17 is parked at the harbour, it is available and has done 29 rides since its last service. Customer c-1 and customer c-2 tap “Rent” in the app almost at the same time. Two Lambda instances read the bike, both see “available”, both set the status to “rented” and save. Without protection the last write wins: the record shows c-2 as the renter, but c-1 has received a confirmation as well. On top of that, the ride counter only went up by one, although two rentals were confirmed.
Optimistic locking assumes that such collisions are rare. That is why nothing is locked when reading. Instead, every request remembers the version number it has read and only writes under the condition that this number is still valid in the database. Whoever comes first increases the version, so the second write fails the condition, and DynamoDB reports a ConditionalCheckFailedException instead of silently overwriting.
🎬 Optimistic locking: two rental requests, one wins – this animated diagram only runs on my site: watch it on sascha-ms.de
Optimistic or pessimistic locking?
Its counterpart is called pessimistic locking: before reading, a request sets a lock and everybody else waits. DynamoDB has no built-in mechanism for this; you build it yourself with a lock item and an expiry time. For typical Lambda workloads this is rarely worth it, because a lock stays in place when a function runs into its timeout halfway through, and all other requests wait for nothing.
When do you need DynamoDB optimistic locking?
According to the AWS documentation, single write operations such as UpdateItem are already atomic. A lost update only happens when your code makes a decision between reading and writing. This leads to a simple test question: is there logic between GetItem and PutItem that depends on the state that was read?
When a condition in the update is enough
If the answer is no, a version number is overkill. You increase a counter atomically with ADD, and a simple rule such as “only rent when available” fits directly into a ConditionExpression:
// Variant without a version number: the rule lives directly in the condition await db.send( new UpdateCommand({ TableName: 'bikes', Key: { bikeId: 'b-17' }, UpdateExpression: 'SET #status = :rented, rentedBy = :customer ADD ridesSinceService :one', ConditionExpression: '#status = :available AND ridesSinceService < :limit', ExpressionAttributeNames: { '#status': 'status' }, ExpressionAttributeValues: { ':rented': 'RENTED', ':available': 'AVAILABLE', ':customer': 'c-1', ':one': 1, ':limit': 30, }, }), ); This is shorter and faster, because the read disappears. However, the approach reaches its limits as soon as the rule gets more complex: calculating prices from several attributes, checking lists of accessories, asking an external service or testing business rules on their own without hiding them in an expression string. In those cases, the three steps read, decide, write with a version number are the cleaner solution. The logic stays plain TypeScript, and the database only checks one thing: has anything changed since the read?
| Situation | Suitable tool |
|---|---|
| Increase a counter, set a field without precondition | UpdateItem with SET or ADD |
| Simple rule on one item | ConditionExpression directly in the update |
| Logic in code based on the item that was read | Optimistic locking with a version number |
| Several items must be consistent together | TransactWriteItems with conditions |
| Long editing, conflicts very expensive | Lock item with expiry time (pessimistic) |
Transactions and global tables
You can see what conditions in a transaction across several items look like in the series part about reservations with DynamoDB TTL. Global tables are a special case: between Regions the rule is “last writer wins”, and the AWS documentation explicitly points out that a version check across Regions does not work as expected. If you write the same item in several Regions, you need your own conflict rule, for example by letting each Region change only its own items.
How to write several items atomically with TransactWriteItems and evaluate canceled transactions properly is covered in my article DynamoDB transactions: TransactWriteItems with Lambda.
Prerequisites
- Node.js 22 or newer (tested with Node.js 24)
- an AWS account and a configured CLI profile
- basic knowledge of Lambda and DynamoDB – if not, read DynamoDB with Lambda and CDK first
- about 30 minutes
Step by step: DynamoDB optimistic locking with Lambda and CDK
The example consists of one Lambda function behind an HTTP API and one DynamoDB table. The function creates bikes, rents them out, takes them back and stores notes from the workshop. This time I describe the infrastructure with the AWS CDK, and the code is TypeScript throughout.
Step 1: Create the project
mkdir dynamodb-optimistic-locking && cd dynamodb-optimistic-locking npm init -y npm pkg set type=module npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/util-dynamodb npm install -D aws-cdk aws-cdk-lib constructs esbuild tsx typescript vitest aws-sdk-client-mock @types/aws-lambda @types/node@24 mkdir -p src/handlers lib bin test esbuild lets the CDK bundle the Lambda locally without starting Docker. After that you need two small configuration files:
// tsconfig.json { "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, "include": ["src", "lib", "bin", "test"] } // cdk.json { "app": "npx tsx bin/app.ts" } Step 2: Data model and business logic
Every bike has an ID, a station, a status, a ride counter and the version number version, which starts at 1. The business logic is deliberately a pure function without database access. It receives the state that was read and returns the new one – and it leaves version unchanged. As a result, the new state carries the information about which state the decision was based on, and the repository can use exactly that state as its condition.
// src/bike.ts export type BikeStatus = 'AVAILABLE' | 'RENTED' | 'MAINTENANCE'; export interface Bike { bikeId: string; station: string; status: BikeStatus; ridesSinceService: number; rentedBy?: string; rentedAt?: string; note?: string; version: number; } /** After this many rides a bike has to go to the workshop. */ export const RIDES_UNTIL_SERVICE = 30; export type RejectReason = 'NOT_AVAILABLE' | 'SERVICE_DUE' | 'NOT_RENTED' | 'WRONG_CUSTOMER'; export class RentalRejectedError extends Error { constructor(public readonly reason: RejectReason) { super(`Rental rejected: ${reason}`); this.name = 'RentalRejectedError'; } } /** Pure business logic: decides based on the state that was read. */ export function rentBike(bike: Bike, customerId: string, now: Date): Bike { if (bike.status !== 'AVAILABLE') throw new RentalRejectedError('NOT_AVAILABLE'); if (bike.ridesSinceService >= RIDES_UNTIL_SERVICE) throw new RentalRejectedError('SERVICE_DUE'); return { ...bike, status: 'RENTED', rentedBy: customerId, rentedAt: now.toISOString(), ridesSinceService: bike.ridesSinceService + 1, }; } export function returnBike(bike: Bike, customerId: string, station: string): Bike { if (bike.status !== 'RENTED') throw new RentalRejectedError('NOT_RENTED'); if (bike.rentedBy !== customerId) throw new RentalRejectedError('WRONG_CUSTOMER'); const { rentedBy: _rentedBy, rentedAt: _rentedAt, ...rest } = bike; return { ...rest, station, status: bike.ridesSinceService >= RIDES_UNTIL_SERVICE ? 'MAINTENANCE' : 'AVAILABLE', }; } Step 3: Conditional writes with a version number
The core is the save method. It writes the complete item with version + 1, but only if the table still holds the version that was read. I use PutItem instead of UpdateItem here, because the business logic returns the full new state anyway. Attributes that should disappear, such as rentedBy when a bike is returned, are removed automatically. With UpdateItem you would have to build the SET and REMOVE parts yourself; the condition would stay the same.
When creating a bike, attribute_not_exists(bikeId) makes sure that an existing bike is not overwritten. That matters more than it seems: an unprotected PutItem would recreate an existing bike with version 1 and thereby reset its version history.
I also set ReturnValuesOnConditionCheckFailure: 'ALL_OLD'. With it, DynamoDB returns the current state directly in the exception when a conflict occurs, and according to the API reference this costs no additional read capacity. There is one catch: the document client from @aws-sdk/lib-dynamodb does not convert the item in the error object. It arrives in the raw AttributeValue format, which is why the code calls unmarshall. I use the placeholder #version out of habit; version is not a reserved word, but status is.
// src/bike-repository.ts import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; import { unmarshall } from '@aws-sdk/util-dynamodb'; import type { Bike } from './bike.js'; export class VersionConflictError extends Error { constructor( public readonly bikeId: string, public readonly expectedVersion: number, public readonly current?: Bike, ) { super(`Bike ${bikeId} was changed in the meantime (expected version ${expectedVersion})`); this.name = 'VersionConflictError'; } } export class BikeAlreadyExistsError extends Error { constructor(public readonly bikeId: string) { super(`Bike ${bikeId} already exists`); this.name = 'BikeAlreadyExistsError'; } } export class BikeRepository { constructor( private readonly db: DynamoDBDocumentClient, private readonly tableName: string, ) {} async get(bikeId: string): Promise<Bike | undefined> { const result = await this.db.send( new GetCommand({ TableName: this.tableName, Key: { bikeId }, ConsistentRead: true }), ); return result.Item as Bike | undefined; } /** Creates a bike with version 1 and never overwrites an existing one. */ async create(bike: Omit<Bike, 'version'>): Promise<Bike> { const item: Bike = { ...bike, version: 1 }; try { await this.db.send( new PutCommand({ TableName: this.tableName, Item: item, ConditionExpression: 'attribute_not_exists(bikeId)', }), ); return item; } catch (error) { if (error instanceof ConditionalCheckFailedException) throw new BikeAlreadyExistsError(bike.bikeId); throw error; } } /** * Saves the new state only if nobody has changed the item since it was read. * `next.version` is the version that was read; the stored item gets version + 1. */ async save(next: Bike): Promise<Bike> { const expectedVersion = next.version; const item: Bike = { ...next, version: expectedVersion + 1 }; try { await this.db.send( new PutCommand({ TableName: this.tableName, Item: item, ConditionExpression: '#version = :expected', ExpressionAttributeNames: { '#version': 'version' }, ExpressionAttributeValues: { ':expected': expectedVersion }, ReturnValuesOnConditionCheckFailure: 'ALL_OLD', }), ); return item; } catch (error) { if (error instanceof ConditionalCheckFailedException) { // The document client does not convert the item in the error: it is still in AttributeValue format. const current = error.Item ? (unmarshall(error.Item) as Bike) : undefined; throw new VersionConflictError(next.bikeId, expectedVersion, current); } throw error; } } } Step 4: Resolve conflicts with retry and jitter
A conflict is really just the message “somebody was faster”. The right reaction depends on who makes the decision. For a machine decision like renting, the function simply reads again and decides again. The helper withOptimisticRetry does exactly that, with at most three attempts and a random wait in between. You can read why I use full jitter in detail in the article on exponential backoff with jitter.
The comment in the code is the crucial part: the function you pass in has to read again on every attempt. Only then does the conflict turn into a business answer. In the second attempt, customer c-2 does not get “conflict” but “bike not available”, because the business logic now runs on the fresh state.
// src/optimistic-retry.ts import { VersionConflictError } from './bike-repository.js'; export interface RetryOptions { maxAttempts?: number; baseDelayMs?: number; sleep?: (ms: number) => Promise<void>; } const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); /** * Runs read -> decide -> conditional write again if another request was faster. * Important: `operation` has to read the item again on every attempt. */ export async function withOptimisticRetry<T>( operation: (attempt: number) => Promise<T>, { maxAttempts = 3, baseDelayMs = 20, sleep = defaultSleep }: RetryOptions = {}, ): Promise<T> { for (let attempt = 1; ; attempt++) { try { return await operation(attempt); } catch (error) { if (!(error instanceof VersionConflictError) || attempt >= maxAttempts) throw error; // Full jitter: random wait between 0 and base * 2^(attempt-1) await sleep(Math.random() * baseDelayMs * 2 ** (attempt - 1)); } } } Step 5: The Lambda handler
The handler serves five routes. Renting and returning go through the retry helper. Editing a note with PATCH, on the other hand, is a human decision: there is no automatic retry, and the client has to send the version it has seen. Business rejections and final conflicts are answered with 409 Conflict, outdated forms with 412 Precondition Failed.
// src/handlers/bikes.ts import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'; import { RentalRejectedError, rentBike, returnBike, type Bike } from '../bike.js'; import { BikeAlreadyExistsError, BikeRepository, VersionConflictError } from '../bike-repository.js'; import { withOptimisticRetry } from '../optimistic-retry.js'; const db = DynamoDBDocumentClient.from(new DynamoDBClient({}), { marshallOptions: { removeUndefinedValues: true }, }); const repo = new BikeRepository(db, process.env.TABLE_NAME ?? 'bikes'); class NotFoundError extends Error {} const json = (statusCode: number, body: unknown, headers: Record<string, string> = {}): APIGatewayProxyResultV2 => ({ statusCode, headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body), }); const etag = (bike: Bike) => `"${bike.version}"`; function parseBody(event: APIGatewayProxyEventV2): Record<string, unknown> { try { return JSON.parse(event.body ?? '{}') as Record<string, unknown>; } catch { return {}; } } const requireString = (value: unknown): string | undefined => typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; async function loadOrFail(bikeId: string): Promise<Bike> { const bike = await repo.get(bikeId); if (!bike) throw new NotFoundError(); return bike; } export async function handler(event: Source:https://dev.to/sascha-ms/dynamodb-optimistic-locking-version-numbers-with-lambda-1foc