
Idempotent Writes in DynamoDB: Conditional Expressions, Transactions, and Lambda Powertools Compared
Stop Relying on EventBridge to Dedupe Your Events: A Practical Guide to Idempotent DynamoDB Writes
Three ways to make a DynamoDB write safe to retry — from a five-line conditional expression to a full request-caching utility — using a real double-registration bug as the running example.

Stop Relying on EventBridge to Dedupe Your Events — a practical guide to idempotent DynamoDB writes
If you've worked with serverless architectures for more than about a week, you've internalized "at-least-once delivery" as a phrase. API Gateway retries. SQS retries. EventBridge retries. Step Functions retries. Your own frontend retries, because the user tapped a button twice on bad wifi. Retries are not an edge case in distributed systems — they're the normal case, and the question is never "will this handler run more than once for the same logical request," but "what happens when it does."
This article is a practical, code-first walkthrough of three ways to answer that question for DynamoDB writes, using a real bug from a production-deployed serverless app (TEMS, a university event management platform) as the running example: a registration endpoint that could create two registration records for the same student and the same event, because nothing in the data model enforced that it couldn't.
We'll cover:
- The anti-pattern (what TEMS had, and why it looked fine)
- Technique 1 — deterministic claim keys + conditional writes
- Technique 2 —
TransactWriteItemsfor multi-item atomicity - Technique 3 — AWS Lambda Powertools' Idempotency utility
- How to actually test for the race condition
- A decision framework for picking between the three
- Common pitfalls
All code is TypeScript, using@aws-sdk/client-dynamodbv3 and@aws-sdk/lib-dynamodb.
The anti-pattern
Here's the shape of the original handler (simplified from
backend/services/registrations/handlers/register.ts ):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 1. Optional idempotency key — caller may or may not send one
async function handler(event: { eventId: string; userId: string; idempotencyKey?: string }) {
const { eventId, userId, idempotencyKey } = event;
// 2. If a key was provided, check for a cached result
if (idempotencyKey) {
const existing = await docClient.send(new GetCommand({
TableName: TABLE_NAME,
Key: { PK: `IDEMPOTENCY#${idempotencyKey}`, SK: 'METADATA' },
}));
if (existing.Item) return existing.Item; // cached
}
// 3. Separate "have I already done this?" check
const already = await isUserRegistered(userId, eventId); // a Query
if (already) throw new Error('Already registered');
// 4. Generate a brand-new ID and write
const registrationId = `reg_${nanoid(16)}`;
await docClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: { PK: `USER#${userId}`, SK: `REGISTRATION#${registrationId}`, /* ... */ },
}));
await docClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: { PK: `EVENT#${eventId}`, SK: `REGISTRATION#${registrationId}`, /* ... */ },
}));
// 5. THEN record the idempotency key, if one was given
if (idempotencyKey) {
await docClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: { PK: `IDEMPOTENCY#${idempotencyKey}`, SK: 'METADATA', registrationId, /* TTL */ },
}));
}
}
The read-then-write anti-pattern: two concurrent Lambda invocations both clear the “already registered?” check before either write lands, producing two valid registration records.
If you've reviewed a few hundred Lambda handlers, this one doesn't ring alarm bells on a skim. There's an idempotency check. There's a duplicate-registration check. There's even a generated ID that looks like it could be a stable identifier.
The problem is that every one of those checks is a read followed, at some later point, by a write — with no mechanism preventing a second concurrent execution from performing the exact same reads (and getting the exact same "all clear") before the first execution's writes land. Two requests arriving close together both pass step 3, both generate a different
registrationId in step 4, and both write successfully in step 4 — because nothing about steps 4 says "but only if no other item with this (eventId, userId) pair already exists." There's no such item to check, because the IDs are different.The fix isn't "add more checks." It's: make the write itself reject duplicates, so timing stops mattering.
Technique 1: Deterministic claim keys + conditional writes
The core idea: instead of generating a random ID and hoping nobody else generates a colliding one, derive a DynamoDB key deterministically from the thing that must be unique — here, the
(eventId, userId) pair — and let DynamoDB's ConditionExpression do the uniqueness enforcement.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { PutCommand, ConditionalCheckFailedException } from '@aws-sdk/lib-dynamodb';
async function claimRegistrationSlot(eventId: string, userId: string): Promise<boolean> {
try {
await docClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: {
PK: `EVENT#${eventId}`,
SK: `REGCLAIM#${userId}`,
createdAt: new Date().toISOString(),
},
// The whole trick is this one line:
ConditionExpression: 'attribute_not_exists(PK)',
}));
return true; // we got the claim — proceed with registration
} catch (err) {
if (err instanceof ConditionalCheckFailedException) {
return false; // someone else already claimed this slot
}
throw err;
}
}
Technique 1: deterministic claim key: EVENT#{eventId} / REGCLAIM#{userId} is the race surface. Exactly one PutCommand wins the attribute_not_exists check; all others get ConditionalCheckFailedException.
Now the handler becomes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function handler(event: { eventId: string; userId: string }) {
const { eventId, userId } = event;
const claimed = await claimRegistrationSlot(eventId, userId);
if (!claimed) {
// Either a genuine duplicate request, or the user is already
// registered. Look up and return the existing registration.
return getExistingRegistration(eventId, userId);
}
// From here on, we are the ONLY execution that will ever get here
// for this (eventId, userId) pair. Proceed with the real writes.
const registrationId = `reg_${nanoid(16)}`;
// ... create registration records, increment counters, publish event
}Two concurrent requests for the same
(eventId, userId) now race on a single conditional PutCommand — and DynamoDB guarantees exactly one of them wins. The loser doesn't retry the whole flow blindly; it looks up what the winner created and returns that. The result is idempotent regardless of how many times the request is retried, by whom, or how close together.This is cheap (one extra item, one extra write), fast (no transactions needed for this part), and — importantly — the claim item itself is useful data.
EVENT#{eventId} / REGCLAIM#{userId} is exactly the kind of item you'd want for an efficient "is this user registered for this event" check (a GetItem by exact key, versus the Query + FilterExpression the original isUserRegistered used).Technique 2: TransactWriteItems for multi-item atomicity
Technique 1 solves "can two registrations be created for the same pair" — but the TEMS handler writes to multiple items per registration: the
USER#.../REGISTRATION#... record, the EVENT#.../REGISTRATION#... record (a denormalized copy for event-centric queries), the atomic counter increment, and optionally the idempotency record. If the Lambda crashes between any of these writes — and Lambda can be terminated mid-execution, that's just reality — you can end up with a claim recorded but no registration, or a registration but no counter increment.TransactWriteItems lets you bundle up to 100 write operations (Put / Update / Delete / ConditionCheck) into a single all-or-nothing operation: either every item succeeds, or none do.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { TransactWriteCommand } from '@aws-sdk/lib-dynamodb';
await docClient.send(new TransactWriteCommand({
TransactItems: [
{
// The claim — fails the whole transaction if it already exists
Put: {
TableName: TABLE_NAME,
Item: { PK: `EVENT#${eventId}`, SK: `REGCLAIM#${userId}`, registrationId },
ConditionExpression: 'attribute_not_exists(PK)',
},
},
{
Put: {
TableName: TABLE_NAME,
Item: { PK: `USER#${userId}`, SK: `REGISTRATION#${registrationId}`, /* ... */ },
},
},
{
Put: {
TableName: TABLE_NAME,
Item: { PK: `EVENT#${eventId}`, SK: `REGISTRATION#${registrationId}`, /* ... */ },
},
},
{
Update: {
TableName: TABLE_NAME,
Key: { PK: `EVENT#${eventId}`, SK: 'METADATA' },
UpdateExpression: 'ADD registeredCount :inc',
ExpressionAttributeValues: { ':inc': 1 },
},
},
{
Put: {
TableName: TABLE_NAME,
Item: { PK: `IDEMPOTENCY#${idempotencyKey}`, SK: 'METADATA', registrationId, ttl },
},
},
],
}));If the conditional check on the claim item fails, the entire transaction fails with a
TransactionCanceledException — none of the other four writes happen. No partial state, ever. Catch that exception, look up the existing registration (via the claim item, which still tells you the registrationId of whoever won), and return it.
Technique 2: TransactWriteItems: all five writes (claim, two registration records, counter increment, idempotency record) commit atomically. The claim's conditional check gates the entire transaction.
One important caveat:
TransactWriteItems is more expensive than individual writes — each item in the transaction consumes capacity as if written individually, plus there's overhead for the transaction coordination itself (roughly double the write capacity units of an equivalent non-transactional write). For a registration flow, that's a trivial cost relative to the correctness you're buying. For a high-throughput hot path writing thousands of times per second, it's worth benchmarking — but "I'm worried about the cost of a transaction" is rarely the right reason to ship a data-integrity bug.The
RegistrationCreated event publish to EventBridge happens after the transaction commits, not inside it — PutEvents isn't part of DynamoDB's transaction model. That's fine: if the transaction succeeds but the subsequent PutEvents call fails, you're back to "the event might not have been published," which is a separate (and separately solvable, e.g. via the transactional-outbox pattern using DynamoDB Streams) problem from "did I create two registrations."Technique 3: AWS Lambda Powertools Idempotency utility
Techniques 1 and 2 are "DIY" — you design the claim key, you write the condition, you handle the failure path. For many handlers, especially ones where the entire response should be cached and replayed (not just a uniqueness check), AWS Lambda Powertools for TypeScript provides a drop-in
makeIdempotent() wrapper with a DynamoDB-backed persistence layer.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { makeIdempotent } from '@aws-lambda-powertools/idempotency';
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';
import { IdempotencyConfig } from '@aws-lambda-powertools/idempotency';
const persistenceStore = new DynamoDBPersistenceLayer({
tableName: process.env.IDEMPOTENCY_TABLE_NAME!,
});
const config = new IdempotencyConfig({
eventKeyJmesPath: 'arguments.eventId & arguments.idempotencyKey', // derive key from payload
expiresAfterSeconds: 60 * 60, // how long a result stays cached
});
export const handler = makeIdempotent(
async (event: AppSyncResolverEvent<{ eventId: string; idempotencyKey: string }>, context) => {
// Your normal handler body. Powertools ensures that for a given
// derived idempotency key, this body runs at most once — concurrent
// or retried invocations with the same key either block until the
// first completes (then get its result) or get the cached result
// if it already completed.
return registerUser(event);
},
{ persistenceStore, config }
);
Technique 3: Powertools Idempotency: the utility manages the full lifecycle (INPROGRESS → COMPLETED → EXPIRED), coalesces in-flight duplicates, and caches the complete response — including generated fields like the QR code.
What you get beyond Techniques 1 and 2:
- Full response caching, not just a uniqueness flag — a retried request gets back the exact same response the original got, including generated fields like the QR code.
- In-flight coalescing — if request B arrives while request A (same key) is still executing, Powertools can make B wait for A's result rather than racing it.
- TTL and status management (
INPROGRESS,COMPLETED,EXPIRED) handled for you, including cleanup of stale "in progress" records if a Lambda times out mid-execution.
The trade-offs: it's a separate DynamoDB table (or at least a clearly-scoped item collection) with its own IAM permissions (
dynamodb:GetItem, PutItem, UpdateItem, DeleteItem on that table/collection), and — as currently shipped — the TypeScript utility's persistence layer is DynamoDB-backed, so if your idempotency store needs to live elsewhere, this isn't the tool. For TEMS, where everything already lives in one DynamoDB table, it's a natural fit, and it directly replaces the hand-rolled, optional, check-then-write IDEMPOTENCY# pattern from the original handler — this time with the key required, derived from (eventId, userId) server-side rather than trusted to an optional client-supplied string.Testing for the race condition
A unit test that calls the handler once and asserts it works will pass on both the buggy and fixed versions — the bug is entirely about concurrency, so the test has to create concurrency.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { handler } from '../handlers/register';
it('creates exactly one registration when called concurrently for the same user+event', async () => {
const event = (idempotencyKey?: string) => ({
arguments: { eventId: 'evt_123', idempotencyKey },
identity: { sub: 'user_456' },
});
// Fire 10 "simultaneous" requests for the same user + event
const results = await Promise.allSettled(
Array.from({ length: 10 }, () => handler(event(), mockContext))
);
const succeeded = results.filter((r) => r.status === 'fulfilled');
const registrationIds = new Set(
succeeded.map((r) => (r as PromiseFulfilledResult<any>).value.id)
);
// All successful calls should agree on a SINGLE registration ID
expect(registrationIds.size).toBe(1);
// And the DB should contain exactly one registration row for this pair
const items = await queryRegistrations('evt_123', 'user_456');
expect(items.length).toBe(1);
});
The concurrency test in action: against DynamoDB Local, the original handler produces 2+ distinct registrationId values; after Technique 1 or 2, all 10 concurrent calls converge on exactly one.
Against DynamoDB Local or a real (test/dev) table, this test fails reliably on the original code — you'll see
registrationIds.size come back as 2, sometimes more under load — and passes reliably once Technique 1 or 2 is in place, because the conditional check resolves the race deterministically rather than probabilistically. TEMS's existing test suite (backend/__tests__/registration-service/) already exercises the waitlist and cancellation handlers with this kind of setup; the idempotency fix needs the same treatment for the registration path — a concurrency test is the only kind of test that actually validates the fix.Decision framework

Decision framework: claim key + conditional write for simple uniqueness; TransactWriteItems when multiple items must succeed or fail together; Powertools when you need full response replay and in-flight coalescing.
In practice, these aren't mutually exclusive — TEMS's fix uses all three: a claim item as part of a transaction (Techniques 1+2) for the core uniqueness guarantee at the data layer, with Powertools Idempotency wrapping the handler for response-level replay and making the previously-optional
idempotencyKey a required, server-derived value.Common pitfalls

- TTL design on idempotency/claim records. If a claim record never expires, a permanently-cancelled-then-recreated event could be blocked from re-registering a user who legitimately should be allowed back in (e.g., they cancelled and want to re-register). Decide what "idempotency window" actually means for your domain — TEMS's original (unused) idempotency record had a 24-hour TTL, which is reasonable for "don't double-submit this form," but the claim item for "is this user registered" should track the registration's lifecycle, not a fixed TTL — delete or update it when the registration is cancelled.
- Conditional expressions on the wrong attribute.
attribute_not_exists(PK)only works ifPKis genuinely guaranteed to not exist for "no claim yet." If your claim item shares a PK with other item types under the same key (common in single-table designs), make sure the condition is checking the combination that's actually unique —attribute_not_exists(PK)on an item wherePKalone is shared across manySKvalues won't do what you want; the condition needs to be evaluated on the specific(PK, SK)theKeyparameter targets, which it is by default — just double check your key schema actually makes(PK, SK)the right uniqueness boundary. - Forgetting the loser's path. A conditional write failing is not an error condition to bubble up to the user as "registration failed" — it's the expected, successful outcome for a duplicate request. Make sure that path returns a normal success response (the existing registration), not a 4xx/5xx.
- Publishing events before the transaction commits.
PutEventsto EventBridge should happen after a successfulTransactWriteItems, never before and never inside (it can't be, technically, but it's worth saying explicitly) — otherwise a failed transaction can still result in a "RegistrationCreated" event for a registration that doesn't exist. - Assuming this fixes "exactly-once" everywhere. This fixes exactly-once creation of a registration record. Consumers of
RegistrationCreated(the notification service, the waitlist promoter) can still receive that event more than once — because EventBridge is at-least-once, as covered in the first article in this series — and need their own idempotency story for their side effects (e.g., "has a confirmation email already been sent for thisregistrationId?"). Idempotent writes at the source don't make downstream consumers idempotent for free.
Applying this to TEMS
The concrete change set for TEMS's
register.ts:- Make
idempotencyKeya required argument in the GraphQL schema (registerForEvent(eventId: ID!, idempotencyKey: String!)), generated client-side as a UUID per registration attempt (regenerated on each user-initiated tap, not per network retry — so retries of the same tap share a key, but a deliberate second attempt after an error gets a fresh one). - Replace the two unconditional
PutCommands and the standaloneatomicIncrementRegisteredcall with a singleTransactWriteCommandcontaining: theREGCLAIM#{userId}conditional put, both registration record puts, and the counter update. - Wrap the handler in Powertools'
makeIdempotent(), keyed on(eventId, idempotencyKey), so retried requests get byte-identical responses (same QR code, sameregistrationId) without re-running any of the business logic. - Add the concurrency test from above to the registration-service test suite, and run it against DynamoDB Local in CI.
None of this requires a new table, a new service, or a schema migration beyond adding one GraphQL argument's nullability. It's roughly a one-day change — which is, frankly, the most sobering part of the whole exercise.
This is the second article in a series on what a structured AI architecture audit found in a "production-ready" serverless app. The first article covers the EventBridge deduplication myth that made this bug easy to miss; the next looks at how the same app's FIFO waitlist — built entirely on DynamoDB, no queue service involved — handles its own version of this problem at smaller stakes.
Publishing to AWS Builder Center
This article is a near-perfect fit for Builder Center's tutorial format as-is. Suggested adjustments:
- Title: keep it close to the original — Builder Center readers searching for "DynamoDB idempotency" or "conditional writes" are the exact audience. Consider: "Idempotent Writes in DynamoDB: Conditional Expressions, Transactions, and Lambda Powertools Compared"
- Structure: Builder Center's format favors a clear "prerequisites" line up top (AWS SDK for JavaScript v3, an existing DynamoDB table, Node.js 18+/20.x) — add a 3-bullet prerequisites block before "The anti-pattern" section.
- Expand the cost discussion slightly — Builder Center audiences often arrive via cost-optimization searches, and a sentence quantifying "transactions cost ~2x WCU" with a link to the DynamoDB pricing page for transactional writes adds credibility and SEO surface area.
- Tags:
Amazon DynamoDB,AWS Lambda,AWS Lambda Powertools,Serverless,Tutorial,TypeScript,Best Practices - CTA: link directly to the Powertools Idempotency docs and the DynamoDB
TransactWriteItemsAPI reference; cross-link both companion articles (the audit story and the waitlist design piece) as "part of a series." - The decision-framework table renders well on Builder Center — keep it, possibly widen with a "DynamoDB Local testable?" row (yes for all three) since Builder Center readers frequently ask about local development workflows.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article