Three confirmations, not one
When a messaging system says a message was “delivered”, it is reporting one specific event, and which event depends on the system. Three are worth keeping apart:
- Delivered. The bytes reached the receiving device or server. A transport acknowledgment says this much and no more.
- Accepted. The receiving application checked the message and took responsibility for it: it parsed it, validated it and decided to act on it.
- Committed. The result was written somewhere durable, so a crash or restart will not lose it. In many systems the commit that matters most happens later, at a backend.
Each step can succeed while the next one fails. A message can reach a phone whose app crashes before storing it. An app can accept a task and then lose it because it confirmed before writing to disk. A device can record a handoff that the backend later rejects. Treating any one of these as the whole story is how work gets lost without anyone noticing.
What standards mean by delivered
Protocols that report delivery define it narrowly, and the definitions are worth reading.
Email delivery status notifications (DSNs, RFC 3461) say a message is “delivered” when it has been placed in the recipient’s mailbox. For mail collected later over IMAP or POP, delivery happens when the message is made available to that service, not when the recipient’s mail program fetches it. When a mail server passes a message to a system that cannot report back, the most it can send is a “relayed” notice.
Message disposition notifications (MDNs, RFC 8098) report what happened at the recipient’s mail program. Even the “displayed” disposition comes with a caveat in the standard: there is no guarantee that the content has been read or understood. The RFC also recommends asking the user before sending one, with the default set to not sending, which is why read receipts are so often missing.
MQTT 5.0 shows the same gap inside a broker. For a QoS 1 publish, the receiver answers with a PUBACK once it has accepted ownership of the message, and the specification says it does not need to complete onward delivery first. One PUBACK reason code, “No matching subscribers”, means the message was accepted but nobody was subscribed to it.
An acknowledgment is only as strong as the event its protocol defines.
Accepted: only the application can say it
In “End-to-End Arguments in System Design”, published in 1984, Saltzer, Reed and Clark use delivery acknowledgment as one of their oldest examples. The ARPANET sent a packet back to the sender whenever it delivered a message to a host, and the authors note that this was never found to be very helpful to applications. What an application wants to know is whether the target acted on the message, because, in their words, “all manner of disaster might have struck after message delivery”. The acknowledgment that is really wanted can only come from the target application: “I did it”, or “I didn’t.”
That is the acceptance step. The receiving application, not the network, decides whether a message is valid, authorised and new. It must also be able to say no: a rejected message was still delivered, and the sender needs to hear the rejection rather than assume success. Because a sender that hears nothing will retry, acceptance has to be idempotent, so a repeated message returns the stored result instead of doing the work twice.
Committed: durable, and durable where
A commit is a promise that the result survives a crash. Even that word needs care. PostgreSQL normally waits for a transaction’s write-ahead log records to reach permanent storage before it reports success. With asynchronous commit, it reports success as soon as the transaction is logically complete, and its documentation warns that the most recent transactions may then be lost if the server crashes. Its advice is not to use that mode when the client will take external actions that rely on the transaction being remembered.
The same rule applies to an app sending a confirmation: write the result durably, then acknowledge. A confirmation sent before the write promises something the device cannot yet keep.
In connected systems a commit is often the only step anyone tracks. Offline systems usually have two: a local commit on the device that accepted the work, and a backend commit later, when that device or another one reaches the server. The backend can still reject what the device accepted, for example because another device claimed the same item first.
Why offline apps need all three
Online, the three outcomes often arrive within a second of each other, so collapsing them rarely shows. Offline, they can be hours or days apart. A message can wait in an outbox, reach a nearby phone over Bluetooth, be accepted there, and then wait again until someone walks back into coverage before the backend sees it.
Offline Protocol’s local handoff guide is one documented example of keeping them apart. Its SDK reports message_delivered when a message reaches the other device, and the guide notes that the receiving app may not have stored the record yet. The receiving app commits the operation in one local transaction and only then sends an application receipt, which the sender treats as acceptance. Backend commit is a separate state, and a backend rejection stays visible instead of silently rewriting the local history.
Show users the state you actually have
The practical rule is to give each outcome its own state and its own indicator:
| State | What it proves |
|---|---|
| Saved | Stored on this device, waiting to send |
| Delivered | Reached the other device or server |
| Accepted | The receiving application took responsibility |
| Committed | Durably recorded where it needs to be |
| Expired or rejected | Someone needs to act |
Keep pending work visible while the device is offline, and give every failure a recovery action. A tick that means “delivered” will be read as “done”, so use the strongest wording only when you hold the strongest evidence.