Sometimes, the most useful improvements to a project come from someone asking a question you had not fully considered.
While working on ShopEase, my Spring Boot e-commerce backend, I implemented idempotency protection to prevent duplicate checkout requests.
My initial approach returned HTTP 409 Conflict whenever an Idempotency-Key was reused.
Then Andrei Ovcharenko commented on my Dev.to article with an important observation:
What if the order commits successfully, but the client never receives the HTTP response?
That question exposed an edge case in my implementation, so I worked on fixing it.
What Was Wrong With My Original Approach?
Imagine a checkout request:
The customer submits an order.
The backend creates the order and commits the database transaction.
A network issue prevents the response from reaching the customer.
The customer retries with the same idempotency key.
The backend returns 409 Conflict.
The duplicate order is prevented, but the customer still cannot recover the result of the successful checkout.
The server knows the order succeeded. The client does not.
A retry should be able to recover the original result, not just discover that the key has already been used.
Why Does Multithreading Make This Harder?
Concurrent requests can arrive using the same idempotency key before the first request finishes.
If both threads check the key before either one claims it, they may both attempt to process the checkout.
A reliable implementation needs to distinguish between a completed request and one that is still processing.
Completed request with an identical payload: replay the stored result.
Same key with a different payload: reject the request.
Request already in progress: prevent a second execution from processing it independently.
This requires atomic state management, not just an if statement checking whether a key exists.
What Did I Change?
I updated IdempotencyService.java and ConcurrencyController.java in ShopEase.
- Completed-response replay
For a repeated key with the same payload, the backend can now return the previously completed result.
The original order ID is preserved, so a retry does not create a new order merely because the first HTTP response was lost.
- SHA-256 payload fingerprinting
I added SHA-256 fingerprinting to detect when the same key is reused with a different checkout payload.
A mismatched payload is rejected with HTTP 422 Unprocessable Content.
This prevents a key originally associated with one checkout from silently being reused for another.
- An explicit replay header
Replayed responses include:
Idempotency-Replayed: true
This makes it possible for the client to distinguish a replayed result from a newly executed request.
- In-flight request protection
I added protection around the PROCESSING state to stop concurrent duplicate requests from independently starting the same checkout.
For a multi-instance deployment, the key claim must be coordinated through shared, atomic storage. A local in-memory lock alone is not enough when requests can reach different application instances.
How Should the Fix Be Tested?
The most important acceptance test is:
Submit a checkout request.
Allow the order transaction to commit.
Drop the HTTP response.
Retry using the original key and identical payload.
Confirm that the original order ID is returned.
Confirm that only one order was created and stock was decremented only once.
I would also test concurrent retries and reuse of the same key with a different payload.
The key lesson is that rejecting a duplicate request does not necessarily make the overall checkout experience reliable.
What Did I Learn?
This improvement reminded me that real backend development involves handling failure scenarios, not only successful API calls.
Idempotency needs to account for network failures, transaction commits, concurrent execution, and retries. The original request may have succeeded even when the client never received its response.
I'm glad I received this feedback because it helped me identify and improve a real edge case in ShopEase.
Tech stack: Java, Spring Boot, Spring Data JPA, MySQL, SQL, multithreading.
What strategies have you used to handle idempotency in concurrent checkout or payment systems?
Top comments (0)