Offline send, receive, and reconciliation
How the SDK handles out-of-order and skipped messages, what the subscription APIs cover, and which parts of offline behaviour your application still owns.
- Status
- pre-1.0
- Applies to
- 0.1.0
- Platforms
- Expo · Browser · Node
- Prereqs
- A working client from Start → Quickstart
- Reading time
- 10 min
Encrypted messaging is offline-first whether you planned for it or not. A ratchet is local state that advances with each encryption and decryption. After a week offline, a device does not only have a stale cache. It also holds a different position in a shared cryptographic sequence.
This page explains SDK behavior and application responsibilities.
The SDK does not own an outbox
There is no send queue in @open-e2ee/signal-protocol-sdk. send(recipientUserId, content)
and encryptMessage(remoteAddress, plaintext) attempt the operation now and throw
if they cannot complete it. If the user is on a train, your application must
remember the composed message.
The same responsibility boundary applies here:
"The client owns protocol coordination; the host application owns persistence, authentication, authorization, and product policy."
An outbox combines persistence and policy. Your application determines the retry period and what to show after attempt six. It also determines how to handle a message that remains pending for two days. The SDK cannot make these product decisions.
What it gives you instead is a retry primitive with sane bounds:
import { withRetry } from '@open-e2ee/signal-protocol-sdk/utils/retry';
await withRetry(() => signal.send(recipientUserId, body), {
operationName: 'send',
maxRetries: 2,
baseDelay: 1000,
maxDelay: 10000,
});withRetry is for a single operation crossing a flaky network. It is not a
durable queue and it does not survive a process restart. Your outbox rows need to
live in your own database, next to the decrypted message rows you already own.
Out-of-order and skipped messages
The Double Ratchet handles gaps by deriving and storing the message keys it
skipped over, so a message that arrives late still decrypts. The bounds are
configurable on ratchetConfig and default to:
| Option | Default | What it bounds |
|---|---|---|
maxSkip | 1000 | How far ahead of the last received counter a message may be |
maxMessageKeysStored | 1000 | How many skipped keys are retained at once |
keyExpirationMs | 604800000 (7 days) | How long a skipped key is kept before it is dropped |
Separately, MAX_UNACKNOWLEDGED_SESSION_AGE_MS is 30 days. The SDK treats an
unanswered prekey session as stale after that period.
These limits define the edge of "messages arrive eventually". A device can miss more than 1000 messages in one chain. It can also return after eight days for a key that expired after seven days. Both cases produce an error instead of a retry.
Catching up after reconnect
Two subscriptions cover live delivery.
startRelaySubscription() starts local decryption.
startRetryRequestSubscription() handles SESAME retries
path when a peer cannot decrypt your message. stopRelaySubscription() ends the
first subscription. stop() shuts down the complete client.
For a backlog, decrypt in batches rather than one call per message:
const results = await signal.processIncomingEnvelopes(pendingEnvelopes);
for (const result of results) {
if ('plaintext' in result) {
await db.insertMessage(result.envelope, result.plaintext);
} else {
await db.recordUndecryptable(result.envelope, result.error);
}
}processIncomingEnvelopes() returns a result for each envelope. It does not throw
after the first failure. One invalid envelope therefore does not block the next
ninety envelopes.
The method also sorts prekey messages before ordinary ciphertexts. A prekey message
establishes the session that later messages require. Use
decryptMessages(remoteAddress, ciphertexts) for a single peer's batch.
Read client.syncStatus before you show a connected state. Its value is
'synced' | 'failed' | 'none'. The 'none' value means that you did not configure a relay.
The 'failed' value means that the sync did not complete. Call the idempotent
syncToServer() method again to recover.
Opacity ledger
| Artifact | Stays on device | Sent to relay | In object store | Visible as metadata |
|---|---|---|---|---|
| Application outbox rows (app-owned) | yes | no | no | no |
| Envelope ciphertext | yes | yes | no | size, sender/recipient routing, arrival time |
Skipped message keys (maxMessageKeysStored) | yes | no | no | no |
Session record (version: 4) | yes | no | no | no |
client.syncStatus | yes | no | no | no |
markAsRead(messageId) state | yes | no | no | no |
sendReadReceipt(recipientUserId, timestamps) payload | yes | yes | no | that a receipt-shaped envelope moved |
Decrypted plaintext from processIncomingEnvelopes() | yes | no | no | no |
Reconciliation converges. It does not commit
This is the part that surprises teams coming from a server-authoritative stack. With a central database, two conflicting edits arrive at one place and one of them wins at a known instant. With end-to-end encryption there is no such place. The relay never needs message plaintext or device private keys, which also means it cannot arbitrate anything that depends on reading them. Every device reaches agreement on its own schedule from the operations it received.
This model provides no global order for business logic. It also provides no server-side "last write wins" behavior for encrypted fields. Two devices can show different states while both operate correctly. The users' offline periods, not your infrastructure, bound this window.
0.1.x; public APIs and persisted formats may change before 1.0.
Next
- Recovery, backup, and migration: what happens to history a device was never present for
- Error handling: the full error taxonomy behind
TOO_MANY_SKIPPED_MESSAGESand related errors - Local-first architecture: why convergence, not commit, is the model
- Offline, recovery, and identity change: an end-to-end worked example
Encrypted attachments and object storage
Two encryption layers for large payloads, the brokered object-store model, and why WebCrypto's missing streaming API shapes attachment memory behaviour.
Multi-device
Linking a second device, the difference between provisioning and transfer, and why a new device cannot read messages that predate it.