Your checkout request timed out. The order might still exist.

A customer clicks Place order. The shop inserts the order and commits its database transaction. Then the connection breaks before the response reaches the browser. The customer sees an error, clicks again, and receives a second order confirmation.

Both database transactions can be perfectly valid. The failure is that the system treated two attempts to perform one intended action as two independent actions.

This guide explains how to make that retry safe: identify the caller's intent, coordinate competing requests in durable storage, and return the result already produced. The architecture applies across languages; the downloadable Java/Spring lab demonstrates the local database boundary with real PostgreSQL transactions and real HTTP requests.

Conceptual cover: repeated checkout requests converge on one durable order while a return signal times out.

1. A timeout reports uncertainty

The client and the database observe different events. A client timeout says that a response did not arrive within the client's deadline. It does not say whether the server received the request, started the transaction, rolled it back, or committed it.

What happened on the server? What might the client observe? What should a retry do?
Request never arrived Timeout Perform the operation
Transaction started but rolled back Timeout or error Perform the operation if still valid
Transaction committed; response was lost Timeout Recover the existing result
First transaction is still running Timeout Coordinate with it; do not create a competing order

From the browser, these situations can look identical. Closing a socket or cancelling a future does not reliably undo a remote commit. A deadline limits how long the client waits; it is not a distributed rollback command.

The AWS Builders' Library discussion of safe retries motivates explicit request identity for resolving this uncertainty. That identity makes a later attempt recognizable as a retry of the same intent.

Sequence: the first request commits an order and result; its response is lost; a retry with the same key receives the saved order.

Idempotency means that repeating an operation has the same intended effect as performing it once. It does not require every incidental observation to be identical: access logs, latency and request IDs can differ. For this API, we make a stronger, useful promise inside a defined scope and retention window: a successful retry returns the saved status, response body and Location for the same order.

2. Why @Transactional alone does not solve it

A transaction groups database changes into one commit or rollback. Each call to the following Spring bean can start its own transaction:

@Transactional
public void place() {
 jdbc.update("INSERT INTO orders(id,tenant,sku,quantity) VALUES (?,'shop','BOOK',2)",UUID.randomUUID());
}

Call it twice through the Spring proxy and two rows can commit. The annotation has no record that both calls represent one customer action. Even serializable execution can serialize two legitimate inserts; isolation does not invent a business or request identity.

The lab tests this counterexample through an actual Spring transactional proxy. Its HTTP endpoint uses a different service with explicit coordination.

For transaction mechanics, proxy interception and rollback rules, see the existing Spring Transactions guide. Here the new question is how multiple transactions recognize one logical request. Spring's transaction documentation describes the annotation's transaction scope; deduplication requires additional application behavior.

3. Design the contract before choosing a table

One key for one intended action

The client generates an opaque key, typically a random UUID, before sending the first attempt. It keeps that key across retries, including a page refresh or app restart where the pending checkout can be restored. A new deliberate purchase gets a new key. Generating a fresh key inside every retry defeats the mechanism.

POST /orders
Idempotency-Key: 73450a44-6a56-49e6-8128-a857a0c0d315
Content-Type: application/json

{"sku":"BOOK","quantity":2}

The header is an application contract. This article does not claim that all HTTP servers interpret it automatically or that its exact response policy is universal.

Scope by authorized caller and operation

A key alone is insufficient. Our durable identity is:

(authorized tenant, operation version, idempotency key)

The operation is create-order-v1. Another tenant can independently use the same key. A larger system might scope by API client or authenticated principal as well, depending on whose intent the key identifies. The rule must be stable and documented.

Derive the scope from authenticated and authorized identity. A client-supplied tenant header is not proof of tenancy. The lab deliberately uses X-Demo-Tenant to stay small; it has no authentication and is suitable for local learning only. Reauthorize every retry before releasing a stored response. Random keys are not access tokens.

Compare meaning, not JSON formatting

The same key with a different quantity is usually a caller bug. Replaying the old order silently would hide that bug; executing the new command would break the contract. Our API returns 409 Conflict.

Parse and validate the command, then compare all fields that determine its meaning. Equivalent JSON with reordered properties should match. Real commands may include currency, delivery address, cart version, discounts or a quote identifier; leaving one out can misclassify a changed purchase as a retry.

For this restricted demo, SKU accepts only letters, digits, underscore and hyphen, and quantity is an integer from 1 to 100. The versioned string v1|BOOK|2 is therefore an unambiguous semantic encoding. We store the encoding itself, avoiding hash-collision and cryptographic implementation distractions. General payloads need a carefully defined canonical representation; if you hash it, choose an appropriate digest and protect stored sensitive data. Do not log full checkout payloads casually.

Save an outcome that can be replayed

The successful result includes 201, a JSON body containing the order ID, and a Location header. Store them inside the transaction creating the order. Do not rebuild the original response from a mutable order representation: the order may already have moved from accepted to shipped when the retry arrives.

Our replay returns 201 again, with the same body and Location. This is an explicit policy. Another API may return a reference to an asynchronous operation instead. Stored results need versioning, access control, size limits and retention just like other customer data.

4. The database must choose one owner

The tempting algorithm is: look up the key, find nothing, create the order, remember the result. Two simultaneous requests can both find nothing. A Java synchronized block coordinates only the participating threads in one JVM; another application instance can still race.

Our design makes PostgreSQL's unique constraint the arbiter:

PRIMARY KEY (tenant, operation, request_key)

In a single local transaction, the service attempts to insert the request identity. The winner creates the order and stores its result before committing. A contender uses ON CONFLICT DO NOTHING, waits for the competing transaction where necessary, and then reads the winner's result in a separate SQL statement.

Atomic boundary: claim key, create order and save response all commit together; payment and email remain outside.

Why a separate statement? At PostgreSQL READ COMMITTED, each statement gets a new snapshot. A conflicting insert may cause DO NOTHING even when that conflicting row was not visible in the insert statement's initial snapshot. The following SELECT can then see the committed winner. Combining insert and fallback lookup in a single clever statement can miss that distinction. See PostgreSQL's isolation documentation and INSERT reference.

This reasoning assumes that a cleanup worker does not concurrently remove records needed by active retries. The demo has no cleanup worker. A production retention design must coordinate deletion with active operations or define a safe eligibility and locking protocol; casually adding a DELETE loop changes the concurrency proof.

5. Read the Java/Spring implementation

The lab uses Java 21, Spring Boot 4.1.2, its managed Spring Framework 7.0.10 and PostgreSQL JDBC 42.7.14. Spring Boot's verified requirements include Java 21. Tests actually ran on Temurin 21.0.11+10 and PostgreSQL 17.6, using Maven 3.9.11. PostgreSQL 17.6 is a reproducible lab fixture, not a recommendation to deploy an old patch release; use a maintained patch version and rerun the suite for your deployment.

TransactionTemplate makes the boundary visible without depending on self-invocation or annotation placement. Spring's JDBC support uses the transaction-bound connection. This is the coordination core, copied from the tested service:

  return tx.execute(status -> {
   jdbc.execute("SET LOCAL lock_timeout = '1000ms'");
   int claimed = jdbc.update("""
       INSERT INTO idempotency(tenant, operation, request_key, fingerprint)
       VALUES (?, 'create-order-v1', ?, ?)
       ON CONFLICT (tenant, operation, request_key) DO NOTHING
       """, tenant, key, fingerprint);
   if (claimed == 0) {
    // A NEW statement sees the committed winner at READ COMMITTED.
    return jdbc.queryForObject("""
        SELECT fingerprint, status, response_body, location FROM idempotency
        WHERE tenant=? AND operation='create-order-v1' AND request_key=?
        """, (rs, row) -> {
      if (!fingerprint.equals(rs.getString(1))) throw new Conflict();
      if (rs.getObject(2) == null) throw new IllegalStateException("Incomplete committed record");
      return new Result(rs.getInt(2), rs.getString(3), rs.getString(4));
    }, tenant, key);
   }

After winning the claim, the service inserts the order, constructs a small stable response and updates the key row. These writes all use the same transaction manager and database. The result leaves TransactionTemplate only after successful commit; the controller then builds the HTTP response. Full schema, validation, result storage and exception mapping are in the download.

We use READ COMMITTED explicitly. Raising isolation without reviewing the implementation can introduce serialization failures requiring whole-transaction retries. The explicit conflict target also avoids accidentally swallowing an unrelated uniqueness violation.

The one-second lock_timeout bounds waiting for a database lock. It is neither a complete HTTP deadline nor a lease after which ownership can be stolen. PostgreSQL aborts the waiting statement on lock timeout; the transaction is rolled back before the controller maps SQLSTATE 55P03 to 503 with Retry-After: 1. No second order is created by that waiting attempt. Other database failures become a generic 500; the client still cannot infer that no commit occurred.

The lab stores only successful results. Validation happens before reservation; a changed payload for an existing key returns a conflict; pre-commit failures roll back the reservation. Failure caching is a separate policy decision. For comparison, Stripe's API v1 documentation describes retaining certain error results after execution begins. Do not copy one provider's policy without checking which API and version it applies to.

6. What happens under concurrency, crashes and expiry?

Two identical requests arrive together

One transaction wins the unique insert. The other waits. If the winner commits within the wait budget, the contender reads and replays the saved result. If the winner rolls back, the contender can obtain the claim and perform the command. If waiting exceeds the budget, the contender receives the retryable 503.

A database wait still occupies resources: a connection, a request and potentially a thread. Use bounded retry attempts, backoff and jitter, admission control and meaningful timeouts. Clients must reuse the original key and payload. See AWS on retries and backoff. The existing Concurrency vs Parallelism guide explains the execution model; here, the scarce resource is the database coordination boundary. Virtual threads do not increase the connection pool or remove lock contention.

Two different payloads use one key

If the first transaction commits, the second compares against its saved fingerprint and receives 409. If the first transaction rolls back, its reservation disappears; the other payload may become the first accepted command. If the second request hits the lock timeout before the winner finishes, it receives 503 first and may discover the conflict on a later retry. The contract is tied to the accepted durable operation, not to an invisible request that never committed.

The owner crashes before commit

Once PostgreSQL detects the disconnected session, its open transaction rolls back. Both the order and the claim disappear. A retry can perform the operation. Detection may take time; requests can wait or time out while the old session is still considered alive.

The tests inject a Java exception and separately terminate a real PostgreSQL backend connection. These demonstrate rollback and recovery of that local transaction. They do not simulate an operating-system crash, a database power failure or replica promotion.

The owner commits and then disappears

The saved result survives the application's memory. A retry on another application instance can read it from the same authoritative database. Our transport tests discard a real successful HTTP response and separately keep the client waiting until its Java HTTP timeout fires. A subsequent request returns the existing order. A fresh service object also recovers the stored result.

A database acknowledgment lost around commit is another uncertain outcome. Retry by identity against authoritative state; do not decide to create a new order because the previous connection reported an error. Durability also depends on database configuration, storage and failover policy. This single-node lab does not establish cross-region guarantees.

What about a durable PROCESSING state?

In this short-transaction design, the claim remains uncommitted until the result is complete. There is no normally visible, committed PROCESSING row to poll or recover. A contender coordinates through the database conflict.

Long workflows may instead commit an operation record first and return 202 Accepted with a status URL. That allows short requests and explicit progress, but creates new responsibilities: durable state transitions, worker recovery, leases or fencing, ownership rules and handling a worker that continues after its lease expires. An expired timestamp alone is insufficient proof that rerunning a payment is safe. This alternative is explained, not implemented by the lab.

Keys have a limited lifetime

Retention is part of the API promise. Suppose an API promises replay for 24 hours from acceptance. A retry after the record has been removed may create another order, even with the original key. Twenty-four hours is an illustrative policy here, not a property of Spring or this sample.

Choose the window from realistic retry behavior: offline mobile clients, queue redelivery, support tooling and delayed provider responses. Document when the clock starts, whether reads extend it, what happens after expiry, and whether old attempts must reconcile via an order or checkout reference. A stable server-issued checkout identity can reject obsolete submissions independently of key storage.

The sample retains records indefinitely until you reset the local lab. One test deliberately deletes a completed key row and proves that protection has ended while the original order still exists. It tests the consequence of removal, not a production expiry scheduler. Production cleanup must coordinate with in-flight attempts and satisfy privacy and operational requirements.

Idempotency is not business duplicate detection

Two different keys with identical contents represent two requests to this mechanism, and both can create orders. That may be correct: a customer might intentionally buy the same book twice.

If your rule is “one accepted order per checkout session,” enforce a tenant-scoped unique checkout reference in the order model. A purchase-order number, provider event ID or inventory reservation may require its own constraint. Suspiciously similar purchases can need a separate review policy. Request identity, business identity and fraud detection solve different problems and can coexist.

7. The local transaction ends before the payment provider

Putting an HTTP payment call inside a database transaction does not make the provider part of its rollback. A charge may succeed and the order transaction may fail. Sending email after commit has the opposite gap: the process may die before sending anything. An after-commit callback alone does not preserve work across that crash.

The transactional outbox pattern records an event or command in the same database transaction as the order and request result. A separate relay publishes it later. That closes the gap between committing the order and durably recording the intention to send a message.

Outbox continuation: order and outbox commit locally, then relay, consumer and external provider each have their own retry and deduplication boundary.

The relay can still publish twice: it might crash after delivery but before recording success. Consumers need durable event-ID deduplication or an inbox where appropriate. If the consumer updates its own database, deduplication and that update belong in its local transaction. That does not automatically protect an external email or payment call.

For payments, retain a stable payment-attempt identity, use the provider's supported idempotency mechanism with that identity, record provider references, and reconcile uncertain outcomes. Verify the provider's scope, retention, payload rules and failure behavior. Retrying with a new provider key after an uncertain charge can charge twice.

For email, choose what the infrastructure can actually promise. A provider with suitable idempotency support may help. Without it, a worker can crash after sending and before marking the event handled; repeating the send may produce a second message. Recording “sent” before calling the provider instead risks losing the message. A Message-ID header alone is not a universal delivery deduplication guarantee.

Workflows that span order acceptance, payment authorization and stock allocation may need explicit states, reconciliation and compensating actions, often described as a saga. A compensation is another operation with its own failures; refunding a charge does not erase the fact that the charge happened.

This example implements none of the relay, inbox, payment, email or saga machinery. Its 201 means that the order record was accepted locally. It does not mean paid, fulfilled or emailed. Avoid describing the resulting distributed workflow as universally “exactly once.” Name the effect, scope and conditions of each guarantee.

8. Architecture decisions and their costs

Decision Benefit Cost or limit
Store keys and results beside orders One local atomic boundary Writes, storage and traffic on the order database
Unique tenant + operation + key Durable arbitration across application instances Requires correct identity and operation versioning
Wait briefly for the winner Simple synchronous replay Hot keys consume connections; waiting must be bounded
Return a retryable response when locked Preserves ownership without indefinite waits Clients need a retry policy; conflict detection may be delayed
Store the original result Stable replay after order state changes Response retention, sensitive data and schema evolution
Use a semantic command encoding Equivalent JSON can match Every meaningful field and version must be included
Use a durable asynchronous operation instead Fits long workflows More states, recovery, polling and ownership complexity
Put an outbox beside the order Durable intent for downstream work Relay and consumer duplicates still need handling
Remove old keys Bounds storage and data retention Old retries lose protection; cleanup affects correctness

A separate cache can be useful, but then ask how a cache reservation and order commit stay consistent. TTL expiry, eviction and store outages can reopen duplicate creation. A cache hit is an optimization only if the durable correctness boundary remains clear. Moving the key to Redis does not, by itself, atomically couple Redis and PostgreSQL.

For operations teams, record replay counts, payload conflicts, lock timeouts, incomplete records, retention age and reconciliation backlog. Use correlation identifiers without treating request keys as secrets or exposing customer payloads. A surge in replay traffic may reflect a transport problem; a surge in conflicts may expose a client bug.

9. Run the executable lab

Download the documented Java/Spring/PostgreSQL idempotency lab.

Requirements: Java 21, Maven 3.9.x, Docker with Compose, and a free localhost port 55432. The database is disposable local teaching data. Do not point the tests at a shared or production database: they truncate the lab tables and terminate one of their own database sessions.

From the extracted examples directory:

docker compose up -d --wait
mvn verify
mvn spring-boot:run

In another terminal, submit and repeat this command with the same key:

curl -i http://localhost:8080/orders \
  -H 'Content-Type: application/json' \
  -H 'X-Demo-Tenant: shop' \
  -H 'Idempotency-Key: checkout-001' \
  -d '{"sku":"BOOK","quantity":2}'

The shell example uses POSIX quoting; the README includes a PowerShell version. Both successful responses have status 201, the same order ID and the same Location. Change quantity to 3 while keeping the key: expect 409. Use a new key: expect another order. With the application stopped, docker compose down removes the Compose containers; do not use that command against unrelated infrastructure.

What the tests actually prove

The completed suite contains 15 passing integration tests, zero failures, errors or skips. It exercises PostgreSQL 17.6, JDBC transactions, an embedded Tomcat HTTP server and Java's HTTP client. No H2 substitution and no database mock is used.

Evidence Assertion
Transactional counterexample Two separate proxied calls create two orders
Sequential HTTP replay Status, body and Location match, including reordered JSON fields
Twelve simultaneous HTTP requests One order, one key record, identical successful bodies
Held transaction and database observation A contender is visibly waiting on a PostgreSQL lock before release
Changed payload, including overlapping calls Conflict is reported and only one order exists
One-second lock timeout HTTP 503 carries Retry-After, then a later attempt replays
Exception and terminated database backend Claim and order roll back; a retry succeeds
Discarded response and actual client timeout The committed order remains; retry returns its saved result
Fresh service object Replay depends on database state rather than process memory
Tenant scope and distinct keys Different tenants or new intent remain independent
Invalid command and deleted key Validation does not reserve; removal ends protection

The ZIP includes source, schema, Compose fixture, README, test report, architecture diagrams, version evidence and license. The delivered ZIP was separately extracted and its Maven verification rerun. The project verification folder records the output and package hash.

What it does not establish

This is a local, single-node demonstration. It does not test authentication, authorization, rolling upgrades, multiple JVM processes, actual application process death, power-loss durability, database failover, production traffic, automatic expiry races, provider integration or distributed message delivery. Independent request threads and separate JDBC transactions test database arbitration; they are not a multi-instance deployment test.

The HTTP fault proxy receives the upstream response and deliberately withholds it. This reproduces an observable lost response and client deadline after commit; it is not a claim to reproduce every real network failure. The database-session termination test verifies server-side rollback after connection loss, rather than a complete disaster-recovery procedure.

The response body is intentionally tiny, the domain model has one SKU and quantity, and the key rules are deliberately restrictive. Production work must add the actual checkout invariants, bounded request sizes, secure identity, monitoring, appropriate retry and statement deadlines, migrations, retention coordination and downstream reconciliation.

A useful design exercise

Take one command in your system and write down five answers: whose intent does the key identify, which inputs must match, where is ownership decided, what result survives a crash, and when does protection expire? Then draw every boundary crossed after the local commit. That drawing usually reveals where the next duplicate can still occur.

Sources and further reading

Primary sources checked on 12 November 2026. Response codes, the encoding and the one-second wait budget are choices of this example.

  1. AWS Builders' Library: Making retries safe with idempotent APIs — request identity, caller scope, matching intent and delayed requests.
  2. AWS Builders' Library: Timeouts, retries, and backoff with jitter — deadlines, retry load and backoff.
  3. Spring Framework: Using @Transactional — proxy-based transaction behavior.
  4. Spring Framework: Programmatic transaction management — TransactionTemplate.
  5. Spring Boot: System requirements — verified Java/Boot compatibility.
  6. PostgreSQL 17: Transaction isolation and INSERT — snapshots and conflicting inserts.
  7. PostgreSQL 17: Client connection defaults — lock_timeout behavior.
  8. Chris Richardson: Transactional outbox — durable messaging intent and possible relay duplicates.
  9. Stripe: Idempotent requests — a provider-specific policy; API v1 and v2 differ.

© 2026 Homann Software. Original educational diagrams and example code; illustrative shop scenario.