The Offline Protocol SDK is designed for environments where connectivity is intermittent or absent for extended periods. The delivery system ensures that messages persist until delivered (or expired), and are sent immediately when any transport becomes available.
This guide explains how messages move through the system, how failures are handled, and how client applications should integrate with the delivery lifecycle.
send_message()
│
├─ Transport available ──► Send ──► ACK tracking ──► MessageDelivered
│ │
│ ├─ ACK timeout ──► Retry via queue (MessageRetrying)
│ │ │
│ │ ├─ Send succeeds ──► ACK tracking
│ │ └─ Max ACK retries ──► MessageFailed
│ │ (or re-park, if the recipient
│ │ is parked as unreachable)
│ │
│ └─ Relay: recipient unreachable ──► MessageUndeliverable
│ │ (message parked)
│ └─ Reachability edge ──► fresh ACK budget
│
└─ Transport unavailable ──► Outbox + Retry Queue ──► MessageDeferred
│
├─ Backoff timer fires ──► Retry send
├─ Peer discovered ──► Immediate flush
└─ Internet reconnects ──► Immediate flush
send_message()creates the message and attempts transport delivery via DORS- On success, the message is registered for ACK tracking
- When the recipient sends an ACK, a
MessageDeliveredevent fires - The message is removed from outbox and retry queue
Only the recipient settles a message. An acknowledgement naming a message sent to somebody else is ignored, whichever carrier it arrives on — every device that carried a frame knows its id, and settling on the id alone would let any of them drop the outbox entry and report a delivery that never happened. This is an attribution check, not authentication: acknowledgements are not signed, so it removes the unattributed answer rather than a determined forgery by someone who saw the frame — and who therefore also saw the recipient's name.
send_message()returnsOk(message_id)even when the transport send fails- The message is persisted to the outbox and enqueued in the retry queue
- A
MessageDeferredevent fires with the reason - The retry queue will attempt redelivery on its next cycle
The caller always receives the message ID. A deferred message is not a failure — it's queued for delivery.
The system has two distinct retry mechanisms that serve different purposes:
Transport Retries (retry queue):
- Fires when no transport can deliver the message right now
- The retry queue is a pure scheduling mechanism with no attempt limit
- Uses exponential backoff: 1s → 2s → 4s → 8s → ... → 300s (capped)
- Messages stay in the queue indefinitely as long as the process runs
- Processed in batches of 20 during each
process()tick
ACK Retries (ACK manager):
- Fires when a message was sent but no acknowledgment arrived
- Each re-schedule emits a non-terminal
MessageRetryingevent with the actualnext_retry_at - Limited to
max_retriesattempts (default: 10) - After exhausting retries, the message permanently fails with
MessageFailed— unless the recipient is currently parked as unreachable, in which case the message re-parks instead of settling (see Unreachable Recipients: Parking)
This separation is critical: a message that can't reach any transport should not be permanently failed. Only messages that were sent but never acknowledged count toward the retry limit. The terminal paths for a regular message are ACK-retry exhaustion (against a reachable peer) and outbox lifetime/capacity expiry — nothing else settles it as failed.
Retry 0: 1s (initial_delay_ms)
Retry 1: 2s (1000 × 2^1)
Retry 2: 4s (1000 × 2^2)
Retry 3: 8s (1000 × 2^3)
Retry 4: 16s (1000 × 2^4)
Retry 5: 32s (1000 × 2^5)
Retry 6: 64s (1000 × 2^6)
Retry 7: 128s (1000 × 2^7)
Retry 8: 256s (1000 × 2^8)
Retry 9+: 300s (capped at max_delay_ms, 5 min)
The delay is initial_delay_ms × backoff_multiplier^retry_count, computed in
f64 and clamped to max_delay_ms before the integer cast. That makes it total
over every configuration rather than needing an exponent ceiling: a non-finite
intermediate collapses to max_delay_ms, and the float-to-int cast saturates.
At the default maxRetries: 10 the ladder sums to about 13.5 minutes of backoff,
so a message that is going to fail permanently takes roughly 15 minutes to say
so once ACK timeouts are included. Lower maxDelayMs and maxRetries if you
need faster failure detection.
The retry queue is a priority-based min-heap. When multiple messages are ready:
- Earlier
retry_attimes are dequeued first - Among messages with the same retry time, higher priority wins
- Within the same priority, order is arbitrary
When a transport becomes available, pending messages are sent immediately — they don't wait for their backoff timer to expire.
When on_neighbor_discovered() is called (a BLE or Wi-Fi Direct peer appears):
- The outbox is scanned for messages addressed to that peer
- Matching messages are removed from the retry queue
- Each message is sent immediately (batch limit: 20)
- Failed sends are re-enqueued with their current attempt count
This means a message queued while a peer was unreachable will be delivered as soon as that peer reappears, without waiting for the next backoff cycle.
When internet_status_changed(true) is called after a disconnection:
- All entries are drained from the retry queue (ignoring timing)
- Outbox entries not in the retry queue are also collected (stranded messages)
- Each message is sent immediately (batch limit: 20)
- Failed sends are re-enqueued
Both flush methods cap at 20 messages per invocation to prevent blocking. If more messages are pending, the remainder stays in the retry queue and will be picked up on the next process() tick or the next flush.
The relay authenticates a JWT and knows the connection by the account name
behind it. The SDK, meanwhile, stamps every frame's sender with this device's
off1… address, and the receiver strict-matches the two — so a relay that
attributes a frame by account name hands the receiver a mismatch and the frame
is rejected. That rejection applies to the security-gated control prefixes
(__MLS_KEY_PKG__, __MLS_WELCOME__), which is what establishes a session; an
already-established session keeps working, because __MLS_ENC__ is data-plane
and ungated.
So, on every connection, immediately after the relay's Authenticated answer
and before any queued message is flushed, each bridge proves its address:
it signs the per-connection address_challenge from that frame — bound to a
dedicated domain and to the relay-resolved account name — with the same
identity key the address derives from, and sends a DeclareAddress. The relay
verifies the signature, re-derives the address from the presented key, and from
its next inbound frame onward attributes and routes that connection by
address. This also makes an off1… recipient resolvable: the relay's registry
is keyed by account name, and the declaration is what maps one to the other.
The ordering is load-bearing. A frame written before the declaration is attributed by account name permanently — the relay does not re-stamp retroactively — so the declaration precedes the reconnect flush described above, not merely the first send an app makes.
This is automatic and unconfigurable. It happens only against a relay
advertising the address_routing_v1 capability; against an older relay, or
before MLS holds an identity (an app running with encryption.enabled: false
never has one), the bridges send nothing and the connection behaves exactly as
it did before addresses existed. A refusal is non-fatal for the same reason:
the relay answers AddressError, the connection stays up in account-name
space, and the next reconnect declares again from scratch. Both the acceptance
and the refusal also reach the app verbatim as internet_server_message
events, and the bridge diagnostics name the reason it skipped.
Sending the declaration establishes what this device claims. The relay's
AddressDeclared echo is the only evidence of what it actually bound, so
the bridges hand both answers to the SDK, which checks them and reports through
security_warning:
| Answer | Check | Event |
|---|---|---|
AddressDeclared |
echoed address == local_address() |
none — this is the expected outcome |
AddressDeclared |
echoed address is anything else | RELAY_ADDRESS_BINDING_MISMATCH, peer_id = the address the relay bound |
AddressError |
— | RELAY_ADDRESS_DECLARATION_REFUSED, peer_id = this device, reason = a fixed local classification (the relay's own wording never travels onto the event) |
Both are reported and neither is acted on. A mismatch has no benign reading — the relay verifies that the declared address derives from the key that signed the proof, so an echo naming something else means it bound what it did not verify — but a relay that controls the socket already controls everything a local teardown would protect, so the mitigation is that the signal is loud. A refusal is genuinely non-fatal by contract on both sides.
What a refusal costs is worth stating plainly, because "the connection works"
and "the connection can start new conversations" are different claims: an
undeclared connection keeps delivering on established sessions, and cannot
establish new ones. Apps that surface relay health should treat
RELAY_ADDRESS_DECLARATION_REFUSED as degraded rather than down.
Both answers arrive through dedicated FFI entry points
(internet_address_declared, internet_address_declaration_refused) rather
than through message-plane injection — an acknowledgement that could be
synthesized from a notification payload would assert exactly what the check
exists to establish.
A device attaching to a Reticulum gateway does the same thing under a different
domain: it signs offline-gateway-addr-v1 over its own address and the
gateway's per-connection challenge, sends DeclareAddress, and checks the
address the gateway echoes back. The two answers report as
GATEWAY_ADDRESS_BINDING_MISMATCH and GATEWAY_ADDRESS_DECLARATION_REFUSED,
through their own entry points, for the same reasons.
The domains must stay distinct, and not only because every signing domain must: the two payloads have an identical layout, so under a shared domain a proof harvested by a hostile gateway would replay against the relay. The bytes are published and pinned.
What differs is the consequence of a refusal. A relay connection that fails to declare keeps delivering on established sessions, because it still has an account-name space to work in. A gateway has no such space: a session it has not bound may submit and be told a verdict, and is never registered as a recipient, so nothing addressed to that device arrives over it. The bridges therefore do not report the carrier available at all until the session is bound, and a refusal closes the connection. A Reticulum transport that connects and never becomes available is that, and the security warning is what says so.
The declaration itself is built in the core rather than in each bridge. The relay's has to be bridge-side because it commits the relay-resolved account name, which only the bridge knows; the gateway's commits only this device's own address, so it exists once, where a conformance vector pins the bytes instead of three hand-mirrored copies of a layout.
The outbox persists messages that require acknowledgment. It serves as the source of truth for "what messages are in flight."
| Parameter | Default | Description |
|---|---|---|
| Max entries | 500 | Regular messages |
| Max media entries | 100 | File chunk messages |
| Max lifetime | 7 days | outbox_max_lifetime_ms |
When the outbox is full, the oldest entry is evicted with a terminal message_failed event (reason "Outbox capacity exceeded"). When a message exceeds its lifetime and has no pending ACK, it is dropped and a terminal message_failed event (reason "Outbox lifetime exceeded") is emitted so the app can settle its UI state.
Important: When a message storage backend is configured, regular-message outbox entries are persisted and restored on the next start() with a refreshed delivery window. Media chunks are never persisted — an interrupted transfer surfaces as media_resend_required instead. See Client-Side Persistence for the app-side layer.
When the internet relay reports a recipient unreachable for an in-flight regular message (its recipient_unreachable delivery verdict), the message does not burn its ACK retry budget against a peer that is provably offline. Instead it is parked:
- A non-terminal
MessageUndeliverableevent fires (message_id,recipient,reason, andfile_idwhen the message is a media chunk) - The pending ACK and the retry-queue entry are dropped — the retry machinery goes quiet
- The outbox entry stays put, so the message remains "in flight" and subject only to the outbox lifetime
- The message is offered to any mesh neighbors, who may be able to reach a recipient the relay cannot — see An online device in a mixed neighborhood
The mesh offer repeats on each subsequent park, so a recipient who was out of range when the message was first parked is still reached later. Handing a copy to a neighbor is not proof of arrival, so the park and its probe stand regardless; what settles the message is the acknowledgement coming back. Since parking removed the pending ACK, that acknowledgement settles the parked entry on its own and fires the ordinary MessageDelivered.
A parked message is re-driven with a fresh ACK budget on every reachability edge:
- Transport reconnect (
internet_status_changed(true)) start()after a restart- The peer being discovered on a local transport
- The peer coming online per presence —
internet_presence_watchlist()includes recipients of pending/parked outbox messages, so the SDK owns presence-watching its own outbox; apps do not need their own watch queue for offline sends
A parked message never goes fully quiet — the SDK keeps a timed reachability probe running on every carrier. The probe interval escalates with each consecutive unreachable park (15s doubling up to a 600s cap) and resets on any reachability edge. The escalation counter is per recipient while the probes are per message: a burst of DMs to one offline peer climbs the shared ladder once per park, so later messages start at an already-escalated interval rather than each walking 15s → 600s on their own — the delivery re-drive below is the compensating edge. If a probe attempt exhausts its ACK budget while the recipient still holds a live park counter, the message re-parks at the escalated interval rather than settling.
The probe is deliberately carrier-agnostic. With a local mesh carrier (BLE / Wi-Fi Direct) up the peer may be a room away even though the relay reports it offline — and possibly already a discovered neighbor, so no future edge would fire for it. On an internet-only device the external edges above are the only other recovery, which leaves delivery hostage to the platform's presence-polling cadence (and to nothing at all for a consumer that never polls presence). Probing over the relay is self-limiting in every outcome: a still-offline peer returns a fresh verdict that escalates the interval, an accepted frame becomes an ordinary in-flight send on the ACK ladder, and a peer that is back means the probe was the delivery.
Relay traffic is bounded differently in each of those branches. When the relay answers with a verdict, the escalation is the bound — one frame per interval per parked message, settling at one per 600s. When the relay accepts the frame instead (its push fallback succeeded, so no verdict comes back), the probe rides the ordinary ACK ladder — up to max_retries sends on 1s → 300s backoff, roughly 800s cumulative on the defaults — before re-parking at the escalated interval. That ~800s is the fresh-entry bound: a resend that has to register a new ACK carries the retry-queue entry's accumulated retry count onto it (retry_count + 1) rather than restarting the ladder at 0, so a probe that already climbed the backoff resumes near its current position and re-parks after roughly one more timeout — the carried case only sends less. Plan relay capacity against the fresh-entry number, not the verdict one. Without this carry-forward a never-ACKing (offline) recipient's probe would pin the resend delay at its 1s floor and flood the relay once per second. Delivery of any one message to a parked peer immediately re-drives that peer's remaining parked messages rather than leaving them on their own escalated timers.
The outbox lifetime bounds the entry itself, with one caveat worth knowing: each probe refreshes the entry's last-send timestamp, so the sliding 7-day window stops binding and terminal message_failed moves out to the absolute cap (4× the lifetime, i.e. ~28 days).
Opt-out for deployments where returning peers always interact. The perpetual probe above is the default and stays the default. A deployment whose peers always send an inbound frame or advertise presence when they return (for example a machine-to-machine capability exchange) can set edgeDrivenUnreachableDm to true. The message is then timed-probed only a bounded number of times before it goes quiet and rests in the outbox, re-driven purely on the reachability edges above, and enabling the flag also caps the core resend rate to gone peers so a large unreachable backlog cannot trip a relay's rate limiter into a disconnect loop. This suspends the "never goes fully quiet" guarantee for unreachable DMs, so it must not be enabled for a consumer whose only recovery path is the timed probe (a silent returning peer that never polls presence would not be re-driven). It is off by default; the always-on behavior in this document is what every unconfigured integration sees.
Raising outbox_max_lifetime_ms interacts with control-frame freshness. A retransmitted control frame carries the signature it was minted with, timestamp included, and a receiver refuses one stamped more than 30 days ago (see Control messages). That window was chosen to clear the ~28-day absolute cap above, so the defaults are safe with room to spare. Configure a lifetime past a quarter of the window (7.5 days) and this device's own late retransmissions of __CONN_REQ__ and the other signed control frames start being refused as stale by the peer they finally reach. Ordinary messages are unaffected: __MLS_ENC__ is data-plane and carries no such signature. If a deployment genuinely needs a longer outbox, raise it for the reachability behaviour and expect control frames near the far end of the ladder to be dropped rather than delivered.
What parks and what doesn't:
| Message kind | Behavior on recipient_unreachable |
|---|---|
| Regular DM | Parked (as above), and offered to mesh neighbors |
| Media chunk | Not parked — normal retry exhaustion → transfer abort → media_resend_required — but offered to mesh neighbors, and it keeps its pending ACK, so an answer carried back settles it the ordinary way |
| Connection request | Not parked — settles immediately via connection_request_undeliverable |
Contract: MessageUndeliverable is the "recipient is offline" signal and may fire repeatedly for the same message while the peer stays offline. Terminal settlement happens only at delivery (MessageDelivered) or outbox-lifetime expiry (MessageFailed). Apps that previously keyed "recipient offline" UX off the ~15-minute terminal message_failed should key it off message_undeliverable instead.
A group send takes one of two paths, and which one is not a config flag alone — it is negotiated with the relay.
Relay broadcast with a delivery report (the default against a v3 relay).
When the group is relay-registered, group.relayBroadcastEnabled is on (the
default), and the connected relay advertised the group_delivery_v3 capability
in its Authenticated answer — the v2 settled-report contract plus an
address-aware group path, i.e. members named by the identifiers the roster was
registered under (off1… addresses), which is what makes the report
comparable against the MLS roster at all — the send is one frame the relay
fans out, and the send returns a single logical message id. The relay then
answers with a settled per-recipient delivery report (its GroupMessageSent) naming
which members took the message over a live socket and which took a device push
carrying the ciphertext. The SDK consumes that report: every MLS roster member
the report does not account for — the ones the relay names as missed and
the ones it does not know about at all (its registered roster can lag the MLS
roster) — is automatically re-sent a per-member copy that gets everything
described on this page: outbox entry, ACK, retry ladder, relay write-ack,
offline push, park-on-unreachable. The report is surfaced to the app as the
group_message_delivery_report event (observability only — the backstop
re-send has already happened by the time it fires).
The report itself is on a contract too: it can legitimately arrive tens of seconds after the send (the relay reports only when its whole fan-out settles, bounded by a 45 s wall-clock budget), so the SDK waits 60 s per attempt. A lost report re-sends the broadcast under the same logical id — receivers and the relay's push dedup key on it, so a duplicate fan-out costs bandwidth, never duplicate messages — at most twice, then the whole message downgrades to per-member fan-out, which needs no report to be correct. The same downgrade fires immediately if the Internet transport drops while a report is pending.
The one gap on this path: the report tracker is in memory only. Unlike an
outbox entry, a broadcast awaiting its report does not survive process death.
If the app is killed inside the report window (up to 60 s per attempt) the
backstop is lost with it — members the relay could not reach get no per-member
re-send, and nothing retries on restart, even though the send already reported
group_message_sent. The exposure is narrow: it needs a member the relay
missed and a process death in that window, and the relay's own push fan-out
still covers members who are merely offline with a valid push token. It is
also strictly smaller than the pre-report broadcast's, which had no backstop at
all. But it is a real difference from per-member fan-out, where the outbox
persists for 7 days — so an app that must not lose a group message to a
mid-flight kill should set relayBroadcastEnabled: false and pay the O(N)
uplink. Persisting the tracker is planned; it is not in this release.
Per-member fan-out (the fallback, and the opt-out). Against a relay that
did not advertise group_delivery_v3 — or with relayBroadcastEnabled: false
— a group send returns a Vec<MessageId> with one id per recipient: each
is a real SendMessage frame carrying the same MLS group ciphertext,
addressed to one member, with the full DM delivery ladder independently. The
contract-less fire-and-forget broadcast of the v1 relay (no presence check, no
push fallback, no persistence, "sent" answered before delivery was known) is
never taken regardless of configuration; missing a group message on that
path was undetectable, because MLS application messages do not advance the
group epoch. See Group Configuration.
Cost of per-member fan-out. Sends are O(N) frames. This does not risk tripping the relay's rate limiter at any group size: the platform bridge meters every relay-bound frame through a client-side token bucket (28 capacity, 9/s refill) deliberately tighter than the relay's own (30 burst, 10/s), and defers a frame it cannot fund to a later poll tick rather than dropping it — so the fan-out self-paces below the server's budget regardless of member count.
What large groups cost on that path is drain latency, and past roughly 118
members, self-inflicted duplicate sends. The core enqueues all N frames at once
and the bridge writes them at about 9/s after an initial burst of ~28, so frame
N reaches the wire at roughly (N - 28) / 9 seconds. The ACK timer starts when
a frame is enqueued locally, not when it reaches the wire, so beyond that size
the tail of a single fan-out exceeds the 10s ACK timeout and is retransmitted
before it was ever written. Those duplicates are absorbed by receiver and push
dedup via the stable outbox id, so they cost bandwidth rather than correctness.
Presence checks, typing indicators, and read receipts draw on the same bucket
and lower the threshold. This is the strongest reason large groups should leave
the broadcast enabled.
When an ACK is received for a message:
MessageDeliveredevent is emitted with latency and hop count- The message is removed from the retry queue (prevents ghost re-sends)
- The message is removed from the outbox
- Transport delivery metrics are updated
When a message exceeds max ACK retries:
MessageFailedevent is emitted- The message is removed from the retry queue
- The ACK tracking is removed
- The outbox entry is removed
const config = {
reliability: {
ack: {
defaultTimeoutMs: 10000, // 10s ACK timeout (default)
maxPendingAcks: 1000,
},
retry: {
maxRetries: 10, // ACK retry limit (default)
initialDelayMs: 1000, // First backoff delay
maxDelayMs: 300000, // Backoff ceiling (5 min)
backoffMultiplier: 2.0, // Exponential factor
outboxMaxLifetimeMs: 604800000, // 7 day outbox lifetime
pendingMessageMaxLifetimeMs: 604800000, // 7 days awaiting MLS session
},
},
};The outbound queue waiting for MLS session establishment is also hard-bounded, on entry count and on bytes — a count alone bounds neither memory nor durable storage, because message content is application-supplied and four very large messages sit there reporting 4/64 and looking fine:
| Bound | Value | Behaviour at capacity |
|---|---|---|
| Messages per peer | 64 | oldest settled with message_failed, then the new message is admitted |
| Messages globally | 4096 | globally oldest settled the same way |
| Bytes per peer | 2 MiB | oldest evicted until the new message fits |
| Bytes globally | 16 MiB | globally oldest evicted until it fits |
These are fixed, not configurable; only the lifetime above is. All four evict
oldest-first and emit the same terminal message_failed, and restore applies
them too, so a queue written by an older build cannot re-inflate memory on boot.
sendMessage rejects content over 256 KiB with an InvalidArgument error
(send_message, send_message_with, and forward_message in Rust). The cap is
enforced at the send boundary rather than at transmit time because a message
waiting on session establishment is queued — in memory and on disk — long before
it reaches the transport's own 1 MiB check, so a transmit-time cap would never
run for exactly the messages that accumulate. It sits well under that 1 MiB
ceiling to leave room for MLS ciphertext expansion, base64, and the JSON wire
envelope, so anything accepted here can actually be delivered. Large payloads
belong on sendMedia, which chunks them and is not subject to this limit.
Group sends are exempt from the pre-session queue bounds and from the content
cap — and only from those. A group send encrypts to group state that already
exists, so there is nothing to wait on and no durable pre-session queue behind
it, and neither sendGroupMessage nor sendGroupMessageWith runs the 256 KiB
check (they reject reserved internal prefixes, FileChunk, and oversized rich
extras instead). Everything else on this page applies: over the Internet
transport a group send is, by default, one ordinary SendMessage frame per
member, so each member's copy gets its own outbox entry, ACK, retry ladder, and
park-on-unreachable handling exactly like a direct message. See
Group sends for what that means per member.
Expiry work is scheduled from the earliest queued deadline rather than scanning
the queue on every process() tick.
High-reliability (field operations, disaster response):
reliability: {
ack: { defaultTimeoutMs: 15000 },
retry: {
maxRetries: 20,
outboxMaxLifetimeMs: 2592000000, // 30 days
},
}Low-latency (real-time chat with good connectivity):
reliability: {
ack: { defaultTimeoutMs: 5000 },
retry: {
maxRetries: 5,
maxDelayMs: 10000,
},
}Battery-constrained (IoT sensors):
reliability: {
retry: {
maxRetries: 3,
initialDelayMs: 5000,
maxDelayMs: 60000, // Longer backoff to save power
},
}The delivery system emits events at each stage of the message lifecycle:
| Event | When | Key Fields |
|---|---|---|
MessageSent |
Message accepted and sent via transport | message_id, recipient, priority |
MessageDeferred |
Message queued for retry (transport unavailable) | message_id, reason, retry_count, next_retry_at |
MessageRetrying |
Retry re-scheduled after a failed attempt (transport send error or ACK timeout); non-terminal | message_id, recipient, retry_count, next_retry_at |
MessageUndeliverable |
Transport reported the recipient unreachable; message parked, non-terminal | message_id, recipient, reason, file_id? |
MessageDelivered |
ACK received from recipient | message_id, latency_ms, hop_count, transport |
MessageFailed |
Terminal failure (max ACK retries, outbox lifetime or capacity exceeded) | message_id, reason, retry_count |
MediaResendRequired |
Interrupted outbound media transfer detected at start(); app must re-supply the bytes via send_media with the same file_id |
file_id, recipient, file_name, file_size |
MessageDecryptionFailed |
An inbound encrypted message failed to decrypt on this attempt; advisory, not terminal (see below) | message_id, sender, code, reason |
MessageDecryptionFailed is the one receiver-side event on this list, and it
does not settle anything. A message that fails to decrypt is not delivery-ACKed
(so the sender keeps retrying, and each resend of a DM is re-sealed against the
current session), which means the event fires once per failed attempt rather
than once per message — bounded by the sender's ACK retry budget. Treat it as
"this attempt did not decrypt"; the terminal signal is MessageFailed on the
sender, or FileReceiveFailed for media. See
Crypto-Failure Recovery.
Immediate delivery:
MessageSent → MessageDelivered
Deferred then delivered:
MessageDeferred → (transport becomes available) → MessageDelivered
Permanent failure:
MessageSent → (ACK timeout) → MessageRetrying → ... → MessageFailed
Offline recipient (internet path):
MessageSent → MessageUndeliverable (parked) → (peer comes online) → MessageSent → MessageDelivered
→ (7-day outbox lifetime expires) → MessageFailed
This version introduces the following breaking changes to the delivery system:
Previously, send_message() returned Err when no transport could deliver the message immediately. Now it returns Ok(message_id) and emits a MessageDeferred event instead. The message is queued for automatic retry.
Migration: If your code matches on Err from send_message() to detect "no transport available," switch to listening for MessageDeferred events instead. An Err from send_message() now only indicates a true failure (e.g., invalid recipient, protocol not started).
The MaxRetriesExceeded variant was removed from the reliability crate's error type. The retry queue no longer enforces a retry limit — only ACK timeouts count toward permanent failure.
Migration: Remove any match arms for Error::MaxRetriesExceeded. If you need to detect permanent failure, listen for the MessageFailed event.
| Parameter | Old Default | New Default |
|---|---|---|
| ACK timeout | 5,000 ms | 10,000 ms |
| Max ACK retries | 3 | 10 |
Messages take considerably longer to permanently fail. With the current defaults
(maxRetries: 10, maxDelayMs: 300000) the backoff ladder alone sums to about
13.5 minutes, versus ~15 seconds under the old defaults. Configure lower values
if you need faster failure detection:
reliability: {
ack: { defaultTimeoutMs: 5000 },
retry: { maxRetries: 3 },
}When a message storage backend is configured, the SDK persists regular-message outbox entries and restores them on the next start() with a refreshed delivery window (media chunks are never persisted — see media_resend_required). The refresh is bounded: an entry whose total age exceeds 4× the outbox lifetime (28 days at the default) is dropped at restore with a terminal message_failed instead of re-granted a window. Client applications should still maintain their own persistence layer for message history and UI state.
┌─────────────────────────────────────┐
│ Client App │
│ │
│ ┌───────────┐ ┌──────────────┐ │
│ │ Local DB │ │ Message UI │ │
│ │ (SQLite) │ │ │ │
│ └─────┬─────┘ └──────────────┘ │
│ │ │
│ Store message Read events │
│ with status and update │
│ │ status │
│ ▼ │
│ ┌──────────────────────────────┐ │
│ │ Offline Protocol SDK │ │
│ │ (in-memory retry + outbox) │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
- On send: Store the message in local DB with
status: pending
// Send the message
const messageId = protocol.sendMessage(recipient, content, priority);
// Persist locally
db.insert({
id: messageId,
recipient,
content,
priority,
status: 'pending',
createdAt: Date.now(),
});- On delivery: Update status when ACK arrives
protocol.onEvent((event) => {
if (event.type === 'message_delivered') {
db.update(event.message_id, { status: 'delivered' });
}
if (event.type === 'message_failed') {
db.update(event.message_id, { status: 'failed' });
}
});- On app restart: Re-send pending messages
const pending = db.query({ status: 'pending' });
for (const msg of pending) {
// Optional: skip messages older than your retention policy
if (Date.now() - msg.createdAt > 7 * 24 * 3600 * 1000) {
db.update(msg.id, { status: 'expired' });
continue;
}
protocol.sendMessage(msg.recipient, msg.content, msg.priority);
}- Duplicate delivery: If the original send succeeded but the app was killed before receiving the ACK, re-sending creates a duplicate. The receiver's deduplicator catches duplicates by message ID, but re-sends generate a new message ID. Consider content-level dedup in the UI if this matters for your use case.
- Message ordering: Re-sent messages get new timestamps and Lamport clocks. If strict ordering matters, the client should track sequence numbers.
- Retention policy: Decide how long to keep unsent messages. A disaster-response app might keep them for days; a chat app might expire after hours.
- Storage limits: Cap the local pending queue to prevent unbounded growth during extended offline periods.