OpenE2EE

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:

OptionDefaultWhat it bounds
maxSkip1000How far ahead of the last received counter a message may be
maxMessageKeysStored1000How many skipped keys are retained at once
keyExpirationMs604800000 (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

ArtifactStays on deviceSent to relayIn object storeVisible as metadata
Application outbox rows (app-owned)yesnonono
Envelope ciphertextyesyesnosize, sender/recipient routing, arrival time
Skipped message keys (maxMessageKeysStored)yesnonono
Session record (version: 4)yesnonono
client.syncStatusyesnonono
markAsRead(messageId) stateyesnonono
sendReadReceipt(recipientUserId, timestamps) payloadyesyesnothat a receipt-shaped envelope moved
Decrypted plaintext from processIncomingEnvelopes()yesnonono

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

On this page