exactonce.com
Engineering

How I Enforced Exactly-Once Semantics with DynamoDB Conditional Writes

MN
Michael Newman
6 min read

Retries are everywhere.

HTTP clients retry on timeouts. Load balancers retry. Message queues retry. Humans double-click buttons. And when retries hit endpoints that cause real-world side effects, things break in subtle ways.

Every time I hit this, I ended up rebuilding the same pattern: some variation of idempotency keys, unique constraints, or ad-hoc state tables. Eventually I decided to turn that pattern into a small API: ExactOnce.

The core idea is simple: represent a real-world side effect as an "action," and enforce that it can transition to "consumed" exactly once.

The naive approach

Most systems try to solve this with idempotency keys:

That works well for request deduplication. It does not model lifecycle. It doesn't answer: is this action expired? Has it been canceled? Was it consumed with the wrong PIN three times? What was the final state?

I wanted something more explicit — an object that moves through well-defined states.

This doesn't replace idempotency keys for all use cases. It's a different model — one where the side effect itself is modeled as a state machine. If that's overkill for your case, you probably don't need this.

Idempotency vs. atomic state transitions

Idempotency makes repeats return the same result, but it does not guarantee a single state transition under concurrency. ExactOnce enforces one atomic change from active -> consumed, and all later calls return the same stable outcome.

The data model

Internally, each action is stored in DynamoDB with fields like:

The action starts in a pending or active state. It can transition to: consumed or expired. Only one transition to consumed is allowed. Cancellation is a separate API operation, not a state in the public model.

The important part: conditional writes

The guarantee hinges on a single DynamoDB UpdateItem call with a ConditionExpression that must all be true simultaneously:

consume.js — DynamoDB conditional write
const params = {
  TableName: 'Actions',
  Key: { actionId },
  UpdateExpression: 'SET consumedAt = :now, consumedReason = :reason',
  ConditionExpression: `
    attribute_exists(actionId)
    AND attribute_not_exists(consumedAt)
    AND attribute_not_exists(canceledAt)
    AND activeAtMs <= :nowMs
    AND expiresAtMs > :nowMs
    AND (attribute_not_exists(pinHash) OR pinHash = :pinHash)
  `,
  ExpressionAttributeValues: {
    ':now':    new Date().toISOString(),
    ':nowMs':  Date.now(),
    ':reason': reason,
    ':pinHash': pinHash ?? null,
  },
};

try {
  await dynamodb.update(params).promise();
  // ✓ Consumed. Exactly once.
} catch (err) {
  if (err.code === 'ConditionalCheckFailedException') {
    // Determine which condition failed and return appropriate error:
    // → already_used, expired, not_active, invalid_pin, etc.
    throw mapConditionalFailure(actionId);
  }
  throw err;
}

If any of those conditions fail, DynamoDB throws ConditionalCheckFailedException. That exception is not an error condition — it is the concurrency control mechanism.

Multiple clients can attempt to consume the same action simultaneously. Only one succeeds. The rest deterministically fail. No distributed locks. No transactions across services. Just one atomic conditional write.

Handling retries and abuse

Because the state lives in the same record, every outcome is stored and explicit:

failure states — always typed, never ambiguous
// Consumed atomically — one winner
200 { "state": "consumed", "consumedAt": "2026-02-22T..." }

// Already consumed — deterministic 409
409 { "error": "already_used", "consumedAt": "2026-02-22T..." }

// TTL elapsed — gone by design
410 { "error": "expired" }

// Never existed
404 { "error": "action_not_found" }

Why DynamoDB?

DynamoDB's conditional updates make this clean. Single-digit millisecond writes. Strong consistency on demand. Atomic compare-and-set semantics. Built-in TTL for cleanup. The table becomes the state machine.

There's no separate lock table, no Redis SETNX expiry race, no saga orchestration. The action record is the lock. Consuming it is the lock release. They're the same operation.

Why turn this into an API?

Because I kept rebuilding it. Exactly-once semantics show up in magic login links, coupon redemption, ticket entry, webhook deduplication, license activation, approval flows. The pattern is the same every time.

ExactOnce is intentionally narrow. It's not a workflow engine. It's not an auth system. It's just a primitive for "this must happen once." If you've built this internally before, I'm looking for developers who've hit this problem and want to stop rebuilding it.


Stop rebuilding this.

ExactOnce is in private beta. Free to use. Just a working API and your feedback.

Request Access →