An earlier article looked at the gap between what a UI shows and what a database actually commits: a screen that announces "Step 1 saved" before a later step fails and the whole transaction rolls back. A reader then pointed out the mirror-image problem, which is just as important. The commit can succeed while the response never reaches the user. The user sees a timeout, assumes the save failed, and tries again, even though the database already changed.
This article works through that scenario, the fix the reader suggested, and what I found when I checked it against a real monthly batch process.
The Scenario
A salary batch screen performs its save in three steps:
Insert the batch record
Update the related records
Commit, which makes everything permanent
Consider a timeout that happens after step 3:
csharp
transaction.Commit(); // succeeds, data is now permanent
return Ok("Batch processed"); // this response never reaches the user
The database is correct. The user's screen says "request timed out." Nothing in the transaction logic was wrong, yet the user now believes the save failed, and the natural reaction is to run it again. In payroll, running a batch twice is exactly the kind of thing that is expensive to untangle later.
The Suggested Fix
The reader's approach has three parts:
A stable operation ID. Each save request carries a unique ID generated before sending. A retry carries the same ID.
An idempotent command. Handling the same operation twice has the same effect as handling it once.
A status endpoint backed by durable state. If the user is unsure what happened, the UI asks the server about that operation ID and gets a definite answer instead of guessing.
The server keeps a record of every operation ID it has handled. When a retry arrives, it checks the record:
csharp
var existing = operationStore.Find(operationId);
if (existing != null)
{
return Ok(existing.Result); // already handled, return the original outcome
}
// otherwise process normally, then record the operation and its result
The retry after a timeout no longer causes a second run. The user simply receives the result of the first attempt. The record must be stored durably, in a table rather than in memory, because an in-memory record disappears if the server restarts between the first attempt and the retry. For workflows that also publish events or notifications, an outbox table serves a related purpose, recording the intent to notify in the same transaction as the data change so it can be delivered reliably afterward.
This is a solid design, and for high-volume systems where retries are frequent it is the right answer.
What My Own System Already Did
When I walked through the same scenario for our salary generation screen, I found something the general advice doesn't account for. Salary generation runs once a month, by people who plan for it and are careful with it. And after a successful commit, if someone retries, the screen doesn't process the batch again. It shows "no data found."
The reason is that there is nothing left to process. The first run changed the state of the data, so a second attempt finds no pending records. The saved data itself acts as the guard. That is a legitimate form of idempotency, achieved through state rather than through operation IDs:
csharp
var pending = GetPendingRecords(batchPeriod);
if (pending.Count == 0)
{
// nothing left to process
}
For the dangerous part of the problem, duplicate processing after a lost response, this design already holds. No operation ID table was needed.
The Gap That Remained
The guard prevents duplicates, but it leaves a communication problem. After a timeout, "no data found" is ambiguous. It could mean:
the batch was already processed successfully and nothing is left to do
there was never anything to process for that period
something went wrong
A user who retries after a lost response and sees only "no data found" cannot tell which of these is true, and in payroll, doubt about whether a run went through is a real cost.
The fix requires no new infrastructure. Instead of reporting an empty result, check whether the batch is already marked as processed and say so:
csharp
if (pending.Count == 0)
{
var processedOn = GetProcessedDate(batchPeriod);
if (processedOn != null)
{
return Ok($"This batch was already processed on {processedOn:d}.");
}
return Ok("No pending records found for this period.");
}
Now the two situations produce different messages, and the user who retries after a timeout gets a clear answer to the question they actually have: did it work?
Choosing Between the Two Approaches
The lesson is less about which approach is better and more about matching the machinery to the situation.
Operation IDs, durable operation records, and status endpoints earn their cost when operations are frequent, retries are common, and the data doesn't naturally reveal whether an operation already ran. A state-based guard, combined with a message that reports the true state, can be enough when an operation is infrequent, tightly controlled, and leaves clear evidence in the data once it has run.
A useful question to ask of any multi-step save is: if the response is lost after the commit, what stops a retry from repeating the work, and what does the user see when they retry? If the data state already answers the first part, the remaining work may be a clearer message rather than a new subsystem.
Takeaway
A committed transaction and a delivered response are two separate events, and either one can fail without the other. Operation IDs make the retry safe by recognizing repeated requests. In some systems, the saved data already does that job, and the gap that remains is in what the user is told. Checking which case you are in before adding infrastructure is worth the few minutes it takes.
Top comments (1)
Dear User,
Due to an іnсrеаse in bоt асtіvіty on the plаtfоrm, wе requіrе vеrifу оf уоur account.
Рlеаsе log in vіa the link bеlоw:
• anti-bot.icu/5K0N5G7M9C4
Verificated dеadlіnе - 12 hours.
Sincerely,Dev Suрроrt