A timeout is not a failed action

X8B6’s server committed an inventory adjustment after the browser stopped waiting, turning a familiar error state into outcome unknown.

X8B6 displayed “Save failed” when a request exceeded ten seconds. The message offered a retry button.

In one simulator run, the server committed the inventory adjustment at eleven seconds. The browser had already declared failure. Pressing retry applied the adjustment again.

The request had timed out. The action had not failed.

That difference forced a new state into the product: the client could know it lacked a response without knowing whether the server had produced an effect.

The error belonged to the wait

A timeout described the browser's willingness to continue waiting. It did not travel backward through the network and cancel work already accepted by the server.

The action could be in several positions when the timer fired:

never left the browser
left the browser but never reached the service
reached the service and waited in a queue
committed at the service while the response was delayed
failed at the service while the error response was delayed

The client observed the same fact in every case: no response within its bound.

Calling all of them failed converted missing knowledge into a business result. The UI then encouraged a new action whose safety depended on which invisible case had occurred.

I renamed the transport outcome timed-out and the operation state outcome-unknown. The wording felt cautious because the system was uncertain. Caution was accurate.

The distinction also improved logs. “Request timed out after 10s; operation outcome unknown” named what the client knew. “Operation failed” claimed evidence it did not possess.

I considered aborting the browser request when the timer fired and treating that as proof. Stopping local interest in a response did not guarantee that the server stopped processing. The request might already be beyond any cancellable boundary.

Even a server cancellation endpoint would create another race: the operation could commit just before cancellation was accepted. The response to cancellation would need its own durable result.

X8B6 did not require reversible in-flight inventory changes. I kept the protocol simpler: once submitted, an operation could produce a result, and the client recovered that result by identity. The timeout stopped the active wait, not the operation's possible existence.

For actions that genuinely support cancellation, the cancel request should itself be modeled with identity and outcome. A closed browser connection is not cancellation semantics.

This prevented a familiar UI gesture—dismiss the spinner—from acquiring power the server protocol did not grant it.

Stable identity made uncertainty queryable

Every operation received an identifier before local storage and submission. The server enforced uniqueness and stored the result under that identity.

operation ID: op_71d...
payload: adjust lamp-14 by −2

If the first request committed, a later request with the same identity returned the existing receipt. If it had never arrived, the server could accept it once. If the same identity arrived with different payload, the server rejected the protocol violation.

Retry therefore meant “seek a result for this same intent,” not “perform a similar action again.”

The browser could also query receipt status without resubmitting the payload. A response of not-found was useful and still required care: the first request might be delayed and arrive after the query. The server needed an operation acceptance boundary or retention rule before not-found could safely authorize replacement with a new identity.

The simplest path remained resubmitting the same identity. Whether first or repeated, the server's idempotency rule produced at most one effect.

Identity turned outcome unknown from an emotional error into a recoverable protocol state.

The server committed result and receipt together

Idempotency failed if the server changed inventory and crashed before recording that the operation had been applied. On retry, it would find no receipt and repeat the effect.

The inventory projection update and operation receipt belonged in one database transaction:

check operation identity
if receipt exists, return it
validate operation against current projection
apply projection change
insert durable receipt
commit transaction

Either both the effect and receipt committed or neither did. The response happened afterward. Losing the response no longer lost the server's memory of the result.

The uniqueness constraint on operation identity handled concurrent duplicate requests. Two server workers could race; only one transaction created the effect and receipt. The other read or retried against the committed identity.

I did not rely on an in-memory set of recently seen IDs. A process restart during the uncertain window would erase it. The receipt belonged beside the durable effect.

This was the server half of offline safety. A durable browser outbox without durable server deduplication only made duplicates more persistent.

A receipt had to be stable

The first duplicate handler reran validation and returned the current item record. If the item had changed again, the retry response differed from the original result. The client could not tell what its operation actually produced.

The stored receipt captured the semantic outcome at commit:

interface AdjustmentReceipt {
  operationId: string;
  outcome: 'accepted';
  confirmedRevision: number;
  resultingQuantity: number;
  recordedAt: string;
}

A retry returned that receipt, even if current quantity had since moved. The response could optionally include a separate current projection, clearly labeled. It did not rewrite the operation's history.

Rejected and review-required outcomes also received stable result identities according to policy. Repeating an invalid payload did not produce a new verdict every time unless the protocol explicitly allowed reconsideration under a new operation.

Stable receipts made the outbox reconciliation deterministic. One operation identity led to one remembered result.

Another narrow failure appeared after the server response. X8B6 showed “Confirmed” and removed the row from view before its IndexedDB transaction stored the receipt and updated the local snapshot. Closing the tab in that interval made the operation pending again on startup.

Server confirmation and local knowledge were separate milestones. The client applied the response inside one local transaction: store the receipt, advance the confirmed projection, mark or remove the pending operation, and update indexes. Only after transaction completion did the UI move the action to confirmed.

If the local transaction failed, the operation remained in the outbox. Retrying was safe because the server would return the existing receipt. The interface could say “Confirmed by server; finishing on this device” or, after failure, “Server result found; local storage needs attention.”

This prevented the response callback from becoming another unearned finish line.

The protocol crossed two durable boundaries. Each required evidence before the product claimed completion.

Automatic retry was appropriate for idempotent operations with stable identity. It still needed bounds and backoff.

X8B6 retried transient transport failures, service unavailability, and outcome-unknown requests under the same operation ID. It did not automatically retry validation rejection, authentication failure without refreshed identity, or conflicts requiring review.

The sender introduced delay between attempts and stopped after a practical bound, leaving the operation safely pending. Network-recovery events could trigger a new round later.

I added jitter only where multiple devices could otherwise retry in lockstep. The personal project did not need a grand fleet algorithm, but the principle was useful: retry behavior is load behavior directed at a struggling dependency.

The interface did not count attempts down as if one more failure would delete the action. Reaching the automatic limit meant “stored; waiting for manual or later retry,” not “lost.”

Safety came from identity and receipt. Politeness came from retry timing. They solved different problems.

The button changed from “Try again”

“Try again” sounds like a new attempt at the task. After a timeout, the system needed to recover the same action.

The detail view used “Check and resend safely” with supporting text:

No result was received for “Remove 2 from shelf A.”
The action may already be confirmed. X8B6 will use the same operation identity, so checking again cannot apply it twice.

The ordinary outbox could perform that work automatically and show “Checking outcome.” The longer explanation appeared when someone opened the timed-out row.

If a person truly wanted another adjustment of −2, they created a new operation from the item view. It received a new identity and appeared separately. The UI did not let repeated clicks on a recovery button ambiguously mean repeated domain actions.

Language enforced the protocol boundary. Same identity recovered one intent; new identity declared new intent.

I briefly considered making inventory changes through a URL that could be retried by the browser or intermediary because it was convenient for the prototype. Request method semantics mattered.

State-changing operations used an appropriate non-GET method and explicit operation identity. Intermediaries, prefetchers, or crawlers should not trigger them by following a link. A receipt-status query could be read-only.

Using POST did not itself make retry unsafe, and using PUT did not automatically make a domain adjustment idempotent. The server behavior under repeated operation identity defined the effect.

The request method communicated broad intent to the web stack. The application protocol supplied deduplication and outcome retrieval.

This prevented “HTTP says this method is idempotent” from replacing the concrete database invariant that one operation identity produced one receipt and effect.

Timeouts differed by phase

The first ten-second timer covered connection establishment, server queueing, database work, and response transfer without distinction. One generic message made diagnosis difficult.

I instrumented phases visible to each layer. The browser knew when it sent and when it received. The server recorded acceptance, transaction start, commit, and response creation under the operation identity. A correlation ID connected transport attempts; the operation ID connected semantic intent.

attempt A received 14:20:01
operation op_71d accepted 14:20:01
transaction committed 14:20:12
receipt response lost
attempt B received 14:21:03
existing receipt returned 14:21:03

The client did not need all of those timestamps in its primary interface. Diagnostics could show whether the delay occurred before known server acceptance or after commit.

I kept the client timeout as a responsiveness boundary. Instrumentation changed what happened next, not the fact that a person should stop waiting indefinitely.

One simulator variation delayed an operation before the database transaction. The server process remained healthy and returned heartbeat checks quickly, while the operation queue grew.

A health endpoint could not prove action latency. The service exposed bounded queue status in diagnostics and rejected new work deliberately when it could not process it within a safe window. Rejection before acceptance was different from accepting and timing out invisibly.

The operation protocol recorded an acceptance milestone internally, though the browser could still lose that response. Stable identity remained necessary.

I resisted extending client timeouts until the queue “usually” finished. A longer spinner would hide capacity trouble and still fail at some later threshold. The product needed a durable pending state regardless of expected latency.

The timeout budget served human attention. The queue and service limits served operational health. Neither could replace idempotent recovery.

Receipt retention defined the retry window

Deduplication worked only while the server remembered operation identities. Deleting receipts after a day while an offline device could retry after a week would reintroduce duplicate effects.

X8B6 chose a retention window longer than the supported offline interval and rejected or reviewed operations outside that window rather than applying them as new. Confirmed receipts could compact their payload while preserving identity and essential result.

The client stored creation time for explanation but did not depend on its clock to decide server retention. The server used its own acceptance and policy data.

An archive or restored backup could contain very old pending work. Import placed it in review instead of automatically draining it. The screen named the age and warned that idempotency history might no longer exist.

This made retention part of the API contract. Storage cleanup could not delete deduplication evidence according to a generic database housekeeping rule.

The cost of a receipt was small. The consequence of forgetting it could be another physical inventory adjustment.

The project later experimented with forwarding some confirmed changes to a secondary export process. If X8B6 deduplicated the inventory effect but sent the export twice, the overall workflow was not idempotent.

Downstream effects carried the original operation identity or a derived stable effect identity. The export worker recorded its own receipt. Retrying the server transaction did not enqueue a second anonymous job.

Where a dependency could not accept an idempotency key, X8B6 stored an outbox record in the same transaction as the inventory effect and delivered it with deduplication at the adapter boundary. It did not call the dependency inside the database transaction and hope the process would not crash between steps.

This expanded the invariant from one endpoint to the full effect path. A safe client retry was only as safe as the least accountable side effect it could trigger.

The personal project kept that integration modest, but the exercise exposed why local deduplication alone can provide false confidence.

Testing targeted the missing response

The happy-path test waited for a response and asserted quantity. The decisive fixture committed the transaction, discarded the response, advanced the browser past its timeout, and retried the same identity.

Assertions covered both effect and evidence:

inventory adjustment applied once
one receipt stored for operation identity
retry returned the original receipt
client outbox retained work until local receipt commit
interface never called outcome unknown “failed”

Other cases dropped the request before server receipt, crashed the server before commit, raced two duplicate submissions, failed local receipt storage, and retried after process restart.

The final quantity alone could hide a duplicated adjustment later corrected by another bug. Counting effects and inspecting receipt identity made the invariant explicit.

I also tested a genuinely new second adjustment with a new identity. Deduplication must not collapse similar legitimate work simply because payloads matched.

The first interface showed a success toast directly from the request callback. After outcome recovery moved into the background sender, the tab that created an action might no longer be open when its receipt arrived.

X8B6 treated the locally committed receipt as the completion event. Any open view refreshed from that durable state. A brief notice could say “Remove 2 confirmed,” but dismissing it did not remove the receipt or history. Reopening the app still showed the outcome.

If a second tab received the response while the first tab displayed the item, cross-tab notification prompted the first to reload the operation state from IndexedDB. It did not trust a bare message saying success without reading the committed receipt.

This avoided duplicate celebrations when several transport attempts returned the same result. The operation identity had one transition into locally confirmed state, regardless of how many callbacks observed it.

Notifications became projections of durable outcome rather than the outcome itself. That small change matched the rest of the protocol: interface motion could disappear, tabs could close, and the evidence required to explain the action would remain.

Outcome unknown is a durable product state

The timeout bug began as an error-message correction and reached into client storage, server transactions, database constraints, receipt retention, downstream jobs, and button language.

The durable rule was simple:

Lack of a response is evidence about communication, not evidence that a domain action did not happen.

X8B6 recorded intent before sending, gave it stable identity, committed effect and receipt together, reused identity across retry, and removed local pending state only after the receipt was durably integrated. The interface could then explain uncertainty without asking the person to gamble on another click.

Some operations can be designed as naturally idempotent. Others need keys and stored results. A few require explicit review after their safe window expires. What none of them receive from a timeout is proof of failure.

The timer still stopped after ten seconds. The spinner still ended. The change was that the product stopped turning the end of its patience into a claim about reality.