Delivery and store-and-forward

What is idempotency, and why does offline delivery need it?

An operation is idempotent when running it several times has the same effect as running it once. Offline delivery needs it because messages are retried and can arrive more than once, so the receiver must be able to recognise a repeat and return the earlier result instead of doing the work again.

Learning objectives

After reading this article you will be able to:

  • Explain why retries over unreliable links make idempotent operations necessary
  • Describe how idempotency keys let a server return the original result for repeats
  • Apply habits that make offline operations safe to retry, such as recording results with effects

What idempotent means

An operation is idempotent if doing it twice leaves things exactly as doing it once would. Setting a job’s status to “checked” is idempotent: the second time changes nothing. Adding one to a counter is not: every repeat adds again.

HTTP gives the standard definition. RFC 9110 says a request method is idempotent “if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.” PUT, DELETE and the safe methods such as GET are idempotent. POST is not, which is why submitting a payment form twice can charge twice.

The definition is about the intended effect, not every side effect. A server may still write a log line for each request. What matters is that the thing the client asked for happens once.

Why retries make it necessary

Every network has the same blind spot. A sender sends a request and hears nothing back. Either the request was lost, or it arrived, the work was done, and the reply was lost. The sender cannot tell which.

The safe reaction to silence is to try again, which is how at-least-once delivery works. But a retry after a lost reply delivers the same request a second time. RFC 9110 states the rule plainly: a client should not automatically retry a non-idempotent request unless it knows the request is safe to repeat or can detect that the first attempt was never applied.

So retries and idempotency come as a pair. Without retries, messages get lost. Without idempotency, retries cause double work.

Why offline delivery makes it harder

Offline and mesh systems retry more, and over longer gaps, than a phone talking to a server on a good connection.

  • A device keeps unsent work in an outbox and resends it when a path returns, sometimes hours later.
  • An app can be closed or restarted between attempts, and must resend anything it cannot prove arrived.
  • In a mesh, a message can reach the same device by two routes, or be held and forwarded by relays using store-and-forward.
  • Acknowledgments travel back across the same unreliable links and can be lost too.

A receiver in this world should expect to see the same operation more than once, possibly days apart, and possibly wrapped in a different network message each time.

Idempotency keys

When an operation is not naturally idempotent, the usual fix is an idempotency key: a unique value the client generates once per intended operation and sends with every attempt.

Stripe’s API is a well documented example. The server saves the status code and body of the first request made with a key, whether it succeeded or failed, and returns that same result for later requests with the same key. It compares the parameters of a repeat with the original and returns an error if they differ, which catches a key accidentally reused for a different operation. Keys can be up to 255 characters, Stripe suggests random V4 UUIDs, and it warns against putting personal data in them. Keys can be removed once they are at least 24 hours old, and a key reused after that is treated as a new request.

An IETF working group draft proposed a standard Idempotency-Key HTTP header along the same lines. The draft has since expired without becoming an RFC, but it sets out the common rules: the key must be unique and never reused with a different payload; a repeat that arrives after the original completed gets the original result; a repeat that arrives while the original is still running gets a conflict error; and the server should publish how long it keeps keys.

Designing idempotent operations

A few habits make offline delivery safe to retry.

Prefer setting state to changing it. “Set the stock count to this value” can be repeated; “subtract the parts used” cannot. Google Cloud Storage’s retry guide sorts its operations into always, conditionally and never idempotent, and makes some writes conditionally idempotent with preconditions: the write applies only if the object is still at the version the client expected.

Create the key with the operation, not with the message. The identifier has to survive app restarts and every resend. If a retry creates a new network message, it should still carry the original operation ID.

Record the result with the effect. Store the operation ID and its outcome in the same transaction as the change itself. Otherwise a crash between the two leaves the receiver unsure whether the work happened.

Keep keys longer than a retry can arrive. A key that is pruned before a delayed copy turns up is no protection. For devices that can be offline for days, that window is much longer than an online API needs.

Offline Protocol’s local handoff guide follows this pattern: the sending app gives each operation an ID that survives retries and restarts, and the receiving app looks the ID up, commits the operation and its acceptance result together, and returns the stored result if the same operation arrives again.

Frequently asked questions

Is idempotency the same as deduplication?

No. Deduplication drops copies of the same message, usually for a limited time. Idempotency is a property of the operation itself, so a repeated request is harmless even when it arrives as a new message or after the deduplication record has gone.

Are GET requests always idempotent?

In HTTP, GET is defined as safe and therefore idempotent, and RFC 9110 also lists PUT and DELETE as idempotent. That describes the intended effect. A server can still log each request separately, and a badly designed endpoint can break the rule.

Sources

Build it with Offline Protocol

The local handoff guide shows an application-generated operation ID that survives retries and restarts, and a receiver that looks it up, commits the work and its result in one transaction, and returns the stored result when the same operation arrives again.

Read the local handoff guide