Skip to main content
With offline support enabled, a message you send while the device has no connection is durably queued on the device instead of failing. It keeps its place across an app restart and is sent automatically once the connection is back and stable (with autoSendPendingOnReconnect on, the default). This page shows the offline-aware send overloads, how to read a message’s status, how to inspect and recover the send queue, and the queue limits.
Offline support is off by default and requires SDK 4.2.0 or newer. Queuing only happens once you enable it; with offline off, a send over no connection fails exactly as before.
The classic sendTextMessage(message:onSuccess:onError:) and its media, custom and interactive siblings are unchanged. They never queue and never report a pending stage; to get the offline behaviour, call the overloads below.

Send a Message

Call the overload that takes onMessagePending, onMessageSent and onError. Its lifecycle is normally onMessagePending first (the message is durably queued), then its outcome: onMessageSent (the server accepted it) or onError. Offline, onError is a queued-not-failed notice and onMessageSent still fires later on reconnect, so both can fire for one send (see below). If the message cannot be queued at all, for example because its media could not be copied (code ERROR_OFFLINE_MEDIA_COPY_FAILED, reason .permanent), only onError fires.
The same three closures are accepted by all four persistent send methods:
Each closure receives the concrete message type you sent (TextMessage, MediaMessage, and so on). An invalid send — empty text, or a custom message with no customData — is rejected with onError before anything is stored: it fires no onMessagePending and never enters the outbox. All callbacks are delivered on the main thread.

Queued, Not Failed

When the device is offline, the send still fires onMessagePending, then fires onError once with the code ERROR_INTERNET_UNAVAILABLE. This is a notification, not a failure: the message stays durably pending, and onMessageSent fires later, on reconnect, when it actually goes. This is the default behaviour, with autoSendPendingOnReconnect on. With it off, an offline send becomes failed (.transient) right away and onError carries the same code, so read the outcome from the OutboxListener or from getPendingMessages, and recover the message with retryMessage. So for a single send, onError and onMessageSent can both fire (offline, then reconnect), and onError does not always mean the message failed. Read the error code to tell the two apart:
The closures you pass to a send live in memory. If the app is killed while a message is queued, the send still completes on the next launch, but your closures are gone. Use the app-wide OutboxListener to hear about every outcome, including those.

Message status and failure reason

Every message exposes its offline state. localMessageId is the SDK-generated handle stamped at compose time. It stays stable across the whole lifecycle — use it to retry or discard the message.
Use messageStatus, not status. BaseMessage.status is a pre-existing String property and is not the offline status.
MessageFailureReason values:

Inspect the Queue

getPendingMessages(onSuccess:onError:) returns the entire unsent outbox — every pending, sending and failed message, ordered by local compose time. Sent messages are not included. Read each message’s messageStatus to group them.
If the cache is momentarily unavailable (busy, locked or full), it calls onError with ERROR_OFFLINE_MESSAGE_PERSIST_FAILED rather than answering an empty list — try again.
The synchronous getPendingMessages() -> [BaseMessage] still exists but is deprecated: it cannot tell an empty outbox from one that is momentarily unreadable.

Observe the queue app-wide

The per-call closures live only as long as the app does. To watch every queue transition — a live send, a reconnect auto-flush, a manual retry, or a resumed send after an app restart — register a global OutboxListener. Its failed callback carries the MessageFailureReason.
The SDK holds listeners weakly, so retain the object yourself. Registering a second listener under the same ID replaces the first, and removing an ID removes whatever is registered under it — use a distinct ID for each listener.

Retry and Discard

There is no automatic retry of a failed message — recovery is explicit and user-invoked. A failed message stays in the outbox until you retry or discard it. Use the message’s localMessageId.
  • retryMessage(_:onSuccess:onError:) re-attempts one message. A failed message is reset to pending and sent; a pending message is attempted now rather than waiting for a reconnect. onSuccess fires when the message is sent; onError receives a rejection or that attempt’s error.
  • retryAllFailedMessages() retries transient failures only; permanent ones are skipped. It has no callback — watch the outcome on your OutboxListener.
  • discardLocalMessage(_:onSuccess:onError:) deletes an unsent (pending or failed) message from the device, including its copied media. The original send closures get no final outcome, and no outbox event fires.
There is also a fire-and-forget retryMessage(_ message: BaseMessage) that takes the message itself and reports nothing. The callback forms of retryMessage and discardLocalMessage are Swift-only. retryMessage and discardLocalMessage reject, changing nothing, when:

Auto-send on Reconnect

By default, queued pending messages are sent automatically, in the order they were composed, when the realtime connection is re-established. This is controlled by OfflineConfig.autoSendPendingOnReconnect (default true); see Enable Offline Support and the reference. A queued message that keeps getting no answer is retried on each reconnect, up to 5 times, then becomes failed (.transient). With it off, nothing retries a queued message on reconnect — a send that gets no answer becomes failed (.transient), which you recover with retryMessage. Reconnect also runs a catch-up sync for received messages — see Stay in Sync.

Queue Limits

The outbox holds at most maxPendingMessages unsent messages (pending and sending); failed messages do not count. When the queue is full, OfflineConfig.pendingQueueOverflowPolicy decides what happens: A media send whose bytes would push the media cache over maxMediaCacheSize is refused with reason .byteOverflow. The SDK first evicts cached received media, least recently used first, and refuses the send only if that cannot make it fit. A refused send still reaches a final outcome. It fires onMessagePending, then onError with the code ERROR_QUEUE_FULL (or ERROR_OFFLINE_MEDIA_BYTE_OVERFLOW), and the OutboxListener receives onOutboxMessagePending followed by onOutboxMessageFailed(_:_:) carrying the exact reason. On the message you sent, messageStatus is .failed. An evicted message reports onError with ERROR_QUEUE_OVERFLOW on its own original send closures.
A refused or evicted message has no durable row — it does not appear in getPendingMessages, and calling retryMessage on its id returns ERROR_OFFLINE_MESSAGE_NOT_FOUND. Capture these from the failure callback and handle them at that moment. Prefer the default .rejectNewest, which never silently drops an already-queued message from another conversation.

Edits, Deletes, and Reactions Need a Connection

Offline queuing applies to sending new messages only. Changes to existing messages — edit, delete, add or remove a reaction — and other own actions are not queued offline. They are sent to the server whenever you trigger them, and they report an error if the request gets no answer. Their effect reaches the cache only once the server accepts them.

Durable Media Uploads

Send a MediaMessage built with files and the SDK copies the file bytes to the device before it reports pending. The message then survives an app restart and is sent on reconnect. Only files you pass as files are copied. Files you already uploaded need no copy. Uploading new files to hosted storage does need a connection (see Upload Files). An in-progress multi-file upload batch survives an app restart, but it never resumes on its own: the SDK keeps the batch on disk and you can re-attach to it. Fetch the existing request for a conversation with getExistingUploadFileRequest, and read each file’s progress through getItems(), which returns a list of UploadFileItemStatus (each with a state of type UploadFileState: .queued, .uploading, .uploaded, .failed, or .rejected, which cannot be retried). Retry an individual file with retryAttachment(fileId:); the SDK retries from its own copy, so you do not pass the file again.
getExistingUploadFileRequest returns nil when offline support is off, when nobody is logged in, or when no batch was kept for that chat. Files that were still uploading when the app was killed come back as .failed. For a restored item, file has a name but no bytes, and is nil once the file is uploaded — show a preview from localCopyPath instead. See Media Caching for downloading and caching received attachments.
For the full list of offline methods, listeners, enums, model fields, and error codes, see the Offline Configuration & API Reference.