diff --git a/docs.json b/docs.json
index 96952f214..bbaf91df0 100644
--- a/docs.json
+++ b/docs.json
@@ -3163,6 +3163,19 @@
"sdk/javascript/reactions"
]
},
+ {
+ "group": "Offline Support",
+ "pages": [
+ "sdk/javascript/offline-overview",
+ "sdk/javascript/offline-enable",
+ "sdk/javascript/offline-cached-data",
+ "sdk/javascript/offline-send",
+ "sdk/javascript/offline-sync",
+ "sdk/javascript/offline-media-caching",
+ "sdk/javascript/offline-encryption",
+ "sdk/javascript/offline-reference"
+ ]
+ },
"sdk/javascript/calling-overview",
{
"group": "Users",
@@ -3488,6 +3501,19 @@
"sdk/react-native/reactions"
]
},
+ {
+ "group": "Offline Support",
+ "pages": [
+ "sdk/react-native/offline-overview",
+ "sdk/react-native/offline-enable",
+ "sdk/react-native/offline-cached-data",
+ "sdk/react-native/offline-send",
+ "sdk/react-native/offline-sync",
+ "sdk/react-native/offline-media-caching",
+ "sdk/react-native/offline-encryption",
+ "sdk/react-native/offline-reference"
+ ]
+ },
"sdk/react-native/calling-overview",
{
"group": "Users",
@@ -3789,6 +3815,19 @@
"sdk/ios/pin-save-message"
]
},
+ {
+ "group": "Offline Support",
+ "pages": [
+ "sdk/ios/offline-overview",
+ "sdk/ios/offline-enable",
+ "sdk/ios/offline-cached-data",
+ "sdk/ios/offline-send",
+ "sdk/ios/offline-sync",
+ "sdk/ios/offline-media-caching",
+ "sdk/ios/offline-encryption",
+ "sdk/ios/offline-reference"
+ ]
+ },
"sdk/ios/calling-overview",
{
"group": "Users",
diff --git a/sdk/ios/offline-cached-data.mdx b/sdk/ios/offline-cached-data.mdx
new file mode 100644
index 000000000..a9b92f6bf
--- /dev/null
+++ b/sdk/ios/offline-cached-data.mdx
@@ -0,0 +1,200 @@
+---
+title: "Show Cached Data"
+sidebarTitle: "Show Cached Data"
+description: "Render cached messages, conversations, and lists instantly and reconcile them against the server with the CometChat iOS SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```swift
+// Every read fires up to two phases: cached first (instant), then server.
+let request = MessagesRequest.MessageRequestBuilder()
+ .set(uid: "UID")
+ .set(limit: 30)
+ .build()
+
+request.fetchPrevious(onCachedMessages: { messages in
+ // Phase 1: local cache. Fires first, even when empty. Render immediately.
+}, onServerMessages: { messages in
+ // Phase 2: server. Replaces the same slice — reconcile, don't append. Never fires offline.
+}, onError: { error in
+ // Offline ends here, after the cached page, with ERROR_INTERNET_UNAVAILABLE.
+})
+
+// Single items (additive two-phase overloads)
+CometChat.getConversation(conversationWith: "UID", conversationType: .user, listener: conversationDetailsListener)
+CometChat.getMessageDetails(messageId, onCachedMessage: { _ in }, onServerMessage: { _ in }, onError: { _ in })
+```
+
+
+
+With offline support enabled, every list and lookup the SDK already offers answers from the on-device cache first and then from the server. Your UI paints instantly from the cache, then reconciles when the server replies. This page describes that contract, the reads it applies to, and how to merge the two phases.
+
+## The two-phase contract
+
+Each read fires **up to two** callbacks, always in this order:
+
+
+
+The SDK delivers a page from the local cache **first**. It fires **even when the page is empty** — an empty cached page is indistinguishable from a cache miss, by design. Render it right away.
+
+
+After a successful server response, the SDK delivers the server's copy for the **same slice** and writes it through to the cache before the callback. Reconcile your displayed list against it.
+
+
+
+Two rules follow from this:
+
+- **Offline, the read ends with an error.** After the cached page, a read whose server phase cannot be produced because the device is offline settles with `onError` carrying `CometChatConstants.Errors.ERROR_INTERNET_UNAVAILABLE`. The call always settles — it never hangs.
+- **The server phase never fires offline.** It fires only on a genuine server response. Never treat a cached page as "end of history"; an empty cached page only means the end of the *cached* window.
+
+All callbacks are delivered on the main thread.
+
+
+With offline support **off**, these overloads behave like the classic call: the cached phase never fires, and the server result arrives in `onServer…` instead of `onSuccess`. Nothing changes until you enable offline (see [Enable Offline Support](/sdk/ios/offline-enable)). Requires SDK **4.3.0** or later.
+
+
+
+A second fetch on the **same request object** while its previous fetch is still in flight settles immediately with `ERROR_REQUEST_IN_PROGRESS` and fires no cached phase. Wait for the first fetch to finish, or create a new request.
+
+
+## Read messages
+
+`MessagesRequest` supports both directions. `fetchPrevious` pages toward older messages; `fetchNext` pages toward newer ones. Both have a two-phase overload.
+
+```swift
+let request = MessagesRequest.MessageRequestBuilder()
+ .set(uid: "UID")
+ .set(limit: 30)
+ .build()
+
+request.fetchPrevious(onCachedMessages: { messages in
+ // Render the cached page immediately.
+}, onServerMessages: { messages in
+ // Reconcile this slice against the cached page, keyed by message id.
+}, onError: { error in
+ print("Fetch failed: \(error?.errorCode ?? "")")
+})
+```
+
+The classic `fetchPrevious(onSuccess:onError:)` and `fetchNext(onSuccess:onError:)` are unchanged and never deliver a cached result.
+
+## Read conversations
+
+`ConversationRequest` pages in one direction with `fetchNext`.
+
+```swift
+let request = ConversationRequest.ConversationRequestBuilder(limit: 30).build()
+
+request.fetchNext(onCachedConversations: { conversations in
+ // Render the cached list immediately.
+}, onServerConversations: { conversations in
+ // Reconcile, keyed by conversation id.
+}, onError: { error in
+ print("Fetch failed: \(error?.errorCode ?? "")")
+})
+```
+
+
+Conversations deleted on the server are removed from the cache only by this two-phase overload. The classic `fetchNext(onSuccess:onError:)` updates the cache but does not remove them.
+
+
+## Read the other lists
+
+Every remaining list read gains the same two-phase overload, with `onCached…` / `onServer…` closures of the matching type plus `onError`.
+
+| Request | Fetch method(s) | Cached / server closure labels |
+|---|---|---|
+| `MessagesRequest` | `fetchPrevious`, `fetchNext` | `onCachedMessages` / `onServerMessages` |
+| `ConversationRequest` | `fetchNext` | `onCachedConversations` / `onServerConversations` |
+| `ThreadsRequest` | `fetchNext` | `onCachedThreads` / `onServerThreads` |
+| `UsersRequest` | `fetchNext` | `onCachedUsers` / `onServerUsers` |
+| `BlockedUserRequest` | `fetchNext` | `onCachedUsers` / `onServerUsers` |
+| `GroupsRequest` | `fetchNext` | `onCachedGroups` / `onServerGroups` |
+| `GroupMembersRequest` | `fetchNext` | `onCachedGroupMembers` / `onServerGroupMembers` |
+| `BannedGroupMembersRequest` | `fetchNext` | `onCachedBannedGroupMembers` / `onServerBannedGroupMembers` |
+| `ReactionsRequest` | `fetchPrevious`, `fetchNext` | `onCachedReactions` / `onServerReactions` |
+| `NotificationFeedRequest` | `fetchNext` | `onCachedNotificationFeedItems` / `onServerNotificationFeedItems` |
+
+The call shape is identical to the conversations example above — swap the request and the closure labels.
+
+```swift
+let request = UsersRequest.UsersRequestBuilder()
+ .set(limit: 30)
+ .build()
+
+request.fetchNext(onCachedUsers: { users in
+ // render
+}, onServerUsers: { users in
+ // reconcile, keyed by uid
+}, onError: { error in
+ print("Fetch failed: \(error?.errorCode ?? "")")
+})
+```
+
+
+For `ReactionsRequest`, `fetchPrevious` and `fetchNext` are recorded and replayed independently — each direction keeps its own cached pages. Users, groups, members, banned members, reactions, and the notification feed are served from a per-request snapshot rather than a shared store: the cached phase replays what the server returned online for the **same builder settings**, so a list you never fetched online has an empty cached page. Per-builder details are in the [Configuration & API Reference](/sdk/ios/offline-reference).
+
+
+## Read a single item
+
+`getConversation` and `getMessageDetails` each add a two-phase overload. Unlike the list reads, the cached callback fires **only when the item is cached** — never with an empty or nil object. A cache miss goes straight to the server phase online, or straight to `onError` offline.
+
+`getConversation` takes a `ConversationDetailsListener`; `getMessageDetails` takes closures.
+
+```swift
+final class ConversationLoader: NSObject, ConversationDetailsListener {
+
+ func onCachedConversation(_ conversation: Conversation) {
+ // Fires only on a cache hit.
+ }
+
+ func onServerConversation(_ conversation: Conversation?) {
+ // Server copy, written through before delivery.
+ }
+
+ func onError(_ error: CometChatException?) {
+ print("Lookup failed: \(error?.errorCode ?? "")")
+ }
+}
+
+let loader = ConversationLoader()
+CometChat.getConversation(conversationWith: "UID", conversationType: .user, listener: loader)
+
+CometChat.getMessageDetails(messageId, onCachedMessage: { message in
+ // cache hit only
+}, onServerMessage: { message in
+ // server copy
+}, onError: { error in
+ print("Lookup failed: \(error?.errorCode ?? "")")
+})
+```
+
+
+The SDK holds the `ConversationDetailsListener` until the read ends, so you do not need to keep your own reference for a single lookup. The older `getConversation(conversationWith:conversationType:onCachedConversation:onServerConversation:onError:)` closure overload still works but is deprecated in favour of the listener form.
+
+
+## Merge the two phases
+
+The server phase **replaces the same slice** — it is a reconcile, not a second batch. Render the cached page straight away, then on the server phase diff it into your displayed list keyed by the item's stable id (message id, conversation id, uid, guid). Do not append the server page to the cached one.
+
+
+Key your list by id so the two phases merge cleanly: update items that changed, add new ones, and drop cached items the server slice omits. To page further back, issue the next `fetchPrevious` / `fetchNext` rather than treating the server phase as more pages.
+
+
+## When the cached page is empty
+
+The cache answers a read **only if every filter on it is locally computable**. A single server-only predicate makes the entire cached phase return an empty list — with no error and no warning — while the server phase still answers in full. The guiding rule is *better empty than wrong*.
+
+The most common server-only predicate is `set(searchKeyword:)`, which blanks the cached phase on every request. Other examples on `MessagesRequest` are `hasLinks`, `updatesOnly(onlyUpdates:)` and `setInteractionGoalCompletedOnly(_:)`. On the entity lists, a keyword search or a presence filter (`UsersRequest.set(status:)`, `GroupMembersRequest.set(status:)`) is never snapshotted, so it has no cached phase either.
+
+
+An offline-critical screen must use only cache-supported filters — one innocuous builder flag can silently disable the cached phase for a whole screen. The full per-builder matrix of what the cache answers lives in the [Configuration & API Reference](/sdk/ios/offline-reference).
+
+
+## Known limitations
+
+- A `MessagesRequest` with no user or group set is not fully served from the cache while offline, and its filters may not be applied to cached results.
+- With offline on, `NotificationFeedRequest.fetchNext` past the last page returns an empty list without asking the server; create a new request to pick up items that arrive later.
+- `ReactionsRequest` past its last page asks the server again and can return reactions it already returned — remove duplicates by id.
diff --git a/sdk/ios/offline-enable.mdx b/sdk/ios/offline-enable.mdx
new file mode 100644
index 000000000..466702f3a
--- /dev/null
+++ b/sdk/ios/offline-enable.mdx
@@ -0,0 +1,113 @@
+---
+title: "Enable Offline Support"
+sidebarTitle: "Enable Offline Support"
+description: "Turn on local caching and the durable send outbox, and verify offline support is active with the CometChat iOS SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```swift
+// 1. Build an OfflineConfig (off by default — call setEnabled(true)).
+let offlineConfig = OfflineConfig.Builder()
+ .setEnabled(true)
+ .build()
+// Swift shorthand: let offlineConfig = OfflineConfig(enabled: true)
+
+// 2. Attach it to AppSettings.
+let appSettings = AppSettings.AppSettingsBuilder()
+ .setRegion(region: "REGION")
+ .setOfflineConfig(offlineConfig)
+ .build()
+
+// 3. Pass the settings to init.
+CometChat.init(appId: "APP_ID", appSettings: appSettings, onSuccess: { _ in }, onError: { _ in })
+
+// 4. Verify it is active for the logged-in user.
+let active = CometChat.isOfflineEnabled() // Bool
+```
+
+
+
+Offline support is **off by default**. You turn it on once, at initialization, by attaching an `OfflineConfig` to your `AppSettings`. Until you do, the SDK behaves exactly as before. This page covers the minimal setup; for every tunable option see the [Configuration & API Reference](/sdk/ios/offline-reference).
+
+
+
+
+Offline support requires CometChat iOS SDK **4.3.0** or later. Update the dependency:
+
+{/* TODO(version): 4.3.0 is unreleased in the SDK changelog. Confirm the published pod/SPM version and whether a pre-release qualifier is needed. */}
+
+
+```ruby
+pod 'CometChatSDK', '4.3.0'
+```
+
+
+Set the `https://github.com/cometchat/chat-sdk-ios.git` dependency rule to `Up to Next Major Version` with version `4.3.0`.
+
+
+
+If you are new to the SDK, complete the [standard setup](/sdk/ios/setup) first; the steps below only add offline configuration to your existing `init`.
+
+
+
+Create an `OfflineConfig` and enable it. Every other field has a sensible default, so set only what you need. Swift can use the initializer with default arguments; the `Builder` works from both Swift and Objective-C.
+
+
+
+```swift
+let offlineConfig = OfflineConfig(enabled: true)
+```
+
+
+```swift
+let offlineConfig = OfflineConfig.Builder()
+ .setEnabled(true)
+ .build()
+```
+
+
+
+
+
+Pass the config to `AppSettingsBuilder.setOfflineConfig(_:)`. Without it, `AppSettings` keeps `OfflineConfig.default`, which has offline switched off.
+
+```swift
+let appSettings = AppSettings.AppSettingsBuilder()
+ .setRegion(region: "REGION")
+ .setOfflineConfig(offlineConfig)
+ .build()
+```
+
+
+
+Pass the settings to `CometChat.init` as you do today. No other call changes.
+
+```swift
+CometChat.init(appId: "APP_ID", appSettings: appSettings, onSuccess: { isSuccess in
+ print("Initialization completed successfully")
+}, onError: { error in
+ print("Initialization failed: \(error.errorDescription)")
+})
+```
+
+
+
+Call `CometChat.isOfflineEnabled()`. It returns `true` only while offline support is actually available to the logged-in user: your `OfflineConfig` turns it on, CometChat has not switched offline off for your app, and the per-user cache is open. So it is `false` before anyone logs in and after logout, even when you enabled it in `AppSettings`. Read it at the moment of each decision and keep no copy of the result.
+
+```swift
+let active = CometChat.isOfflineEnabled()
+print("Offline support active: \(active)")
+```
+
+
+It is also `false` while the cache cannot run encrypted on this device and you chose `.onlineOnly` (see [Encrypt the Cache](/sdk/ios/offline-encryption)). It is already `true` by the time any `onLoginSuccess` observer runs.
+
+
+
+
+
+## Next steps
+
+Offline support is now active for the logged-in user. Continue with [Show Cached Data](/sdk/ios/offline-cached-data) to adopt the two-phase read overloads, and [Send While Offline](/sdk/ios/offline-send) for the durable outbox. Tune caps, media, and encryption from the [Configuration & API Reference](/sdk/ios/offline-reference).
diff --git a/sdk/ios/offline-encryption.mdx b/sdk/ios/offline-encryption.mdx
new file mode 100644
index 000000000..a2a32e41d
--- /dev/null
+++ b/sdk/ios/offline-encryption.mdx
@@ -0,0 +1,145 @@
+---
+title: "Encrypt the Cache"
+sidebarTitle: "Encrypt the Cache"
+description: "Encrypt the offline message cache at rest with SQLCipher, choose what happens when encryption cannot run, and inspect the live encryption status with the CometChat iOS SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```swift
+// 1. Nothing to add: the SDK bundles SQLCipher (no extra dependency).
+
+// 2. Turn encryption on in OfflineConfig (off by default)
+let offlineConfig = OfflineConfig.Builder()
+ .setEnabled(true)
+ .setEncryption(true) // encrypt the cache database at rest
+ .setEncryptionFailureBehavior(.onlineOnly) // default; never writes plaintext
+ .build()
+
+// 3. Inspect what encryption is actually doing (a [String: Any] dictionary)
+let status = CometChat.getOfflineEncryptionStatus()
+status["enabled"] // Bool - what was configured (false while offline is off for the app)
+status["atRest"] // String - "SQLCIPHER" or "NONE"
+status["keyStorage"] // String - "KEYSTORE" (the iOS Keychain) or "NONE"
+status["cipherAvailable"] // Bool - is the SQLCipher engine present in this build?
+status["cipher"] // String - engine cipher_version once proven, else NSNull
+status["leakage"] // String - what is never covered (media files on disk)
+```
+
+
+
+Offline support keeps the logged-in user's messages in a local database on the device. Turn encryption on to store that database **encrypted at rest** using [SQLCipher](https://www.zetetic.net/sqlcipher/). Encryption is **opt-in** and off by default; nothing changes until you call `setEncryption(true)`. The minimum SDK version is **4.3.0**.
+
+
+Encryption covers the **message database only**. Downloaded and outgoing media files (photos, voice notes, documents) stay as ordinary unencrypted files on disk, because their local paths are handed to native image and video loaders. See [What is and isn't encrypted](#what-is-and-isn-t-encrypted).
+
+
+## No extra dependency
+
+Unlike some platforms, iOS needs no separate cipher dependency. The SDK ships its own SQLCipher build and keeps it isolated from your app, so it does not clash with an app's own SQLite, SQLCipher, GRDB or FMDB. There is nothing to add to your `Podfile` or Swift package.
+
+## Turn on encryption
+
+Set `setEncryption(true)` on the `OfflineConfig` you pass to the SDK during initialization (see [Enable Offline Support](/sdk/ios/offline-enable)).
+
+
+
+```swift
+let offlineConfig = OfflineConfig.Builder()
+ .setEnabled(true)
+ .setEncryption(true)
+ .setEncryptionFailureBehavior(.onlineOnly)
+ .build()
+```
+
+
+```swift
+let offlineConfig = OfflineConfig(
+ enabled: true,
+ encryption: true,
+ encryptionFailureBehavior: .onlineOnly
+)
+```
+
+
+
+## Choose what happens when encryption cannot run
+
+`encryptionFailureBehavior` decides what the cache does when encryption **cannot be applied at all** on this build or device — the cipher engine is not linked into the build, for example. Because the engine ships inside the SDK this should not happen in a normal app, but the setting is there so you decide the outcome rather than the SDK. It governs only that case: a transient failure (such as a locked Keychain) still retries, and a lost or wrong key self-heals by wiping and rebuilding the cache **encrypted**.
+
+| `CometChat.EncryptionFailureBehavior` | Behavior | Writes plaintext? |
+|---|---|---|
+| `.onlineOnly` *(default)* | Do not open a local cache at all — the session runs server-only. Makes a setup mistake visible: offline simply does not work until it is fixed. | No |
+| `.plaintextFallback` | Open an **unencrypted** cache so offline keeps working, and log the downgrade at error level. | Yes |
+
+
+`.plaintextFallback` means cached message content may be stored **unencrypted** on disk whenever encryption cannot run. This availability-over-guarantee trade is wrong for compliance use — keep the default `.onlineOnly` if cached content must never be written in the clear.
+
+
+While the cache is dormant for this reason, `CometChat.isOfflineEnabled()` returns `false`.
+
+## Key storage
+
+You do not supply or manage the encryption key. The SDK generates a random 256-bit key per user (scoped to the app ID and the logged-in user's UID) and keeps it in the **iOS Keychain**, with the `AfterFirstUnlockThisDeviceOnly` accessibility class — available after the first unlock, and never synced or restored to another device. The key is never returned by any API. If the key is lost or the stored database no longer matches it, the cache resets and rebuilds — see [Cache resets](#cache-resets-on-key-loss).
+
+## Check encryption status
+
+`CometChat.getOfflineEncryptionStatus()` returns a `[String: Any]` dictionary describing what encryption is **actually** doing — never what was merely requested. It is safe to call at any time and never returns the key or any part of it.
+
+```swift
+let status = CometChat.getOfflineEncryptionStatus()
+print("enabled=\(status["enabled"] ?? "")",
+ "atRest=\(status["atRest"] ?? "")",
+ "keyStorage=\(status["keyStorage"] ?? "")",
+ "cipherAvailable=\(status["cipherAvailable"] ?? "")",
+ "cipher=\(status["cipher"] ?? "")")
+```
+
+| Key | Value type | Meaning |
+|---|---|---|
+| `enabled` | `Bool` | What was **configured** (`OfflineConfig.encryption`), not what was achieved. `false` whenever offline is switched off for the app. |
+| `atRest` | `String` | Coverage actually achieved: `"SQLCIPHER"` once an encrypted open is proven, otherwise `"NONE"`. |
+| `keyStorage` | `String` | `"KEYSTORE"` when the key was resolved from the iOS Keychain; `"NONE"` when this session holds no key. Read `NONE` as "no key in use", not "no key exists". |
+| `cipherAvailable` | `Bool` | Whether the SQLCipher engine is present and linked in this process. `false` when encryption is off. |
+| `cipher` | `String` / `NSNull` | The engine's `cipher_version` once an encrypted open is proven, else `NSNull`. |
+| `leakage` | `String` | What encryption at rest never covers — the downloaded media files stored outside the database. |
+
+The string values are also available as constants on `CometChat.OfflineEncryptionTier` (for example `OfflineEncryptionTier.atRestSqlcipher`).
+
+
+The single most useful support signal is `enabled == true` with `atRest == "NONE"`: encryption was switched on but is not in effect — the cache is dormant, still opening, or was opened unencrypted under `.plaintextFallback`.
+
+
+## What is and isn't encrypted
+
+| Data | Encrypted at rest? |
+|---|---|
+| Message database (messages, conversations, metadata, the pending-send outbox) | Yes, when `atRest` is `"SQLCIPHER"` |
+| Downloaded media files on disk (images, video, audio, documents) | **No** — stored as ordinary files outside the database |
+
+Media bytes live outside the database because `Attachment.localPath` is public API and those paths are passed straight to native media loaders. "Encrypted at rest" here always means the message database, never the media cache. Encryption is not end-to-end: the server still sees message content.
+
+## Cache resets on key loss
+
+If the key can no longer decrypt the stored database, the SDK wipes the local cache and rebuilds it **encrypted** — it never silently falls back to plaintext for a cache that could be encrypted. Any unsent messages still queued in the outbox at that point are lost.
+
+Register an `OfflineCacheResetListener` to be told when this happens:
+
+```swift
+final class CacheResetObserver: NSObject, OfflineCacheResetListener {
+
+ func onOfflineCacheReset(_ reason: CometChat.OfflineCacheResetReason, _ pendingMessagesLost: Int) {
+ print("Cache reset: \(reason), pending lost=\(pendingMessagesLost)")
+ }
+}
+
+let resetObserver = CacheResetObserver() // keep a strong reference
+CometChat.addOfflineCacheResetListener("LISTENER_ID", resetObserver)
+```
+
+The encryption-related reasons are `.keyLost`, `.wrongKey`, `.corrupted`, and `.encryptionChanged` (you turned encryption on or off between launches). `pendingMessagesLost` is `CometChat.offlinePendingCountUnknown` (`-1`) when the unsent messages could not be counted. See [Stay in Sync](/sdk/ios/offline-sync) for the full cache-reset flow and the complete list of reasons, and the [API Reference](/sdk/ios/offline-reference) for every field and enum.
+
+
+Switching `encryption` on or off between launches rebuilds the cache in the new format. If the old cache file cannot be deleted at that moment, offline support is unavailable for the rest of that app session.
+
diff --git a/sdk/ios/offline-media-caching.mdx b/sdk/ios/offline-media-caching.mdx
new file mode 100644
index 000000000..6618b5be9
--- /dev/null
+++ b/sdk/ios/offline-media-caching.mdx
@@ -0,0 +1,148 @@
+---
+title: "Media Caching"
+sidebarTitle: "Media Caching"
+description: "Cache image, video, audio and file attachments on the device so media stays viewable offline with the CometChat iOS SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```swift
+// Set the media caching mode at init (fixed for the session)
+let offlineConfig = OfflineConfig.Builder()
+ .setEnabled(true)
+ .setMediaCaching(.auto) // .auto (default) or .off
+ .setMaxMediaCacheSize(500 * 1024 * 1024) // bytes; 0 = unlimited
+ .build()
+
+// Read the mode in effect
+let mode = CometChat.getMediaCachingMode() // .off when offline is not active
+
+// Resolve cached bytes for an attachment
+let attachment = mediaMessage.attachment
+let localPath = attachment?.localPath // hydrated automatically on reads
+let cached = CometChat.getCachedMediaPath(attachment!.fileUrl) // nil if not cached
+
+// Download on demand
+CometChat.downloadMedia(url: attachment!.fileUrl, messageServerId: mediaMessage.id,
+ onProgress: { percent in },
+ onSuccess: { localPath in },
+ onError: { error in })
+
+// Observe background (.auto) downloads
+CometChat.addMediaDownloadListener("MEDIA_LISTENER", mediaObserver) // MediaDownloadListener
+```
+
+
+
+With offline support on, the SDK can cache attachment bytes durably on the device so media stays viewable offline — even after the server's presigned URL has expired. This page covers the caching modes, what downloads automatically, how to read cached bytes, and how to download media on demand.
+
+
+Media caching applies only while offline support is active for the logged-in user (`CometChat.isOfflineEnabled()` is `true`). See [Enable Offline Support](/sdk/ios/offline-enable).
+
+
+## Caching modes
+
+The caching mode is set once, in the `OfflineConfig` you pass at initialization, and is fixed for the SDK session. There is no runtime switch — to change it, re-initialize the SDK with a new config.
+
+| `CometChat.MediaCachingMode` | Behavior |
+|---|---|
+| `.auto` (default) | Incoming media bytes are eagerly downloaded and cached, then LRU-managed. The outgoing copy is retained after the server acknowledges the send. |
+| `.off` | Incoming media bytes are **not** cached. The message metadata (including `fileUrl`) is still cached, so the message appears offline and the UI loads media live from the URL when online. The outgoing copy is dropped after the send is acknowledged. |
+
+Sending media always works in both modes.
+
+```swift
+let offlineConfig = OfflineConfig.Builder()
+ .setEnabled(true)
+ .setMediaCaching(.auto)
+ .setMaxMediaCacheSize(500 * 1024 * 1024)
+ .build()
+```
+
+Read the mode in effect at runtime with `CometChat.getMediaCachingMode()`. It returns `.off` whenever offline support is not active, whatever the app configured, so a UI layer can rely on it without reading the config itself.
+
+## What downloads automatically
+
+In `.auto` mode, media bytes are downloaded and cached automatically on the two live receive paths:
+
+- a **realtime** media message received while connected, and
+- opening a chat **online** (the server read refreshes and caches its media).
+
+A media message that arrives through a reconnect catch-up [sync](/sdk/ios/offline-sync) is cached as **metadata only** and renders as a placeholder; its bytes download the next time you open that chat online. This holds in both `.auto` and `.off`.
+
+In `.off` mode, nothing is auto-downloaded on any path; media loads live from its URL when online.
+
+## Reading cached media
+
+When bytes are already cached, the SDK **hydrates the local path automatically** on every message it delivers — you do not have to ask. Read it straight off the attachment:
+
+```swift
+if let attachment = mediaMessage.attachment, let localPath = attachment.localPath {
+ // Non-nil when bytes are cached. Render directly from disk — no network fetch.
+}
+```
+
+To look up the cached path explicitly, call `getCachedMediaPath` with the attachment's URL. It returns `nil` when the bytes are not cached (call `downloadMedia` to fetch them).
+
+```swift
+let path = CometChat.getCachedMediaPath(attachment.fileUrl) // nil if not cached
+```
+
+
+Each attachment of a multi-attachment message resolves independently. An attachment with no cached bytes keeps `localPath == nil` and the UI loads it from the URL as before.
+
+
+## Downloading on demand
+
+Call `downloadMedia` to fetch and cache an attachment's bytes — for example when the user taps a placeholder. Pass the attachment's `fileUrl` and the message's server id. The SDK resolves the bytes in order: the cache, then the URL, then a re-signed URL (looked up with the message id) if the presigned one expired.
+
+```swift
+CometChat.downloadMedia(url: attachment.fileUrl, messageServerId: mediaMessage.id, onProgress: { percent in
+ // 0...100, best-effort.
+}, onSuccess: { localPath in
+ // Bytes cached; render from localPath.
+}, onError: { error in
+ print("Download failed: \(error.errorCode)")
+})
+```
+
+Without progress, use `downloadMedia(url:messageServerId:onSuccess:onError:)`.
+
+`onProgress` reports `0...100`, best-effort and never going backwards. It is emitted only when the server reports a content length; for an unknown-length download it is skipped and the call goes straight to `onSuccess`. Progress never blocks or fails the download. Dispatch to the main queue before updating UI from these closures.
+
+`onError` carries an offline error code, never a raw HTTP status:
+
+| Error code | Meaning | What to do |
+|---|---|---|
+| `ERROR_OFFLINE_MEDIA_NOT_CACHED` | The bytes are not cached and the device is offline. | Show a placeholder; do not retry until the connection returns. |
+| `ERROR_OFFLINE_MEDIA_UNRESOLVABLE` | The cache missed, the download failed, and re-signing produced nothing usable (or the URL was invalid). | A retry is allowed but likely to fail. |
+| `ERROR_OFFLINE_DISABLED` | Offline support is not enabled, or nobody is logged in. | Load from the URL as before. |
+
+## Observe background downloads
+
+In `.auto` mode, incoming media downloads in the background with no per-call callback. Register a `MediaDownloadListener` to learn when the bytes land (or fail) so a UI tile can refresh. Callbacks fire on the main thread.
+
+```swift
+final class MediaObserver: NSObject, MediaDownloadListener {
+
+ func onMediaDownloaded(_ messageId: String, _ url: String, _ localPath: String) {
+ // messageId: the message's server id (or its local id if not yet sent)
+ // url: the attachment URL without its query string or fragment
+ // localPath: where the bytes landed
+ }
+
+ func onMediaDownloadFailed(_ attachment: Attachment?, _ error: CometChatException) { }
+}
+
+let mediaObserver = MediaObserver() // keep a strong reference
+CometChat.addMediaDownloadListener("MEDIA_LISTENER", mediaObserver)
+```
+
+Remove the listener with `CometChat.removeMediaDownloadListener("MEDIA_LISTENER")`. The background `MediaDownloadListener` carries no progress callback — progress is reported only on the explicit `downloadMedia` path above.
+
+## Media cache size limit
+
+Cached media bytes are bounded by `maxMediaCacheSize` (default **500 MB**; `0` means unlimited). When the cache exceeds the limit, the least recently accessed media is deleted first until it is back under budget. The outgoing copies of un-sent messages and in-progress uploads are never evicted. An outgoing media send that cannot fit the budget fails with `ERROR_OFFLINE_MEDIA_BYTE_OVERFLOW` (see [Send While Offline](/sdk/ios/offline-send#queue-limits)).
+
+Media is stored as files in durable, app-private storage; the location is not configurable. Eviction runs silently in the background — there is no cache-limit callback. To reason about size, use `CometChat.getCacheSize()` and the configured limits. See the [configuration reference](/sdk/ios/offline-reference) for the full set of options.
diff --git a/sdk/ios/offline-overview.mdx b/sdk/ios/offline-overview.mdx
new file mode 100644
index 000000000..d601dd7fe
--- /dev/null
+++ b/sdk/ios/offline-overview.mdx
@@ -0,0 +1,95 @@
+---
+title: "Offline Support"
+sidebarTitle: "Offline Support"
+description: "Understand how local caching, the send outbox, and reconnect catch-up keep chat working without a connection with the CometChat iOS SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```swift
+// Offline support is OFF by default. Turn it on at init (SDK 4.3.0 or later).
+let offlineConfig = OfflineConfig.Builder()
+ .setEnabled(true)
+ .build()
+
+let appSettings = AppSettings.AppSettingsBuilder()
+ .setRegion(region: "REGION")
+ .setOfflineConfig(offlineConfig) // or OfflineConfig(enabled: true) for defaults
+ .build()
+
+CometChat.init(appId: "APP_ID", appSettings: appSettings, onSuccess: { _ in }, onError: { _ in })
+
+// Is offline support active right now for the logged-in user?
+let active = CometChat.isOfflineEnabled() // Bool
+```
+
+
+
+Offline support keeps the logged-in user's conversations and messages on the device so your app can show them instantly, lets the user send messages while disconnected, and catches up automatically when the connection returns. It is **off by default** — nothing changes in your app until you enable it ([Enable Offline Support](/sdk/ios/offline-enable)). This page explains the mental model; the other pages show the exact APIs.
+
+
+Offline support ships in CometChat iOS SDK **4.3.0** or later.
+
+
+## How it works
+
+Three ideas carry the whole feature.
+
+
+
+Every read goes through the local cache first. The SDK delivers the cached result immediately — even when it is empty — then fetches from the server and delivers the fresh result. Your UI renders what is on the device without waiting for the network, then updates when the server responds. See [Show Cached Data](/sdk/ios/offline-cached-data).
+
+
+A message sent while offline is saved to a durable outbox and reported as pending instead of failing. It survives app restarts and is sent, in the order it was composed, when the connection returns. See [Send While Offline](/sdk/ios/offline-send).
+
+
+When the device reconnects, the SDK syncs the conversations and messages that changed while it was away and flushes the outbox, so the local cache matches the server again. See [Stay in Sync](/sdk/ios/offline-sync).
+
+
+
+```mermaid
+flowchart LR
+ A[App read] --> B[(Local cache)]
+ B -- cached result first --> A
+ B --> C{Online?}
+ C -- yes --> D[Server]
+ D -- fresh result --> A
+ C -- no --> E[onError:\nERROR_INTERNET_UNAVAILABLE]
+ F[Send while offline] --> G[(Durable outbox)]
+ G -- on reconnect --> D
+```
+
+## What changes in your app
+
+With offline support enabled, reads fire **twice** and sends gain a pending stage. To receive these cached and pending results you call the new overloads of the calls you already use:
+
+- Reads: `fetchNext` / `fetchPrevious` with `onCached…`, `onServer…` and `onError` closures, and `getConversation(conversationWith:conversationType:listener:)` with a `ConversationDetailsListener`.
+- Sends: `sendTextMessage`, `sendMediaMessage`, `sendCustomMessage` and `sendInteractiveMessage` with `onMessagePending`, `onMessageSent` and `onError` closures.
+
+The existing `onSuccess` / `onError` overloads are unchanged and keep working exactly as before. With offline support off, the new overloads behave like the classic ones: the cached and pending callbacks never fire and the server result arrives in `onServer…` / `onMessageSent`.
+
+## One cache per user
+
+The SDK keeps a single local cache for the user who is logged in.
+
+- Logging the **same** user back in reuses that cache, so the data is there instantly on next launch.
+- Logging out, or logging in as a **different** user, deletes the cache and any unsent messages. You never clear it yourself — the SDK does it.
+
+See [Stay in Sync](/sdk/ios/offline-sync) for cache resets and logout behaviour.
+
+## How much is kept
+
+By default the cache is bounded by size, not age: it holds up to **100 conversations**, up to **500 messages per conversation**, around **250 MB** of cached content, and up to **500 MB** of cached media. Older entries are evicted first as these limits are reached. Unsent messages, saved messages and pinned rows are never evicted by these limits. Every limit is tunable — see the [Configuration & API Reference](/sdk/ios/offline-reference).
+
+## Next steps
+
+
+ Turn it on and verify it is active.
+ The cached-first, server-next read contract.
+ The durable outbox and pending messages.
+ Reconnect catch-up, cache resets, logout.
+ Download and cache attachments for offline viewing.
+ Encrypt the local database at rest.
+ Every option, method, listener, enum, and error.
+
diff --git a/sdk/ios/offline-reference.mdx b/sdk/ios/offline-reference.mdx
new file mode 100644
index 000000000..f9132ecda
--- /dev/null
+++ b/sdk/ios/offline-reference.mdx
@@ -0,0 +1,215 @@
+---
+title: "Offline Configuration & API Reference"
+sidebarTitle: "Configuration & API Reference"
+description: "A complete reference for the offline configuration options, public methods, listeners, enums, model fields, and error codes with the CometChat iOS SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```swift
+// Build the config (every field has a default; set only what you change)
+let offlineConfig = OfflineConfig.Builder()
+ .setEnabled(true)
+ .setMaxConversations(100)
+ .setMaxMessagesPerConversation(500)
+ .setMaxPendingMessages(50)
+ .setMediaCaching(.auto)
+ .setEncryption(false)
+ .build()
+
+// Introspection / control
+CometChat.isOfflineEnabled() // Bool
+CometChat.getMediaCachingMode() // CometChat.MediaCachingMode
+CometChat.getOfflineEncryptionStatus() // [String: Any]
+CometChat.getSyncState() // CometChat.SyncState
+CometChat.getLastSyncTimestamp() // Int (server epoch seconds)
+CometChat.getCacheSize() // Int64 (bytes) - call off the main thread
+CometChat.getPendingMessages(onSuccess: { _ in }, onError: { _ in })
+CometChat.forceSyncNow() // or forceSyncNow(onSuccess:onError:)
+CometChat.clearOfflineCache()
+```
+
+
+
+This page is the full surface of CometChat's offline support on iOS: configuration options, public methods, listeners, enums, model fields, and error codes. The minimum SDK version is **4.3.0**, and offline support is **off by default**. For task-focused guides see [Enable Offline Support](/sdk/ios/offline-enable), [Show Cached Data](/sdk/ios/offline-cached-data), [Send While Offline](/sdk/ios/offline-send), [Stay in Sync](/sdk/ios/offline-sync), [Media Caching](/sdk/ios/offline-media-caching), and [Encrypt the Cache](/sdk/ios/offline-encryption).
+
+## OfflineConfig options
+
+Build with `OfflineConfig.Builder`, or with the Swift initializer `OfflineConfig(enabled:encryption:…)`, whose arguments all default to the values below. The resulting `OfflineConfig` is immutable. Byte caps and the TTL treat `0` as **unlimited** (that dimension's eviction is disabled); the count caps are clamped and have no "unlimited".
+
+| Builder setter | Type | Default | Range / clamp | Effect |
+|---|---|---|---|---|
+| `setEnabled(_:)` | `Bool` | `false` | — | Master gate for the whole offline engine. With it off the SDK behaves exactly as before. |
+| `setCacheDurationDays(_:)` | `Int` | `0` (unlimited) | `>= 0`; negative treated as `0` | Message retention TTL in days. `0` = no age-based prune; eviction is by the byte/count caps instead. |
+| `setMaxStorageSize(_:)` | `Int64` bytes | `262144000` (250 MB) | `0` = unlimited; negative treated as `0` | Byte budget for cached content: messages, entity snapshots and stored lists. The outbox is excluded. |
+| `setMaxMessagesPerConversation(_:)` | `Int` | `500` | clamped `[1, 1000]`; `0`/negative/over → `1000` | Per-conversation cached-message window. |
+| `setMaxConversations(_:)` | `Int` | `100` | clamped `[1, 1000]`; `0`/negative/over → `1000` | Cached-conversation cap. |
+| `setMaxPendingMessages(_:)` | `Int` | `50` | clamped `[1, 100]`; `0`/negative/over → `100` | Pending-send outbox capacity. |
+| `setAutoSendPendingOnReconnect(_:)` | `Bool` | `true` | — | Auto-flush queued pending sends on reconnect. Gates only the outbox flush; the reconnect sync sweep runs regardless. |
+| `setPendingQueueOverflowPolicy(_:)` | `CometChat.PendingMessageOverflowPolicy` | `.rejectNewest` | — | What the outbox does when it is full. |
+| `setMaxMediaCacheSize(_:)` | `Int64` bytes | `524288000` (500 MB) | `0` = unlimited; negative treated as `0` | Media-only byte budget (LRU). |
+| `setMediaCaching(_:)` | `CometChat.MediaCachingMode` | `.auto` | — | Receive-side media mode. Fixed at init. |
+| `setEncryption(_:)` | `Bool` | `false` | — | Encrypt the cache database at rest. See [Encrypt the Cache](/sdk/ios/offline-encryption). |
+| `setEncryptionFailureBehavior(_:)` | `CometChat.EncryptionFailureBehavior` | `.onlineOnly` | — | What happens when encryption cannot run at all. |
+| `setMaxSnapshotQueries(_:)` | `Int` | `100` | `0`/negative → `100` | Most entity-list snapshots (users, groups, group members, and so on) kept for offline replay; LRU. |
+| `setSnapshotTtlDays(_:)` | `Int` | `7` | `0`/negative → `7` | Days an entity-list snapshot is kept. |
+| `setDebugTracing(_:)` | `Bool` | `false` | — | Dev-only diagnostic tracing. Leave off in production. |
+
+{/* TODO(verify): the source comments for maxSnapshotQueries / snapshotTtlDays say "clamped to [1,1000]" / "[1,365]" but OfflineConfig only normalises 0/negative to the default and applies no upper clamp. The table documents the code; confirm intended behaviour. */}
+
+Attach the config at init via `AppSettings.AppSettingsBuilder.setOfflineConfig(_:)` — see [Enable Offline Support](/sdk/ios/offline-enable). Each value is readable on the built `OfflineConfig` (for example `offlineConfig.maxConversations`), and `AppSettings.offlineConfig` exposes the active config read-only.
+
+## Public methods on CometChat
+
+| Method | Returns | Purpose |
+|---|---|---|
+| `isOfflineEnabled()` | `Bool` | Whether offline support is active for the current session. |
+| `getMediaCachingMode()` | `CometChat.MediaCachingMode` | The effective media caching mode (`.off` whenever offline is not active). |
+| `getOfflineEncryptionStatus()` | `[String: Any]` | What encryption at rest is actually doing. |
+| `getPendingMessages(onSuccess:onError:)` | `Void` | All queued/failed messages in the outbox. The synchronous `getPendingMessages()` is deprecated. |
+| `retryMessage(_ localMessageId: String, onSuccess:onError:)` | `Void` | Retry one unsent message by its local id. Swift-only. |
+| `retryMessage(_ message: BaseMessage)` | `Void` | Fire-and-forget retry that takes the message. |
+| `retryAllFailedMessages()` | `Void` | Retry every transient-failed message in the outbox. No callback. |
+| `discardLocalMessage(_ localMessageId: String, onSuccess:onError:)` | `Void` | Remove one unsent message from the outbox. Swift-only. |
+| `forceSyncNow()` | `Void` | Trigger a catch-up sync immediately. |
+| `forceSyncNow(onSuccess:onError:)` | `Void` | Same, with callbacks that report whether the sync was accepted. |
+| `getSyncState()` | `CometChat.SyncState` | Whether a catch-up sweep is currently running. |
+| `getLastSyncTimestamp()` | `Int` | Server time of the last successful sync, in epoch seconds. |
+| `clearOfflineCache()` | `Void` | Wipe the local cache (and fail any unsent messages). |
+| `getCacheSize()` | `Int64` | Current on-disk cache size in bytes. Reads the disk; call off the main thread. |
+| `downloadMedia(url:messageServerId:onSuccess:onError:)` | `Void` | Download and cache an attachment's bytes. |
+| `downloadMedia(url:messageServerId:onProgress:onSuccess:onError:)` | `Void` | Same, with a `0...100` progress closure. |
+| `getCachedMediaPath(_ url: String)` | `String?` | Local path to already-cached bytes, or `nil`. |
+| `getExistingUploadFileRequest(receiverId:receiverType:parentMessageId:)` | `UploadFileRequest?` | Re-attach to a kept offline upload batch; `nil` when offline is off or none exists. |
+
+### Listener registration
+
+Listeners are held **weakly** — keep a strong reference — and each ID holds one listener, so use a distinct ID per listener.
+
+| Method | Listener |
+|---|---|
+| `addMessageListener(_:_:)` / `removeMessageListener(_:)` | `CometChatMessageDelegate` (adds `onMessagesSynced`) |
+| `addOutboxListener(_:_:)` / `removeOutboxListener(_:)` | `OutboxListener` |
+| `addSyncListener(_:_:)` / `removeSyncListener(_:)` | `SyncListener` |
+| `addOfflineCacheResetListener(_:_:)` / `removeOfflineCacheResetListener(_:)` | `OfflineCacheResetListener` |
+| `addMediaDownloadListener(_:_:)` / `removeMediaDownloadListener(_:)` | `MediaDownloadListener` |
+
+## Listeners and callbacks
+
+### Two-phase fetch overloads
+
+iOS expresses the two-phase read as `onCached…` / `onServer…` / `onError` closure overloads of the existing fetch methods. The only listener protocol on the read path is `ConversationDetailsListener`. With offline off the cached closure never fires.
+
+| Call | Callbacks |
+|---|---|
+| `MessagesRequest.fetchNext` / `fetchPrevious` | `onCachedMessages([BaseMessage])`, `onServerMessages([BaseMessage])`, `onError(CometChatException?)` |
+| `ThreadsRequest.fetchNext` | `onCachedThreads([MessageThread])`, `onServerThreads([MessageThread])`, `onError(CometChatException?)` |
+| `CometChat.getMessageDetails(_:…)` | `onCachedMessage(BaseMessage?)`, `onServerMessage(BaseMessage?)`, `onError(CometChatException?)` |
+| `ConversationRequest.fetchNext` | `onCachedConversations([Conversation])`, `onServerConversations([Conversation])`, `onError(CometChatException?)` |
+| `CometChat.getConversation(conversationWith:conversationType:listener:)` | `ConversationDetailsListener`: `onCachedConversation(Conversation)`, `onServerConversation(Conversation?)`, `onError(CometChatException?)` |
+| `UsersRequest.fetchNext`, `BlockedUserRequest.fetchNext` | `onCachedUsers([User])`, `onServerUsers([User])`, `onError(CometChatException?)` |
+| `GroupsRequest.fetchNext` | `onCachedGroups([Group])`, `onServerGroups([Group])`, `onError(CometChatException?)` |
+| `GroupMembersRequest.fetchNext` | `onCachedGroupMembers([GroupMember])`, `onServerGroupMembers([GroupMember])`, `onError(CometChatException?)` |
+| `BannedGroupMembersRequest.fetchNext` | `onCachedBannedGroupMembers([GroupMember])`, `onServerBannedGroupMembers([GroupMember])`, `onError(CometChatException?)` |
+| `ReactionsRequest.fetchNext` / `fetchPrevious` | `onCachedReactions([Reaction])`, `onServerReactions([Reaction])`, `onError(CometChatException?)` |
+| `NotificationFeedRequest.fetchNext` | `onCachedNotificationFeedItems([NotificationFeedItem])`, `onServerNotificationFeedItems([NotificationFeedItem])`, `onError(CometChatException?)` |
+
+### Send, outbox, sync, cache, and media callbacks
+
+| Surface | Callbacks |
+|---|---|
+| Send closures (`sendTextMessage` / `sendMediaMessage` / `sendCustomMessage` / `sendInteractiveMessage`) | `onMessagePending(message)`, `onMessageSent(message)`, `onError(CometChatException?)` |
+| `OutboxListener` | `onOutboxMessagePending(_ message: BaseMessage)`, `onOutboxMessageSent(_ message: BaseMessage)`, `onOutboxMessageFailed(_ message: BaseMessage, _ reason: CometChat.MessageFailureReason)` |
+| `SyncListener` | `onConversationSyncStarted()`, `onConversationSyncCompleted(_ conversations: [Conversation])`, `onConversationSyncError(_ error: CometChatException)`, `onMessageSyncStarted()`, `onMessageSyncCompleted(_ messages: [BaseMessage], _ outcome: CometChat.SyncOutcome)`, `onMessageSyncError(_ error: CometChatException)` |
+| `OfflineCacheResetListener` | `onOfflineCacheReset(_ reason: CometChat.OfflineCacheResetReason, _ pendingMessagesLost: Int)` |
+| `downloadMedia` closures | `onProgress(Int)`, `onSuccess(String)`, `onError(CometChatException)` |
+| `MediaDownloadListener` | `onMediaDownloaded(_ messageId: String, _ url: String, _ localPath: String)`, `onMediaDownloadFailed(_ attachment: Attachment?, _ error: CometChatException)` |
+| `CometChatMessageDelegate` | `onMessagesSynced(_ messages: [BaseMessage])` (offline addition to the existing delegate) |
+
+All listener methods are optional (`@objc optional`).
+
+## What the cache can answer
+
+The cached phase answers a read only if **every** active filter is locally computable; one server-only filter returns an empty cached page (the server phase still answers in full). Enrichment flags never block the cached phase.
+
+| Read | Answered from cache | Server-only (empty cached page) | Enrichment (never blocks) |
+|---|---|---|---|
+| `MessagesRequest` | category/categories, type/types, parent message (threads), hide replies, hide deleted, tags, has mentions, mentioned UIDs, has attachments, attachment types, hide messages from blocked users, has reactions, unread, pinned, saved | `searchKeyword`, `hasLinks`, `updatesOnly`, `interactionGoalCompletedOnly`; a group read narrowed to one sender | `withTags`, `withParent`, `mentionsWithTagInfo`, `mentionsWithBlockedInfo`, `hideQuotedMessages` |
+| `ConversationRequest` | conversation type, unread, tags, user tags, group tags, hide/only agentic, include blocked users, with blocked info, pinned by | `searchKeyword` | `withTags`, `withUserAndGroupTags` |
+| Users, groups, group members, banned members, blocked users, reactions, notification feed | Replays the snapshot recorded online for the **same builder settings**, page by page | `searchKeyword`; presence filters (`UsersRequest.set(status:)`, `GroupMembersRequest.set(status:)`) | — |
+
+## Enums
+
+| Enum | Values |
+|---|---|
+| `CometChat.MediaCachingMode` | `.auto` (auto-download + retain received media), `.off` (skip auto-download; metadata still cached) |
+| `CometChat.PendingMessageOverflowPolicy` | `.dropOldest` (evict the oldest pending to admit a newer send), `.rejectNewest` (reject the new send) |
+| `CometChat.EncryptionFailureBehavior` | `.onlineOnly` (server-only; never plaintext), `.plaintextFallback` (open an unencrypted cache) |
+| `CometChat.MessageStatus` | `.pending`, `.sending`, `.sent`, `.delivered`, `.read`, `.failed` |
+| `CometChat.MessageSource` | `.realtime` (arrived over the socket), `.sync` (arrived via the reconnect sweep) |
+| `CometChat.MessageFailureReason` | `.transient` (retry likely helps), `.permanent` (retry won't help), `.queueOverflow` (evicted to admit a newer send), `.queueFull` (new send rejected, queue full), `.byteOverflow` (media exceeded the byte budget), `.discarded` (removed before it could send) |
+| `CometChat.SyncOutcome` | `.caughtUp` (gap replayed exactly), `.partial` (a page was ingested but more remains) |
+| `CometChat.SyncState` | `.idle`, `.syncing` |
+| `CometChat.OfflineCacheResetReason` | `.keyLost`, `.wrongKey`, `.corrupted`, `.escalated` (unclassified failure recurred and was escalated), `.encryptionChanged` (the `encryption` setting changed between launches) |
+| `UploadFileState` | `.queued`, `.uploading`, `.uploaded`, `.failed`, `.rejected` |
+
+## Model fields
+
+| Field | Type | Meaning |
+|---|---|---|
+| `BaseMessage.localMessageId` | `String?` | SDK-generated local handle for an optimistic/offline send; the stable key to reconcile the row when the server ack arrives. Read-only. `nil` for messages that never went through the outbox. |
+| `BaseMessage.messageStatus` | `CometChat.MessageStatus` | Lifecycle status of the message. Not the same as the legacy `BaseMessage.status` string. |
+| `BaseMessage.failureReason` / `getFailureReason()` | `CometChat.MessageFailureReason?` | Why a failed message last failed; `nil` otherwise. Device-only, never sent to the server. |
+| `BaseMessage.source` | `CometChat.MessageSource` | How a message reached the app. Read-only. |
+| `Attachment.localPath` | `String?` | Absolute device path to cached media bytes, or `nil` when not cached. Never sent to the server. |
+| `UploadFileItem` (from `UploadFileRequest.getItems()`) | — | `fileId`, `file` (`nil` after a restart), `name`, `size`, `mime`, `state` (`UploadFileState`), `error`, `attachment`, `localCopyPath`. |
+
+## Error codes
+
+Compare `CometChatException.errorCode` against these `CometChatConstants.Errors` constants.
+
+| Code | Raised when |
+|---|---|
+| `ERROR_INTERNET_UNAVAILABLE` | A read reached the server phase with no connection (fires after the cached page on a two-phase fetch), or a send was queued offline (the "queued, not failed" notice). |
+| `ERROR_REQUEST_IN_PROGRESS` | A two-phase fetch was called on a request whose previous fetch is still in flight. |
+| `ERROR_USER_NOT_LOGGED_IN` | `forceSyncNow(onSuccess:onError:)` was called with nobody logged in. |
+| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | `forceSyncNow(onSuccess:onError:)` was called while a sync is already running. |
+| `ERROR_OFFLINE_SYNC_COOLDOWN` | `forceSyncNow(onSuccess:onError:)` was called within the throttle cooldown. |
+| `ERROR_OFFLINE_DISABLED` | An offline-only operation was called while offline support is not active. |
+| `ERROR_OFFLINE_MESSAGE_DISCARDED` | A queued message was removed before it could send. |
+| `ERROR_OFFLINE_MESSAGE_IN_FLIGHT` | `retryMessage` / `discardLocalMessage` targeted a message that is still being sent. |
+| `ERROR_OFFLINE_MESSAGE_NOT_FOUND` | `retryMessage` / `discardLocalMessage` found no unsent message with the given local id. |
+| `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` | The local cache was briefly unavailable, so `getPendingMessages`, `retryMessage` or `discardLocalMessage` changed nothing. Try again. |
+| `ERROR_QUEUE_FULL` | A new send was refused because the outbox is full (`.rejectNewest`). |
+| `ERROR_QUEUE_OVERFLOW` | A queued message was evicted to admit a newer send (`.dropOldest`). |
+| `ERROR_OFFLINE_MEDIA_BYTE_OVERFLOW` | Outgoing media would exceed `maxMediaCacheSize`. |
+| `ERROR_OFFLINE_MEDIA_COPY_FAILED` | An outgoing media file could not be copied for offline send. |
+| `ERROR_OFFLINE_MEDIA_NOT_CACHED` | `downloadMedia`: no cached bytes and the device is offline. |
+| `ERROR_OFFLINE_MEDIA_UNRESOLVABLE` | `downloadMedia`: the cache missed, the download failed and re-signing produced nothing usable. |
+
+A background download that fails reports `onMediaDownloadFailed` with the error code string `"ERROR_MEDIA_DOWNLOAD_FAILED"`, which is not a `CometChatConstants.Errors` constant.
+
+## Legacy overloads
+
+Unlike some platforms, the classic single-callback overloads are **not** deprecated on iOS. They are unchanged and never deliver a cached or pending result; the two-phase and pending-aware overloads are additive and chosen by argument label. Only these are deprecated:
+
+| Deprecated | Replacement |
+|---|---|
+| `getConversation(conversationWith:conversationType:onCachedConversation:onServerConversation:onError:)` | `getConversation(conversationWith:conversationType:listener:)` with a `ConversationDetailsListener` |
+| `getPendingMessages() -> [BaseMessage]` | `getPendingMessages(onSuccess:onError:)` |
+| `NotificationFeedRequest.fetchNext(onCachedFeedItems:onServerFeedItems:onError:)` | `fetchNext(onCachedNotificationFeedItems:onServerNotificationFeedItems:onError:)` |
+
+| Classic call (unchanged) | Offline-aware overload |
+|---|---|
+| `MessagesRequest.fetchNext(onSuccess:onError:)` | `fetchNext(onCachedMessages:onServerMessages:onError:)` |
+| `MessagesRequest.fetchPrevious(onSuccess:onError:)` | `fetchPrevious(onCachedMessages:onServerMessages:onError:)` |
+| `ConversationRequest.fetchNext(onSuccess:onError:)` | `fetchNext(onCachedConversations:onServerConversations:onError:)` |
+| `ThreadsRequest.fetchNext(onSuccess:onError:)` | `fetchNext(onCachedThreads:onServerThreads:onError:)` |
+| `CometChat.getMessageDetails(_:onSuccess:onError:)` | `getMessageDetails(_:onCachedMessage:onServerMessage:onError:)` |
+| `CometChat.sendTextMessage(message:onSuccess:onError:)` | `sendTextMessage(message:onMessagePending:onMessageSent:onError:)` |
+| `CometChat.sendMediaMessage(message:onSuccess:onError:)` | `sendMediaMessage(message:onMessagePending:onMessageSent:onError:)` |
+| `CometChat.sendCustomMessage(message:onSuccess:onError:)` | `sendCustomMessage(message:onMessagePending:onMessageSent:onError:)` |
+| `CometChat.sendInteractiveMessage(message:onSuccess:onError:)` | `sendInteractiveMessage(message:onMessagePending:onMessageSent:onError:)` |
+
+The entity-list requests (`UsersRequest`, `GroupsRequest`, `GroupMembersRequest`, `BannedGroupMembersRequest`, `BlockedUserRequest`, `ReactionsRequest`, `NotificationFeedRequest`) follow the same pattern: keep `fetchNext(onSuccess:onError:)` or add the `onCached…` / `onServer…` overload listed above.
diff --git a/sdk/ios/offline-send.mdx b/sdk/ios/offline-send.mdx
new file mode 100644
index 000000000..a2e2e085a
--- /dev/null
+++ b/sdk/ios/offline-send.mdx
@@ -0,0 +1,271 @@
+---
+title: "Send While Offline"
+sidebarTitle: "Send While Offline"
+description: "Queue messages sent without a connection, track their status, and retry failed sends with the CometChat iOS SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```swift
+// Send with the offline-aware overload (queued when offline, auto-sent on reconnect)
+CometChat.sendTextMessage(message: textMessage, onMessagePending: { message in
+ // messageStatus == .pending, fires right after the message is durably queued
+}, onMessageSent: { message in
+ // server ack: real id + sentAt
+}, onError: { error in
+ // ERROR_INTERNET_UNAVAILABLE = queued, not failed. Any other code = failed.
+})
+
+// Read offline state off a message
+let localId = message.localMessageId // String?, stable client handle
+let status = message.messageStatus // .pending .sending .sent .delivered .read .failed
+let reason = message.getFailureReason() // CometChat.MessageFailureReason?, set only while failed
+
+// Inspect and recover the outbox
+CometChat.getPendingMessages(onSuccess: { messages in }, onError: { error in }) // pending + sending + failed
+CometChat.retryMessage(localId, onSuccess: { }, onError: { error in }) // one message
+CometChat.retryAllFailedMessages() // transient failures only
+CometChat.discardLocalMessage(localId, onSuccess: { }, onError: { error in }) // delete an unsent message
+
+// Observe every outbox transition, app-wide
+CometChat.addOutboxListener("OUTBOX_LISTENER", outboxListener) // OutboxListener
+```
+
+
+
+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 when the connection returns. 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.3.0** or newer. Queuing only happens once you [enable it](/sdk/ios/offline-enable); 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 `onMessagePending` first (the message is durably queued), then exactly one final outcome — `onMessageSent` (the server accepted it) or, for a real failure, `onError`.
+
+```swift
+let textMessage = TextMessage(receiverUid: "UID", text: "Hello!", receiverType: .user)
+
+CometChat.sendTextMessage(message: textMessage, onMessagePending: { message in
+ // Queued and persisted; message.messageStatus == .pending
+ print("Pending, local id: \(message.localMessageId ?? "")")
+}, onMessageSent: { message in
+ // Server ack: message now carries its real id and sentAt
+ print("Sent: \(message.id)")
+}, onError: { error in
+ // Tell "queued, not failed" from a real failure by the error code
+ print("Send error: \(error?.errorCode ?? "")")
+})
+```
+
+The same three closures are accepted by all four persistent send methods:
+
+```swift
+CometChat.sendTextMessage(message: textMessage, onMessagePending: { _ in }, onMessageSent: { _ in }, onError: { _ in })
+CometChat.sendMediaMessage(message: mediaMessage, onMessagePending: { _ in }, onMessageSent: { _ in }, onError: { _ in })
+CometChat.sendCustomMessage(message: customMessage, onMessagePending: { _ in }, onMessageSent: { _ in }, onError: { _ in })
+CometChat.sendInteractiveMessage(message: interactiveMessage, onMessagePending: { _ in }, onMessageSent: { _ in }, onError: { _ in })
+```
+
+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.
+
+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:
+
+| `error.errorCode` | Meaning | What to do |
+|---|---|---|
+| `ERROR_INTERNET_UNAVAILABLE` | Queued; not sent yet. Will auto-send on reconnect. | Show as sending/queued; nothing to do. |
+| Anything else | A real failure; the message will not retry on its own. | Offer the user a manual retry (see below). |
+
+```swift
+onError: { error in
+ if error?.errorCode == CometChatConstants.Errors.ERROR_INTERNET_UNAVAILABLE {
+ // Queued offline — it will send automatically on reconnect
+ } else {
+ // Real failure — surface a retry action
+ }
+}
+```
+
+
+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`](#observe-the-queue-app-wide) 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.
+
+| Property | Type | Notes |
+|---|---|---|
+| `localMessageId` | `String?` | Client handle; read-only. `nil` for messages that never went through the outbox. |
+| `messageStatus` | `CometChat.MessageStatus` | `.pending`, `.sending`, `.sent`, `.delivered`, `.read`, `.failed`. |
+| `getFailureReason()` / `failureReason` | `CometChat.MessageFailureReason?` | Set only on a failed message the SDK hands you; otherwise `nil`. |
+| `source` | `CometChat.MessageSource` | `.realtime` or `.sync`. How a received message reached the app. |
+
+
+Use `messageStatus`, not `status`. `BaseMessage.status` is a pre-existing `String` property and is not the offline status.
+
+
+`MessageFailureReason` values:
+
+| Reason | When |
+|---|---|
+| `.transient` | A temporary server error (a completed 5xx), or no answer after the auto-retry cap. Retryable. |
+| `.permanent` | The server rejected the message, or the error was not recognized. A manual retry will not help. |
+| `.queueFull` | A new send was refused because the queue is full (count cap). |
+| `.queueOverflow` | An older queued message was evicted to admit a newer one (`.dropOldest` only). |
+| `.byteOverflow` | A media send was refused because it exceeds the media byte budget. |
+| `.discarded` | The message was removed before it could send (for example a cache clear). |
+
+## 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.
+
+```swift
+CometChat.getPendingMessages(onSuccess: { messages in
+ for m in messages {
+ print("\(m.localMessageId ?? "") -> \(m.messageStatus)")
+ }
+}, onError: { error in
+ print("Could not read the outbox: \(error.errorCode)")
+})
+```
+
+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`.
+
+```swift
+final class OutboxObserver: NSObject, OutboxListener {
+
+ func onOutboxMessagePending(_ message: BaseMessage) { }
+
+ func onOutboxMessageSent(_ message: BaseMessage) { }
+
+ func onOutboxMessageFailed(_ message: BaseMessage, _ reason: CometChat.MessageFailureReason) {
+ print("Failed: \(message.localMessageId ?? "") (\(reason))")
+ }
+}
+
+// Keep a strong reference — the SDK holds listeners weakly.
+let outboxObserver = OutboxObserver()
+CometChat.addOutboxListener("OUTBOX_LISTENER", outboxObserver)
+
+// Remove it when no longer needed
+CometChat.removeOutboxListener("OUTBOX_LISTENER")
+```
+
+
+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. 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.
+
+```swift
+CometChat.retryMessage(localMessageId, onSuccess: {
+ print("Sent")
+}, onError: { error in
+ print("Retry rejected or failed: \(error.errorCode)")
+})
+
+CometChat.retryAllFailedMessages()
+
+CometChat.discardLocalMessage(localMessageId, onSuccess: {
+ print("Discarded")
+}, onError: { error in
+ print("Discard rejected: \(error.errorCode)")
+})
+```
+
+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:
+
+| Error code | Reason |
+|---|---|
+| `ERROR_OFFLINE_MESSAGE_IN_FLIGHT` | The message is already on the wire. `retryMessage` rejects `.sending` messages; `discardLocalMessage` also rejects a pending message whose request is still outstanding. |
+| `ERROR_OFFLINE_MESSAGE_NOT_FOUND` | No unsent message has that id (unknown, empty, or already sent). |
+| `ERROR_OFFLINE_DISABLED` | Offline support is not running. |
+| `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` | The local cache was briefly unavailable. Try again. |
+
+## 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](/sdk/ios/offline-enable) and the [reference](/sdk/ios/offline-reference). 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](/sdk/ios/offline-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:
+
+| `PendingMessageOverflowPolicy` | Behaviour on overflow |
+|---|---|
+| `.rejectNewest` (**default**) | The new send is refused and reported with reason `.queueFull`. Nothing already queued is lost. |
+| `.dropOldest` | The oldest pending message is evicted (reason `.queueOverflow`) to admit the new one. |
+
+A media send that exceeds the media byte budget is refused with reason `.byteOverflow`.
+
+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.
+
+| Code | Raised when |
+|---|---|
+| `ERROR_QUEUE_FULL` | A new send was refused because the outbox is full (`.rejectNewest`). |
+| `ERROR_QUEUE_OVERFLOW` | A queued message was evicted to make room (`.dropOldest`). |
+| `ERROR_OFFLINE_MEDIA_BYTE_OVERFLOW` | Outgoing media would exceed `maxMediaCacheSize`. |
+| `ERROR_OFFLINE_MEDIA_COPY_FAILED` | The media file could not be copied for offline send. The send fails permanently and immediately, with no pending stage. |
+
+
+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 single `MediaMessage` built with `files:` and the SDK copies the bytes to the device before it reports pending, so the message survives an app restart and is sent on reconnect. A multi-attachment message built from `attachments` that were uploaded earlier queues as a small text-only row, because the files are already on the server. Uploading new files to hosted storage does need a connection (see [Upload Files](/sdk/ios/upload-files)).
+
+An in-progress multi-file upload batch survives an app restart: 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 `UploadFileItem` (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.
+
+```swift
+// parentMessageId: 0 for the main composer, the thread parent's id for a thread composer.
+if let request = CometChat.getExistingUploadFileRequest(receiverId: "UID",
+ receiverType: .user,
+ parentMessageId: 0) {
+ for item in request.getItems() {
+ print("\(item.fileId) -> \(item.state)")
+ if item.state == .failed {
+ request.retryAttachment(fileId: item.fileId)
+ }
+ }
+}
+```
+
+`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` is `nil` — show a preview from `localCopyPath` instead.
+
+See [Media Caching](/sdk/ios/offline-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](/sdk/ios/offline-reference).
+
diff --git a/sdk/ios/offline-sync.mdx b/sdk/ios/offline-sync.mdx
new file mode 100644
index 000000000..7405dc4f2
--- /dev/null
+++ b/sdk/ios/offline-sync.mdx
@@ -0,0 +1,220 @@
+---
+title: "Stay in Sync"
+sidebarTitle: "Stay in Sync"
+description: "Catch up on everything that changed while offline, observe sync progress, trigger a manual sync, and manage the local cache with the CometChat iOS SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```swift
+// Observe the reconnect catch-up sync (conversation phase, then message phase)
+final class SyncObserver: NSObject, SyncListener {
+ func onConversationSyncCompleted(_ conversations: [Conversation]) { /* blind-replace list */ }
+ func onMessageSyncCompleted(_ messages: [BaseMessage], _ outcome: CometChat.SyncOutcome) { }
+ func onMessageSyncError(_ error: CometChatException) { }
+}
+let syncObserver = SyncObserver() // keep a strong reference
+CometChat.addSyncListener("SYNC_LISTENER", syncObserver)
+CometChat.removeSyncListener("SYNC_LISTENER")
+
+// Poll sync state
+let state = CometChat.getSyncState() // .idle | .syncing
+let lastSync = CometChat.getLastSyncTimestamp() // Int, server epoch seconds, 0 if never
+
+// Trigger a manual catch-up (with an acceptance callback)
+CometChat.forceSyncNow(onSuccess: { /* accepted, started */ },
+ onError: { error in /* IN_PROGRESS / COOLDOWN / DISABLED / NOT_LOGGED_IN */ })
+
+// Observe cache rebuilds
+CometChat.addOfflineCacheResetListener("RESET_LISTENER", resetObserver) // OfflineCacheResetListener
+
+// Inspect / clear the cache
+let bytes = CometChat.getCacheSize() // Int64, call off the main thread
+CometChat.clearOfflineCache() // destructive; fails the un-sent outbox
+```
+
+
+
+When the device comes back online, the SDK runs a **catch-up sync**: it refreshes the conversation list, then replays everything that changed in your messages since the last sync, and merges the results into the local cache. This page covers how to observe that sync, trigger one on demand, and manage the cache it maintains.
+
+
+Everything on this page is a no-op unless offline support is active for the logged-in user (`CometChat.isOfflineEnabled()` is `true`). See [Enable Offline Support](/sdk/ios/offline-enable).
+
+
+## How catch-up works
+
+A sync runs automatically when the connection settles after a reconnect. It never runs on a brief connect-then-drop flap, and overlapping triggers coalesce into a single sweep. It proceeds in two phases, in order:
+
+1. **Conversation phase** — the conversation list is fetched as one fresh page. A conversation-list UI can blind-replace its list with the result.
+2. **Message phase** — the SDK replays the message changelog from the last sync point. New messages and updates to already-cached messages are merged into the cache.
+
+The sync is incremental and safe to repeat: it only moves forward from the last successful point, it never deletes cached messages, and a failed or interrupted phase simply resumes on the next reconnect. A first-ever sync (cold start) seeds its starting point from the newest conversation and fetches no message history — older history is reached by scrolling online. A cold start fires the conversation-phase callbacks only.
+
+## Listen for sync progress
+
+Register a `SyncListener` to observe the lifecycle of every automatic and manual sync. Each phase fires `started`, then exactly one of `completed` / `error`, all on the main thread. Every method is optional — implement only the ones you need.
+
+```swift
+final class SyncObserver: NSObject, SyncListener {
+
+ func onConversationSyncStarted() { }
+
+ func onConversationSyncCompleted(_ conversations: [Conversation]) {
+ // The full, fresh conversation page. Blind-replace your list.
+ }
+
+ func onConversationSyncError(_ error: CometChatException) { }
+
+ func onMessageSyncStarted() { }
+
+ func onMessageSyncCompleted(_ messages: [BaseMessage], _ outcome: CometChat.SyncOutcome) {
+ // New + changed messages reconciled this sync. Merge by message id.
+ }
+
+ func onMessageSyncError(_ error: CometChatException) { }
+}
+
+// Keep a strong reference — the SDK holds listeners weakly.
+let syncObserver = SyncObserver()
+CometChat.addSyncListener("SYNC_LISTENER", syncObserver)
+```
+
+| Callback | When it fires |
+|---|---|
+| `onConversationSyncStarted()` | The conversation refresh began. |
+| `onConversationSyncCompleted(_ conversations:)` | The refresh succeeded; `conversations` is the full conversation page. |
+| `onConversationSyncError(_ error:)` | A transport or parse failure; the cache is left untouched and the refresh retries on the next reconnect. |
+| `onMessageSyncStarted()` | The message changelog sweep began. Never fires on a cold start. |
+| `onMessageSyncCompleted(_ messages:, _ outcome:)` | The sweep finished; `messages` carries the new and changed messages. |
+| `onMessageSyncError(_ error:)` | The sweep failed; it resumes on the next reconnect. |
+
+The two phases are independent — a conversation-phase error does not stop the message phase. A phase error is informational: the cache is never left in a wrong state and the sweep resumes automatically.
+
+The `SyncListener` is self-sufficient: `onMessageSyncCompleted` carries the swept messages, including `action` and `call` messages that the message delegate does not deliver, so you can use it without a `CometChatMessageDelegate`.
+
+`onMessageSyncCompleted` reports the outcome:
+
+| `CometChat.SyncOutcome` | Meaning |
+|---|---|
+| `.caughtUp` | The changelog was fully replayed; the cache reflects every server change up to the sync point. |
+| `.partial` | A page was ingested and the sync point advanced, but a backlog remains; it drains one page per reconnect. |
+
+Remove the listener with `CometChat.removeSyncListener("SYNC_LISTENER")`.
+
+
+Registering a listener under an ID that is already in use replaces the previous listener, and removing an ID removes whatever is registered under it. Use a distinct ID for each listener.
+
+
+## Receiving synced messages
+
+If you already use a `CometChatMessageDelegate`, synced messages still arrive there, so you usually do not need a `SyncListener` to keep your chat up to date:
+
+- **Genuinely new** messages arrive one-by-one on the normal received callbacks (`onTextMessageReceived`, `onMediaMessageReceived`, and so on), tagged with `source == .sync`.
+- **Updates** to already-cached messages arrive together on `onMessagesSynced(_:)`.
+
+```swift
+extension ViewController: CometChatMessageDelegate {
+
+ func onTextMessageReceived(textMessage: TextMessage) {
+ if textMessage.source == .sync {
+ // Delivered by a catch-up sync. Suppress notification spam if you wish.
+ }
+ }
+
+ func onMessagesSynced(_ messages: [BaseMessage]) {
+ // Updates to already-cached messages. Merge by id.
+ }
+}
+```
+
+Register the delegate as usual, with `CometChat.messagedelegate = self` or `CometChat.addMessageListener("MSG_LISTENER", self)`. The per-message callbacks fire before the bulk `onMessagesSynced`.
+
+## Check sync state
+
+Poll the current sync state and the time of the last completed sync at any time.
+
+```swift
+let state = CometChat.getSyncState() // .idle or .syncing
+let lastSync = CometChat.getLastSyncTimestamp() // Int, server epoch seconds; 0 if never synced
+```
+
+`getSyncState()` returns `.syncing` while a sweep is in flight, otherwise `.idle` (also when offline support is disabled). `getLastSyncTimestamp()` returns a **server-provided** epoch value in seconds, never the device clock, and `0` if the cache has never synced.
+
+## Trigger a manual sync
+
+Call `forceSyncNow` to run a catch-up immediately — for example behind a pull-to-refresh. It routes through the same throttle as the automatic sync, so it will not start a second overlapping sweep or hammer the backend. A manual sync refreshes the cache only; it does not flush the outbox.
+
+Use the callback overload to learn whether the request was accepted. `onSuccess` fires as soon as the sync is accepted and starts — not when it finishes; the sync's actual progress and outcome still arrive on your registered `SyncListener`.
+
+```swift
+CometChat.forceSyncNow(onSuccess: {
+ // Accepted and started.
+}, onError: { error in
+ // Rejected — see the code.
+ print("Sync not started: \(error.errorCode)")
+})
+```
+
+If you do not need the acceptance result, call the no-argument `CometChat.forceSyncNow()`; a rejected request is silently dropped.
+
+| Rejection code (`CometChatConstants.Errors`) | Meaning |
+|---|---|
+| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | A sync (automatic or manual) is already running. |
+| `ERROR_OFFLINE_SYNC_COOLDOWN` | A sync ran too recently; the error description carries the remaining cooldown in milliseconds. |
+| `ERROR_OFFLINE_DISABLED` | A user is logged in but offline support is not active. |
+| `ERROR_USER_NOT_LOGGED_IN` | Nobody is logged in. |
+
+## When the cache resets
+
+In rare cases the SDK cannot keep the existing cache — for example the encryption key became unrecoverable, or the database file is damaged. When this happens it rebuilds the cache from scratch and notifies an `OfflineCacheResetListener`. Any un-sent messages that were queued are lost in the rebuild; `pendingMessagesLost` reports how many, or `CometChat.offlinePendingCountUnknown` (`-1`) when they could not be counted. A logout never fires this listener.
+
+```swift
+final class CacheResetObserver: NSObject, OfflineCacheResetListener {
+
+ func onOfflineCacheReset(_ reason: CometChat.OfflineCacheResetReason, _ pendingMessagesLost: Int) {
+ // The cache was rebuilt. Re-fetch anything you need; it will re-sync from the server.
+ if pendingMessagesLost == CometChat.offlinePendingCountUnknown {
+ print("Cache reset (\(reason)); lost an unknown number of pending messages")
+ } else {
+ print("Cache reset (\(reason)); lost \(pendingMessagesLost) pending messages")
+ }
+ }
+}
+
+let resetObserver = CacheResetObserver() // keep a strong reference
+CometChat.addOfflineCacheResetListener("RESET_LISTENER", resetObserver)
+```
+
+| `CometChat.OfflineCacheResetReason` | Cause |
+|---|---|
+| `.keyLost` | The cache's key was gone from the Keychain; a fresh key was minted. |
+| `.wrongKey` | The stored key did not open the cache. |
+| `.corrupted` | The cache file is damaged. |
+| `.escalated` | The cache failed to open on three launches in a row for a reason the SDK could not classify. |
+| `.encryptionChanged` | The `encryption` setting changed, so the on-disk cache was rebuilt in the configured format. |
+
+Remove the listener with `CometChat.removeOfflineCacheResetListener("RESET_LISTENER")`. See [Encrypt the Cache](/sdk/ios/offline-encryption) for the encryption-related reasons.
+
+## Manage the cache
+
+Read the current cache size, or clear the cache entirely.
+
+```swift
+DispatchQueue.global().async {
+ let bytes = CometChat.getCacheSize() // database + media, in bytes
+ print("Offline cache: \(bytes) bytes")
+}
+
+CometChat.clearOfflineCache() // empty the entire per-user cache
+```
+
+`getCacheSize()` returns the total on-disk size of the user's cache (the local database files plus the media files) as an `Int64`, or `0` when offline support is disabled or nobody is logged in. It is a **physical** size, so it can read higher than the logical `maxStorageSize` budget. It walks the files on the calling thread, so call it off the main thread.
+
+
+`clearOfflineCache()` is destructive and irreversible. It empties the entire per-user cache — cached messages, conversations, media bytes **and the un-sent outbox**. Each still-queued pending or sending message fails with reason `.discarded` (`ERROR_OFFLINE_MESSAGE_DISCARDED`). A send already on the wire that the server accepts is still reported sent. The next sync is a cold start. It is a no-op unless offline support is active.
+
+
+
+You never need to clear the cache on logout — the SDK does it for you. The cache is stored **per user**: it is kept across re-login as the same user, and wiped automatically on logout, token expiry, or when a different user logs in. See [Offline Support](/sdk/ios/offline-overview).
+
diff --git a/sdk/javascript/offline-cached-data.mdx b/sdk/javascript/offline-cached-data.mdx
new file mode 100644
index 000000000..8f5524212
--- /dev/null
+++ b/sdk/javascript/offline-cached-data.mdx
@@ -0,0 +1,303 @@
+---
+title: "Show Cached Data"
+sidebarTitle: "Show Cached Data"
+description: "Render cached messages, conversations, and lists instantly and reconcile them against the server with the CometChat JavaScript SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```javascript
+// Pass a fetch listener to get up to two phases: cached first (instant), then server.
+const request = new CometChat.MessagesRequestBuilder().setUID("UID").setLimit(30).build();
+
+request
+ .fetchPrevious(
+ new CometChat.MessageFetchListener({
+ onCachedMessages: (messages) => { /* Phase 1: local cache. Render immediately. */ },
+ onServerMessages: (messages) => { /* Phase 2: server. Reconcile, don't append. Never fires offline. */ },
+ onError: (error) => { /* Offline ends here, after the cached page, with FAILED_TO_FETCH. */ },
+ })
+ )
+ .catch(() => {}); // the returned Promise also rejects offline — handle it
+
+// Single items (additive two-phase overloads)
+CometChat.getConversation("UID", "user", new CometChat.ConversationDetailsListener({ /* … */ }));
+CometChat.getMessageDetails(messageId, new CometChat.SingleMessageFetchListener({ /* … */ }));
+```
+
+
+
+With offline support enabled, every list and lookup the SDK already offers can answer from the on-device cache first and then from the server. Your UI paints instantly from the cache, then reconciles when the server replies. This page describes that contract, the reads it applies to, and how to merge the two phases.
+
+## The two-phase contract
+
+Pass a fetch listener as the optional argument and each read fires **up to two** callbacks, always in this order:
+
+
+
+The SDK delivers a page from the local cache **first**. It fires **even when the page is empty** — an empty cached page is indistinguishable from a cache miss, by design. Render it right away.
+
+
+After a successful server response, the SDK delivers the server's copy for the **same slice** and writes it through to the cache before the callback. Reconcile your displayed list against it.
+
+
+
+Three rules follow from this:
+
+- **Offline, the read ends with an error.** After the cached page, a read whose request gets no answer settles with `onError` carrying code `FAILED_TO_FETCH`. The call always settles — it never hangs.
+- **The server phase never fires offline.** It fires only on a genuine server response. Never treat a cached page as "end of history"; an empty cached page only means the end of the *cached* window.
+- **The Promise still settles too.** Passing a listener does not replace the returned Promise: it settles on the server outcome, so it also **rejects** offline. If you use only the listener, add a `.catch()` to avoid an unhandled rejection. Consume the result from one channel, not both.
+
+
+With offline support **off**, these listeners behave like the classic Promise: the cached phase never fires, and the server result arrives in `onServer…`. Nothing changes until you enable offline (see [Enable Offline Support](/sdk/javascript/offline-enable)). Requires SDK **4.3.0** or later.
+
+
+## Read messages
+
+`MessagesRequest` supports both directions. `fetchPrevious` pages toward older messages; `fetchNext` pages toward newer ones. Both take a `MessageFetchListener`.
+
+
+
+```typescript
+const request: CometChat.MessagesRequest = new CometChat.MessagesRequestBuilder()
+ .setUID("UID")
+ .setLimit(30)
+ .build();
+
+request
+ .fetchPrevious(
+ new CometChat.MessageFetchListener({
+ onCachedMessages: (messages: CometChat.BaseMessage[]) => {
+ // Render the cached page immediately.
+ },
+ onServerMessages: (messages: CometChat.BaseMessage[]) => {
+ // Reconcile this slice against the cached page, keyed by message id.
+ },
+ onError: (error: CometChat.CometChatException) => {
+ console.log("Fetch failed:", error.code);
+ },
+ })
+ )
+ .catch(() => {});
+```
+
+
+```javascript
+const request = new CometChat.MessagesRequestBuilder()
+ .setUID("UID")
+ .setLimit(30)
+ .build();
+
+request
+ .fetchPrevious(
+ new CometChat.MessageFetchListener({
+ onCachedMessages: (messages) => {
+ // Render the cached page immediately.
+ },
+ onServerMessages: (messages) => {
+ // Reconcile this slice against the cached page, keyed by message id.
+ },
+ onError: (error) => {
+ console.log("Fetch failed:", error.code);
+ },
+ })
+ )
+ .catch(() => {});
+```
+
+
+
+## Read conversations
+
+`ConversationsRequest` pages with `fetchNext`, taking a `ConversationFetchListener`.
+
+
+
+```typescript
+const request: CometChat.ConversationsRequest = new CometChat.ConversationsRequestBuilder()
+ .setLimit(30)
+ .build();
+
+request
+ .fetchNext(
+ new CometChat.ConversationFetchListener({
+ onCachedConversations: (conversations: CometChat.Conversation[]) => {
+ // Render the cached list immediately.
+ },
+ onServerConversations: (conversations: CometChat.Conversation[]) => {
+ // Reconcile, keyed by conversation id.
+ },
+ onError: (error: CometChat.CometChatException) => {
+ console.log("Fetch failed:", error.code);
+ },
+ })
+ )
+ .catch(() => {});
+```
+
+
+```javascript
+const request = new CometChat.ConversationsRequestBuilder()
+ .setLimit(30)
+ .build();
+
+request
+ .fetchNext(
+ new CometChat.ConversationFetchListener({
+ onCachedConversations: (conversations) => {
+ // Render the cached list immediately.
+ },
+ onServerConversations: (conversations) => {
+ // Reconcile, keyed by conversation id.
+ },
+ onError: (error) => {
+ console.log("Fetch failed:", error.code);
+ },
+ })
+ )
+ .catch(() => {});
+```
+
+
+
+## Read the other lists
+
+Every remaining list read accepts the same two-phase listener. Each listener is constructed from an object with cached/server callbacks of the matching type plus `onError`.
+
+| Request | Fetch method(s) | Listener | Cached / server callback |
+|---|---|---|---|
+| `MessagesRequest` | `fetchPrevious`, `fetchNext` | `MessageFetchListener` | `onCachedMessages` / `onServerMessages` |
+| `ConversationsRequest` | `fetchNext` | `ConversationFetchListener` | `onCachedConversations` / `onServerConversations` |
+| `ThreadsRequest` | `fetchNext` | `ThreadFetchListener` | `onCachedThreads` / `onServerThreads` |
+| `UsersRequest` | `fetchNext` | `UserFetchListener` | `onCachedUsers` / `onServerUsers` |
+| `BlockedUsersRequest` | `fetchNext` | `UserFetchListener` | `onCachedUsers` / `onServerUsers` |
+| `GroupsRequest` | `fetchNext` | `GroupFetchListener` | `onCachedGroups` / `onServerGroups` |
+| `GroupMembersRequest` | `fetchNext` | `GroupMemberFetchListener` | `onCachedGroupMembers` / `onServerGroupMembers` |
+| `BannedMembersRequest` | `fetchNext` | `BannedMemberFetchListener` | `onCachedBannedGroupMembers` / `onServerBannedGroupMembers` |
+| `ReactionsRequest` | `fetchPrevious`, `fetchNext` | `ReactionFetchListener` | `onCachedReactions` / `onServerReactions` |
+| `NotificationFeedRequest` | `fetchNext` | `NotificationFeedFetchListener` | `onCachedNotificationFeedItems` / `onServerNotificationFeedItems` |
+
+The call shape is identical to the conversations example above — swap the request, the listener, and the callback names.
+
+
+
+```typescript
+const request: CometChat.UsersRequest = new CometChat.UsersRequestBuilder()
+ .setLimit(30)
+ .build();
+
+request
+ .fetchNext(
+ new CometChat.UserFetchListener({
+ onCachedUsers: (users: CometChat.User[]) => { /* render */ },
+ onServerUsers: (users: CometChat.User[]) => { /* reconcile, keyed by uid */ },
+ onError: (error: CometChat.CometChatException) => console.log(error.code),
+ })
+ )
+ .catch(() => {});
+```
+
+
+```javascript
+const request = new CometChat.UsersRequestBuilder()
+ .setLimit(30)
+ .build();
+
+request
+ .fetchNext(
+ new CometChat.UserFetchListener({
+ onCachedUsers: (users) => { /* render */ },
+ onServerUsers: (users) => { /* reconcile, keyed by uid */ },
+ onError: (error) => console.log(error.code),
+ })
+ )
+ .catch(() => {});
+```
+
+
+
+
+For `ReactionsRequest`, `fetchPrevious` and `fetchNext` are cached independently — each direction keeps its own cached pages. Users, groups, members, banned members, reactions, and the notification feed are served from a recorded copy of each request (up to `maxSnapshotQueries` distinct requests, each kept for `snapshotTtlDays`), so a list you never opened online is empty offline. See the [Configuration & API Reference](/sdk/javascript/offline-reference).
+
+
+## Read a single item
+
+`getConversation` and `getMessageDetails` each accept a two-phase listener as a final argument. Unlike the list reads, the cached callback fires **only when the item is cached** — never with an empty or null object. A cache miss goes straight to the server phase online, or straight to `onError` offline.
+
+
+
+```typescript
+CometChat.getConversation(
+ "UID",
+ CometChat.RECEIVER_TYPE.USER,
+ new CometChat.ConversationDetailsListener({
+ onCachedConversation: (conversation: CometChat.Conversation) => {
+ // Fires only on a cache hit.
+ },
+ onServerConversation: (conversation: CometChat.Conversation) => {
+ // Server copy, written through before delivery.
+ },
+ onError: (error: CometChat.CometChatException) => console.log("Lookup failed:", error.code),
+ })
+).catch(() => {});
+
+CometChat.getMessageDetails(
+ messageId,
+ new CometChat.SingleMessageFetchListener({
+ onCachedMessage: (message: CometChat.BaseMessage) => { /* cache hit only */ },
+ onServerMessage: (message: CometChat.BaseMessage) => { /* server copy */ },
+ onError: (error: CometChat.CometChatException) => console.log(error.code),
+ })
+).catch(() => {});
+```
+
+
+```javascript
+CometChat.getConversation(
+ "UID",
+ CometChat.RECEIVER_TYPE.USER,
+ new CometChat.ConversationDetailsListener({
+ onCachedConversation: (conversation) => {
+ // Fires only on a cache hit.
+ },
+ onServerConversation: (conversation) => {
+ // Server copy, written through before delivery.
+ },
+ onError: (error) => console.log("Lookup failed:", error.code),
+ })
+).catch(() => {});
+
+CometChat.getMessageDetails(
+ messageId,
+ new CometChat.SingleMessageFetchListener({
+ onCachedMessage: (message) => { /* cache hit only */ },
+ onServerMessage: (message) => { /* server copy */ },
+ onError: (error) => console.log(error.code),
+ })
+).catch(() => {});
+```
+
+
+
+
+`CometChat.SingleConversationFetchListener` is a deprecated alias of `ConversationDetailsListener`.
+
+
+## Merge the two phases
+
+The server phase **replaces the same slice** — it is a reconcile, not a second batch. Render the cached page straight away, then on the server phase diff it into your displayed list keyed by the item's stable id (message id, conversation id, uid, guid). Do not append the server page to the cached one.
+
+
+Key your list by id so the two phases merge cleanly: update items that changed, add new ones, and drop cached items the server slice omits. To page further back, issue the next `fetchPrevious()` / `fetchNext()` rather than treating the server phase as more pages.
+
+
+## When the cached page is empty
+
+The cache answers a read **only if every filter on it can be computed locally**. A single server-only filter makes the whole cached phase come back empty (or not fire at all), with no error and no warning, while the server phase still answers in full. The guiding rule is *better empty than wrong*.
+
+The most common server-only filter is `setSearchKeyword`, which blanks the cached phase on both messages and conversations. On `MessagesRequest`, `hasLinks`, `updatesOnly` and `interactionGoalCompletedOnly` are server-only too.
+
+
+An offline-critical screen must use only cache-supported filters — one innocuous builder flag can silently disable the cached phase for a whole screen. The full per-builder matrix is in the [Configuration & API Reference](/sdk/javascript/offline-reference#cache-supported-filters).
+
diff --git a/sdk/javascript/offline-enable.mdx b/sdk/javascript/offline-enable.mdx
new file mode 100644
index 000000000..32857cbf0
--- /dev/null
+++ b/sdk/javascript/offline-enable.mdx
@@ -0,0 +1,159 @@
+---
+title: "Enable Offline Support"
+sidebarTitle: "Enable Offline Support"
+description: "Turn on local caching and the durable send outbox, and verify offline support is active with the CometChat JavaScript SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```javascript
+// 1. Attach an offline config to AppSettings (off by default — enabled must be true).
+const appSetting = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig({ enabled: true }) // explicit config; its `enabled` wins
+ // .offline(true) // shorthand: enable with all defaults
+ .build();
+
+// 2. Pass the settings to init.
+await CometChat.init("APP_ID", appSetting);
+
+// 3. Verify it is active for the logged-in user.
+const active = CometChat.isOfflineEnabled(); // boolean
+```
+
+
+
+Offline support is **off by default**. You turn it on once, at initialization, by passing an offline config to the `AppSettingsBuilder`. Until you do, the SDK behaves exactly as before. This page covers the minimal setup; for every tunable option see the [Configuration & API Reference](/sdk/javascript/offline-reference).
+
+
+
+
+Offline support requires CometChat JavaScript SDK **4.3.0** or later.
+
+```bash
+npm install @cometchat/chat-sdk-javascript@4.3.0
+```
+
+If you are new to the SDK, complete the [standard setup](/sdk/javascript/setup-sdk) first; the steps below only add offline configuration to your existing `init`.
+
+
+
+Call `setOfflineConfig(...)` on the `AppSettingsBuilder` with an object whose `enabled` is `true`. Every other key has a sensible default, so set only what you need. To enable with all defaults, use the `offline(true)` shorthand instead.
+
+
+
+```typescript
+const appSetting: CometChat.AppSettings = new CometChat.AppSettingsBuilder()
+ .subscribePresenceForAllUsers()
+ .setRegion("REGION")
+ .autoEstablishSocketConnection(true)
+ .setOfflineConfig({ enabled: true }) // explicit config
+ // .offline(true) // shorthand: enable with defaults
+ .build();
+```
+
+
+```javascript
+const appSetting = new CometChat.AppSettingsBuilder()
+ .subscribePresenceForAllUsers()
+ .setRegion("REGION")
+ .autoEstablishSocketConnection(true)
+ .setOfflineConfig({ enabled: true }) // explicit config
+ // .offline(true) // shorthand: enable with defaults
+ .build();
+```
+
+
+
+
+Offline turns on only for an explicit `enabled: true`. A config object without `enabled` leaves it off. If you call both `setOfflineConfig(...)` and `offline(...)`, the explicit config wins.
+
+
+
+
+Pass the settings to `CometChat.init(...)` as you do today. No other call changes.
+
+
+
+```typescript
+CometChat.init("APP_ID", appSetting).then(
+ (initialized: boolean) => {
+ console.log("Initialization completed successfully", initialized);
+ },
+ (error: CometChat.CometChatException) => {
+ console.log("Initialization failed with error:", error);
+ }
+);
+```
+
+
+```javascript
+CometChat.init("APP_ID", appSetting).then(
+ () => {
+ console.log("Initialization completed successfully");
+ },
+ (error) => {
+ console.log("Initialization failed with error:", error);
+ }
+);
+```
+
+
+
+
+
+Call `CometChat.isOfflineEnabled()`. It returns `true` only while offline support is actually running for the logged-in user — so it is `false` before anyone logs in, after logout, while the server has switched offline off for your app, and while the local store is still opening, even when you enabled it in `AppSettings`. Read it at the moment of each decision and keep no copy of the result.
+
+
+
+```typescript
+const active: boolean = CometChat.isOfflineEnabled();
+console.log("Offline support active:", active);
+```
+
+
+```javascript
+const active = CometChat.isOfflineEnabled();
+console.log("Offline support active:", active);
+```
+
+
+
+
+
+
+## Enable from a settings object
+
+If you initialize with `CometChat.initFromSettings(settings)`, add an `offline` key under `chatSDK` in the settings object you pass. It accepts a boolean shorthand or a full configuration object, with the same rules as the code config.
+
+```json settings
+{
+ "appId": "APP_ID",
+ "region": "REGION",
+ "chatSDK": {
+ "offline": true
+ }
+}
+```
+
+## Supported runtimes
+
+| Runtime | Data store | Media bytes |
+|---|---|---|
+| Browser | IndexedDB | OPFS. Where OPFS is missing, media is not cached. |
+| Electron | SQLite in the main process | Files on disk |
+
+Where IndexedDB is missing or blocked (for example some private-browsing modes), offline stays off for the session and the SDK works online as before.
+
+
+**Electron setup.** Install the optional peer dependencies `electron` (20 or later) and `better-sqlite3-multiple-ciphers` (9 or later), call `registerOfflineIpc()` from `@cometchat/chat-sdk-javascript/electron/main` in the main process, and `installCometChatBridge()` from `@cometchat/chat-sdk-javascript/electron/preload` in your preload script (with `contextIsolation: true`). The main process and the renderer must use the same package version. Browser apps need nothing extra.
+
+
+## Server switch
+
+CometChat can switch offline support off for your app from the server. The server can turn it off, never on: offline runs only when your config enables it **and** the server switch is on. When the switch is off, `isOfflineEnabled()` is `false`.
+
+## Next steps
+
+Offline support is now active for the logged-in user. Continue with [Show Cached Data](/sdk/javascript/offline-cached-data) to adopt the fetch listeners, and [Send While Offline](/sdk/javascript/offline-send) for the durable outbox. Tune caps, TTLs, media, and encryption from the [Configuration & API Reference](/sdk/javascript/offline-reference).
diff --git a/sdk/javascript/offline-encryption.mdx b/sdk/javascript/offline-encryption.mdx
new file mode 100644
index 000000000..1d045b73b
--- /dev/null
+++ b/sdk/javascript/offline-encryption.mdx
@@ -0,0 +1,191 @@
+---
+title: "Encrypt the Cache"
+sidebarTitle: "Encrypt the Cache"
+description: "Encrypt the offline message cache at rest, choose what happens when encryption cannot run, and inspect the live encryption status with the CometChat JavaScript SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```javascript
+// 1. Turn encryption on in the offline config (off by default). No extra dependency in a browser.
+const appSetting = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig({
+ enabled: true,
+ encryption: true, // encrypt the cache at rest
+ encryptionFailureBehavior: CometChat.EncryptionFailureBehavior.ONLINE_ONLY, // default; never writes plaintext
+ })
+ .build();
+
+// 2. Inspect what encryption is actually doing
+const status = CometChat.getOfflineEncryptionStatus();
+status.enabled; // what was configured
+status.atRest; // "FIELD_AES_GCM" (browser), "SQLCIPHER" (Electron) or "NONE"
+status.keyStorage; // "SOFTWARE" (browser), "KEYSTORE" (Electron) or "NONE"
+status.cipherAvailable; // can this runtime encrypt?
+status.cipher; // cipher description once proven, else null
+status.leakage; // what is never covered
+```
+
+
+
+Offline support keeps the logged-in user's messages in a local store on the device. Turn encryption on to store that content **encrypted at rest**. Encryption is **opt-in** and off by default; nothing changes until you set `encryption: true`.
+
+
+This is not end-to-end encryption — the server still sees plaintext. It protects the on-device cache only. Cached **media files** are never encrypted, and in a browser some metadata stays readable so the cache can be queried. See [What is and isn't encrypted](#what-is-and-isn-t-encrypted). Requires SDK **4.3.0** or later.
+
+
+## Requirements
+
+| Runtime | Cipher | What you need |
+|---|---|---|
+| Browser | AES-256-GCM over stored message and list content | A **secure context** (HTTPS or `localhost`) so `crypto.subtle` exists. Nothing to install. |
+| Electron | SQLCipher for the whole database | `better-sqlite3-multiple-ciphers` (9 or later) installed in the main process, and an OS keyring where `safeStorage` needs one (for example Linux). |
+
+
+Without a working cipher the cache **cannot run encrypted**. What happens then is decided by [`encryptionFailureBehavior`](#choose-what-happens-when-encryption-cannot-run) — by default the session runs server-only (no local cache). The SDK never creates a cache that is labelled encrypted but is not.
+
+
+## Turn on encryption
+
+Set `encryption: true` in the offline config you pass to `setOfflineConfig` (see [Enable Offline Support](/sdk/javascript/offline-enable)).
+
+
+
+```typescript
+const appSetting: CometChat.AppSettings = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig({
+ enabled: true,
+ encryption: true,
+ encryptionFailureBehavior: CometChat.EncryptionFailureBehavior.ONLINE_ONLY,
+ })
+ .build();
+```
+
+
+```javascript
+const appSetting = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig({
+ enabled: true,
+ encryption: true,
+ encryptionFailureBehavior: CometChat.EncryptionFailureBehavior.ONLINE_ONLY,
+ })
+ .build();
+```
+
+
+
+Switching `encryption` on or off between launches rebuilds the cache in the new format and reports `ENCRYPTION_CHANGED` to the [cache-reset listener](/sdk/javascript/offline-sync#when-the-cache-resets); unsent messages are lost in that rebuild.
+
+## Choose what happens when encryption cannot run
+
+`encryptionFailureBehavior` decides what the cache does when encryption **cannot be applied at all** on this runtime — for example an insecure origin in a browser (no `crypto.subtle`), or a plain `better-sqlite3` instead of the cipher build in Electron. It governs only that case: a transient failure still retries, and a lost or wrong key self-heals by wiping and rebuilding the cache **encrypted**.
+
+| Value | Behavior | Writes plaintext? |
+|---|---|---|
+| `ONLINE_ONLY` *(default)* | Do not open a local cache at all — the session runs server-only. Makes a setup mistake visible: offline simply does not work until it is fixed. | No |
+| `PLAINTEXT_FALLBACK` | Open an **unencrypted** cache so offline keeps working, and log the downgrade at error level. `getOfflineEncryptionStatus()` reports `atRest: "NONE"`. | Yes |
+
+
+`PLAINTEXT_FALLBACK` means cached message content may be stored **unencrypted** whenever encryption cannot run. This availability-over-guarantee trade is wrong for compliance use — keep the default `ONLINE_ONLY` if cached content must never be written in the clear. Any value other than `PLAINTEXT_FALLBACK` is treated as `ONLINE_ONLY`.
+
+
+## Key storage
+
+You do not supply or manage the encryption key. In a browser, the SDK generates a per-user data key, wraps it with a **non-extractable** WebCrypto key, and keeps the wrapped key in the same IndexedDB as the data — so a browser clearing site data can never leave ciphertext without its key. In Electron the key is sealed by the OS through `safeStorage`. The key is never returned by any API. If it is lost or no longer opens the cache, the cache resets and rebuilds — see [Cache resets](#cache-resets-on-key-loss).
+
+
+In a browser the key lives in software next to the data (`keyStorage: "SOFTWARE"`). That protects against casual disk inspection and partial data copies, but not against code running in your page's origin.
+
+
+## Check encryption status
+
+`CometChat.getOfflineEncryptionStatus()` returns an object describing what encryption is **actually** doing — never what was merely requested. It is synchronous, safe to call at any time (even before login or with offline disabled), and never returns the key or any part of it.
+
+
+
+```typescript
+const status = CometChat.getOfflineEncryptionStatus();
+console.log(
+ "enabled=" + status.enabled,
+ "atRest=" + status.atRest,
+ "keyStorage=" + status.keyStorage,
+ "cipherAvailable=" + status.cipherAvailable,
+ "cipher=" + status.cipher
+);
+```
+
+
+```javascript
+const status = CometChat.getOfflineEncryptionStatus();
+console.log(
+ "enabled=" + status.enabled,
+ "atRest=" + status.atRest,
+ "keyStorage=" + status.keyStorage,
+ "cipherAvailable=" + status.cipherAvailable,
+ "cipher=" + status.cipher
+);
+```
+
+
+
+| Field | Type | Meaning |
+|---|---|---|
+| `enabled` | `boolean` | What was **configured** (`encryption`), not what was achieved. |
+| `atRest` | `string` | Coverage achieved: `"FIELD_AES_GCM"` (browser), `"SQLCIPHER"` (Electron), otherwise `"NONE"`. |
+| `keyStorage` | `string` | `"SOFTWARE"` (browser), `"KEYSTORE"` (Electron, OS-sealed), or `"NONE"` when this session holds no key. Read `NONE` as "no key in use", not "no key exists". |
+| `cipherAvailable` | `boolean` | Whether the cipher engine is present in this runtime. |
+| `cipher` | `string \| null` | A description of the cipher once proven, else `null`. |
+| `leakage` | `string` | What encryption at rest never covers. |
+
+
+The most useful support signal is `enabled === true` with `cipherAvailable === false`: encryption was switched on but could not run. In a browser that almost always means an insecure origin; in Electron, a missing `better-sqlite3-multiple-ciphers` cipher build. Check `atRest` to see whether a cache was opened at all.
+
+
+## What is and isn't encrypted
+
+| Data | Encrypted at rest? |
+|---|---|
+| Message and list content, conversations, the pending-send outbox | Yes |
+| Browser only: message ids, timestamps, send state, message type and category, parent and quoted message ids, source, unread counts, conversation type, sync watermarks, row counts, pending-upload file sizes, MIME types and states | **No** — kept readable so the cache can be queried. `leakage` lists them exactly. |
+| Cached media files (images, video, audio, documents) | **No** — stored as ordinary files outside the database |
+
+On Electron the whole database is encrypted with SQLCipher; only the media files stay outside it.
+
+## Cache resets on key loss
+
+If the key can no longer decrypt the stored cache, the SDK wipes the local cache and rebuilds it **encrypted** — it never silently falls back to plaintext for a cache that could be encrypted. Any unsent messages still queued in the outbox at that point are lost and each is failed with `DISCARDED`.
+
+Register an `OfflineCacheResetListener` to be told when this happens:
+
+
+
+```typescript
+CometChat.addOfflineCacheResetListener(
+ "LISTENER_ID",
+ new CometChat.OfflineCacheResetListener({
+ onOfflineCacheReset: (reason: string, pendingMessagesLost: number) => {
+ console.warn("Cache reset:", reason, "pending lost =", pendingMessagesLost);
+ },
+ })
+);
+```
+
+
+```javascript
+CometChat.addOfflineCacheResetListener(
+ "LISTENER_ID",
+ new CometChat.OfflineCacheResetListener({
+ onOfflineCacheReset: (reason, pendingMessagesLost) => {
+ console.warn("Cache reset:", reason, "pending lost =", pendingMessagesLost);
+ },
+ })
+);
+```
+
+
+
+The encryption-related reasons are `KEY_LOST`, `WRONG_KEY`, `CORRUPTED`, and `ENCRYPTION_CHANGED` (you turned encryption on or off between launches). See [Stay in Sync](/sdk/javascript/offline-sync#when-the-cache-resets) for the full cache-reset flow, and the [API Reference](/sdk/javascript/offline-reference) for every field and enum.
diff --git a/sdk/javascript/offline-media-caching.mdx b/sdk/javascript/offline-media-caching.mdx
new file mode 100644
index 000000000..de367ac02
--- /dev/null
+++ b/sdk/javascript/offline-media-caching.mdx
@@ -0,0 +1,225 @@
+---
+title: "Media Caching"
+sidebarTitle: "Media Caching"
+description: "Cache image, video, audio and file attachments on the device so media stays viewable offline with the CometChat JavaScript SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```javascript
+// Set the media caching mode at init (fixed for the session)
+const appSetting = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig({
+ enabled: true,
+ mediaCaching: CometChat.MediaCachingMode.AUTO, // AUTO (default) or OFF
+ maxMediaCacheSize: 500 * 1024 * 1024, // bytes; 0 = unlimited
+ })
+ .build();
+
+// Read the mode in effect
+const mode = CometChat.getMediaCachingMode(); // "OFF" when offline is not active
+
+// Resolve cached bytes for an attachment
+const attachment = mediaMessage.getAttachment();
+const url = await CometChat.getCachedMediaObjectURL(attachment); // blob: URL, or null if not cached
+// ...use as
/
+
+With offline support on, the SDK can cache attachment bytes durably on the device so media stays viewable offline — even after the server's signed URL has expired. This page covers the caching modes, how to read cached bytes, and how to download media on demand.
+
+
+Media caching applies only while offline support is active for the logged-in user (`CometChat.isOfflineEnabled()` is `true`). See [Enable Offline Support](/sdk/javascript/offline-enable). In a browser, media bytes are kept in the Origin Private File System (OPFS); **where OPFS is not available, media is not cached** (messages and attachment metadata still are).
+
+
+## Caching modes
+
+The caching mode is set once, in the offline config you pass at initialization, and is fixed for the SDK session. There is no runtime switch — to change it, re-initialize the SDK with a new config.
+
+| `MediaCachingMode` | Behavior |
+|---|---|
+| `AUTO` (default) | Incoming media bytes are downloaded and cached on receipt, then LRU-managed, so they are available offline. |
+| `OFF` | Incoming media bytes are **not** downloaded or cached. The message and its attachment metadata (including the URL) are still cached, so the message appears offline and the UI loads media live from the URL when online. |
+
+Sending media always works in both modes.
+
+
+
+```typescript
+const appSetting: CometChat.AppSettings = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig({
+ enabled: true,
+ mediaCaching: CometChat.MediaCachingMode.AUTO,
+ maxMediaCacheSize: 500 * 1024 * 1024,
+ })
+ .build();
+```
+
+
+```javascript
+const appSetting = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig({
+ enabled: true,
+ mediaCaching: CometChat.MediaCachingMode.AUTO,
+ maxMediaCacheSize: 500 * 1024 * 1024,
+ })
+ .build();
+```
+
+
+
+Read the mode in effect at runtime with `CometChat.getMediaCachingMode()`. It returns `"OFF"` whenever offline support is not active, whatever the app configured, so a UI layer can rely on it without reading the config itself.
+
+## Reading cached media
+
+When bytes are already cached, the SDK **sets the attachment's local path automatically** on the messages it delivers. Read it with `attachment.getLocalPath()`. In a browser that value is an OPFS storage handle (`opfs:…`), **not** something you can put in an `src` — to render a cached file, ask for an object URL instead.
+
+`getCachedMediaObjectURL` resolves a `blob:` URL for a cached attachment, or `null` when its bytes are not cached. **You must revoke the URL** with `URL.revokeObjectURL` when you are done with it.
+
+
+
+```typescript
+const attachment: CometChat.Attachment = mediaMessage.getAttachment();
+
+const objectUrl: string | null = await CometChat.getCachedMediaObjectURL(attachment);
+if (objectUrl) {
+ imageElement.src = objectUrl; // render from disk — no network fetch
+ imageElement.onload = () => URL.revokeObjectURL(objectUrl);
+} else {
+ imageElement.src = attachment.getUrl(); // not cached: load live when online
+}
+```
+
+
+```javascript
+const attachment = mediaMessage.getAttachment();
+
+const objectUrl = await CometChat.getCachedMediaObjectURL(attachment);
+if (objectUrl) {
+ imageElement.src = objectUrl; // render from disk — no network fetch
+ imageElement.onload = () => URL.revokeObjectURL(objectUrl);
+} else {
+ imageElement.src = attachment.getUrl(); // not cached: load live when online
+}
+```
+
+
+
+To look up the cached handle without creating a URL, call `getCachedMediaPath`. It resolves `null` when the bytes are not cached (call `downloadMedia` to fetch them).
+
+```javascript
+const handle = await CometChat.getCachedMediaPath(attachment); // null if not cached
+```
+
+
+Each attachment of a multi-attachment message resolves independently. An attachment with no cached bytes has no local path, and the UI loads it from the URL as before.
+
+
+## Downloading on demand
+
+Call `downloadMedia` to fetch and cache an attachment's bytes — for example when the user taps a placeholder. The SDK resolves cached bytes first, then the URL, and re-signs an expired link (HTTP 401, 403 or 410) through the message the attachment was delivered with before retrying once. `downloadMedia` returns nothing; results arrive on the `MediaCallbackListener`.
+
+
+
+```typescript
+CometChat.downloadMedia(
+ attachment,
+ new CometChat.MediaCallbackListener({
+ onSuccess: (localPath: string) => {
+ // Bytes cached; render via getCachedMediaObjectURL(attachment).
+ },
+ onProgress: (percent: number) => {
+ // 0..100, best-effort.
+ },
+ onError: (error: CometChat.CometChatException) => {
+ console.log(error.code);
+ },
+ })
+);
+```
+
+
+```javascript
+CometChat.downloadMedia(
+ attachment,
+ new CometChat.MediaCallbackListener({
+ onSuccess: (localPath) => {
+ // Bytes cached; render via getCachedMediaObjectURL(attachment).
+ },
+ onProgress: (percent) => {
+ // 0..100, best-effort.
+ },
+ onError: (error) => {
+ console.log(error.code);
+ },
+ })
+);
+```
+
+
+
+`downloadMedia` calls `onError` with `ERROR_OFFLINE_DISABLED` when offline support is not running, and with `ERROR_OFFLINE_MEDIA_NO_URL` when no attachment, or an attachment without a URL, is given.
+
+## Observe background downloads
+
+In `AUTO` mode, incoming media downloads in the background with no per-call callback. Register a `MediaDownloadListener` to learn when the bytes land (or fail) so a UI tile can refresh.
+
+
+
+```typescript
+CometChat.addMediaDownloadListener(
+ "MEDIA_LISTENER",
+ new CometChat.MediaDownloadListener({
+ onMediaDownloaded: (messageId: string, attachmentId: string, localPath: string) => {
+ // attachmentId: the attachment URL without its query string
+ },
+ onMediaDownloadFailed: (messageId: string, attachmentId: string, error: CometChat.CometChatException) => {},
+ })
+);
+```
+
+
+```javascript
+CometChat.addMediaDownloadListener(
+ "MEDIA_LISTENER",
+ new CometChat.MediaDownloadListener({
+ onMediaDownloaded: (messageId, attachmentId, localPath) => {
+ // attachmentId: the attachment URL without its query string
+ },
+ onMediaDownloadFailed: (messageId, attachmentId, error) => {},
+ })
+);
+```
+
+
+
+Remove the listener with `CometChat.removeMediaDownloadListener("MEDIA_LISTENER")`. The background listener carries no progress callback — progress is reported only on the explicit `downloadMedia` path. `MessageListener` also gains optional `onMediaDownloaded` and `onMediaDownloadFailed` callbacks.
+
+## Media cache size limit
+
+Cached media bytes are bounded by `maxMediaCacheSize` (default **500 MB**; `0` means unlimited). When the cache exceeds the limit, the least recently accessed media is deleted first until it is back under budget. Media belonging to un-sent messages and in-progress uploads is never evicted.
+
+Media is stored in OPFS (browser) or as files on disk (Electron); the location is not configurable. Eviction runs silently in the background — there is no cache-limit callback. To reason about size, use `CometChat.getCacheSize()` and the configured limits. See the [reference](/sdk/javascript/offline-reference) for the full set of options.
+
+
+Cached media files are **not encrypted**, even when you turn on [cache encryption](/sdk/javascript/offline-encryption).
+
diff --git a/sdk/javascript/offline-overview.mdx b/sdk/javascript/offline-overview.mdx
new file mode 100644
index 000000000..104a2f151
--- /dev/null
+++ b/sdk/javascript/offline-overview.mdx
@@ -0,0 +1,90 @@
+---
+title: "Offline Support"
+sidebarTitle: "Offline Support"
+description: "Understand how local caching, the send outbox, and reconnect catch-up keep chat working without a connection with the CometChat JavaScript SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```javascript
+// Offline support is OFF by default. Turn it on at init (SDK 4.3.0 or later).
+const appSetting = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig({ enabled: true }) // or .offline(true) for defaults
+ .build();
+
+await CometChat.init("APP_ID", appSetting);
+
+// Is offline support active right now for the logged-in user?
+const active = CometChat.isOfflineEnabled(); // boolean
+```
+
+
+
+Offline support keeps the logged-in user's conversations and messages on the device so your app can show them instantly, lets the user send messages while disconnected, and catches up automatically when the connection returns. It is **off by default** — nothing changes in your app until you enable it ([Enable Offline Support](/sdk/javascript/offline-enable)). This page explains the mental model; the other pages show the exact APIs.
+
+
+Offline support ships in CometChat JavaScript SDK **4.3.0** or later. It runs in the browser (IndexedDB, with OPFS for media) and in Electron (SQLite). See [Enable Offline Support](/sdk/javascript/offline-enable#supported-runtimes).
+
+
+## How it works
+
+Three ideas carry the whole feature.
+
+
+
+Reads that you pass a fetch listener go through the local cache first. The SDK delivers the cached page immediately — even when it is empty — then fetches from the server and delivers the fresh result. See [Show Cached Data](/sdk/javascript/offline-cached-data).
+
+
+A message sent while offline is saved to a durable outbox and reported as pending instead of failing. It survives page reloads and app restarts and is sent when the connection returns. See [Send While Offline](/sdk/javascript/offline-send).
+
+
+When the connection returns, the SDK syncs the conversations and messages that changed while it was away and flushes the outbox, so the local cache matches the server again. See [Stay in Sync](/sdk/javascript/offline-sync).
+
+
+
+```mermaid
+flowchart LR
+ A[App read] --> B[(Local cache)]
+ B -- cached page first --> A
+ B --> C{Online?}
+ C -- yes --> D[Server]
+ D -- fresh result --> A
+ C -- no --> E[onError:\nFAILED_TO_FETCH]
+ F[Send while offline] --> G[(Durable outbox)]
+ G -- on reconnect --> D
+```
+
+## What changes in your app
+
+With offline support enabled, your fetch callbacks fire **twice** on the read path and sends gain a pending stage. To receive the cached and pending results you pass the new listener objects — `MessageFetchListener`, `ConversationFetchListener`, `MessageSentListener`, and the others listed on each page — as an optional argument. Every method still returns its usual Promise, so existing code keeps working. With offline support off, every API behaves exactly as it did before.
+
+## Queued, not failed
+
+A send made with no connection fires `onMessagePending`, then **one** `onError` with code `FAILED_TO_FETCH`, and the returned Promise rejects. That is a notice, not a failure: the message stays queued and `onMessageSent` fires later, on reconnect. See [Send While Offline](/sdk/javascript/offline-send#queued-not-failed).
+
+## One cache per user
+
+The SDK keeps a single local cache per app and user.
+
+- A page reload or app restart with a still-valid session reuses that cache, so the data is there instantly.
+- Logging out, a session expiring, or logging in as a **different** user deletes the cache. You never clear it yourself — the SDK does it.
+
+See [Stay in Sync](/sdk/javascript/offline-sync) for cache resets and logout behaviour.
+
+## How much is kept
+
+By default the cache holds up to **100 conversations**, up to **500 messages per conversation**, around **250 MB** of cached content, and up to **500 MB** of cached media. There is no age limit unless you set `cacheDurationDays`. Older entries are evicted first as these limits are reached. Every limit is tunable — see the [Configuration & API Reference](/sdk/javascript/offline-reference).
+
+## Next steps
+
+
+ Turn it on and verify it is active.
+ The cached-first, server-next read contract.
+ The durable outbox and pending messages.
+ Reconnect catch-up, cache resets, logout.
+ Download and cache attachments for offline viewing.
+ Encrypt the local cache at rest.
+ Every option, method, listener, enum, and error.
+
diff --git a/sdk/javascript/offline-reference.mdx b/sdk/javascript/offline-reference.mdx
new file mode 100644
index 000000000..fbe6fe46c
--- /dev/null
+++ b/sdk/javascript/offline-reference.mdx
@@ -0,0 +1,178 @@
+---
+title: "Offline Configuration & API Reference"
+sidebarTitle: "Configuration & API Reference"
+description: "Every offline configuration option, method, listener, enum, model field, and error code in the CometChat JavaScript SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+| Field | Value |
+| --- | --- |
+| Package | `@cometchat/chat-sdk-javascript` (offline requires **4.3.0** or later) |
+| Enable | `new CometChat.AppSettingsBuilder().setOfflineConfig({ enabled: true, … })` or `.offline(true)` |
+| Verify | `CometChat.isOfflineEnabled()` |
+| Reads | Pass a `…FetchListener` to `fetchNext()` / `fetchPrevious()` / `getConversation()` / `getMessageDetails()` |
+| Sends | Pass a `MessageSentListener` to `sendMessage()` / `sendMediaMessage()` / `sendCustomMessage()` / `sendInteractiveMessage()` |
+| Outbox | `getPendingMessages()` · `retryMessage()` · `retryAllFailedMessages()` · `discardLocalMessage()` |
+| Sync | `addSyncListener()` · `forceSyncNow()` · `getSyncState()` · `getLastSyncTimestamp()` |
+
+
+
+This page is the complete lookup for offline support in the CometChat JavaScript SDK. For guided walkthroughs, start with [Offline Support](/sdk/javascript/offline-overview).
+
+
+Offline support requires SDK **4.3.0** or later.
+
+
+## OfflineConfig options
+
+Pass these keys in the object given to `AppSettingsBuilder.setOfflineConfig(...)`. Every key is optional except `enabled`, which must be an explicit `true`. Bad values are corrected, never rejected, and unknown keys are ignored. You can also pass `true`/`false` as a shorthand with `AppSettingsBuilder.offline(...)`.
+
+| Key | Type | Default | Range / clamp | Effect |
+|---|---|---|---|---|
+| `enabled` | `boolean` | `false` | only an explicit `true` enables | Master gate for the whole offline engine. With it off the SDK behaves exactly as before. |
+| `cacheDurationDays` | `number` | `0` (unlimited) | negative treated as `0` | Message retention TTL in days. `0` = no age-based prune; eviction is by the other caps. |
+| `maxStorageSize` | `number` (bytes) | `262144000` (250 MB) | `0` = unlimited | Byte ceiling for cached content, counted before encryption and excluding unsent messages. Over budget, the SDK removes recorded list reads first (LRU), then whole conversations (LRU; a conversation with unsent messages is kept), then the oldest saved and my-threads entries. |
+| `maxMessagesPerConversation` | `number` | `500` | clamped `[1, 1000]`; `0`/over → `1000` | Per-conversation cached-message window. |
+| `maxConversations` | `number` | `100` | clamped `[1, 1000]`; `0`/over → `1000` | Cached-conversation cap (whole-conversation LRU). Also the page size of the reconnect conversation refresh. |
+| `maxPendingMessages` | `number` | `50` | clamped `[1, 100]`; `0`/over → `100` | Pending-send outbox capacity. |
+| `autoSendPendingOnReconnect` | `boolean` | `true` | — | Auto-flush queued `PENDING` sends on reconnect. Gates only the outbox flush; the catch-up sync always runs. |
+| `pendingQueueOverflowPolicy` | `PendingMessageOverflowPolicy` | `REJECT_NEWEST` | unknown → default | What the outbox does when it is full. |
+| `maxMediaCacheSize` | `number` (bytes) | `524288000` (500 MB) | `0` = unlimited | Media-only byte budget (LRU). |
+| `mediaCaching` | `MediaCachingMode` | `AUTO` | unknown → `AUTO` | Receive-side media mode. Fixed at init. |
+| `encryption` | `boolean` | `false` | only an explicit `true` enables | Encrypt the cache at rest. See [Encrypt the Cache](/sdk/javascript/offline-encryption). |
+| `encryptionFailureBehavior` | `EncryptionFailureBehavior` | `ONLINE_ONLY` | anything but `PLAINTEXT_FALLBACK` → `ONLINE_ONLY` | What happens when encryption cannot run at all. |
+| `maxSnapshotQueries` | `number` | `100` | `0`/negative → `100` | Most distinct entity-list reads (users, groups, members, banned members, reactions, notification feed, threads) kept for offline replay; LRU. |
+| `snapshotTtlDays` | `number` | `7` | `0`/negative → `7` | Days a recorded list read stays replayable. Past it, the read is empty offline until viewed online again. |
+| `debugTracing` | `boolean` | `false` | — | Dev-only diagnostic trace. Leave off in production. |
+
+Count caps are always bounded and have no "unlimited" setting. Byte and day caps treat `0` as unlimited.
+
+## Public methods on CometChat
+
+| Method | Returns | Purpose |
+|---|---|---|
+| `isOfflineEnabled()` | `boolean` | Whether offline support is active for the current session. |
+| `getMediaCachingMode()` | `string` | The effective media caching mode (`"OFF"` whenever offline is not active). |
+| `getOfflineEncryptionStatus()` | `OfflineEncryptionStatus` | What encryption at rest is actually doing. |
+| `getPendingMessages(conversationId?)` | `Promise` | All queued/failed messages in the outbox. |
+| `retryMessage(message \| localMessageId, listener?)` | `Promise` | Retry one message. |
+| `retryAllFailedMessages(listener?)` | `Promise` | Retry every transiently failed message. |
+| `discardLocalMessage(message \| localMessageId, { onSuccess?, onError? }?)` | `Promise` | Remove one unsent message from the outbox. |
+| `forceSyncNow(callback?)` | `void` | Trigger a catch-up sync immediately; the callback reports whether it started. |
+| `getSyncState()` | `string` | `"IDLE"` or `"SYNCING"`. |
+| `getLastSyncTimestamp()` | `Promise` | Server time of the last successful sync, in epoch seconds. |
+| `clearOfflineCache()` | `Promise` | Wipe the local cache (and fail any unsent messages). |
+| `getCacheSize()` | `Promise` | Cache size in bytes (cached content before encryption plus media). |
+| `downloadMedia(attachment, listener?)` | `void` | Download and cache an attachment's bytes. |
+| `getCachedMediaPath(attachment)` | `Promise` | Storage handle of already-cached bytes, or `null`. |
+| `getCachedMediaObjectURL(attachment)` | `Promise` | A `blob:` URL for cached bytes, or `null`. Revoke it when done. |
+| `getExistingUploadFileRequest(receiverId, receiverType, parentMessageId?)` | `Promise` | Re-attach to an in-progress offline upload. |
+
+### Listener registration
+
+| Method | Listener |
+|---|---|
+| `addMessageListener(name, listener)` / `removeMessageListener(name)` | `MessageListener` (adds `onMessagesSynced`, `onMediaDownloaded`, `onMediaDownloadFailed`) |
+| `addOfflineMessageListener(name, listener)` / `removeOfflineMessageListener(name)` | `OfflineMessageListener` |
+| `addSyncListener(name, listener)` / `removeSyncListener(name)` | `SyncListener` |
+| `addOfflineCacheResetListener(name, listener)` / `removeOfflineCacheResetListener(name)` | `OfflineCacheResetListener` |
+| `addMediaDownloadListener(name, listener)` / `removeMediaDownloadListener(name)` | `MediaDownloadListener` |
+
+Sync and cache-reset registrations are valid at any time, including before login, and survive logout, login and restarts. Registering the same `name` twice replaces the first listener.
+
+## Listeners and callbacks
+
+Every listener is constructed with an object of callbacks, for example `new CometChat.MessageFetchListener({ onCachedMessages, onServerMessages, onError })`.
+
+### Two-phase fetch listeners
+
+| Listener | Callbacks |
+|---|---|
+| `MessageFetchListener` | `onCachedMessages(messages)`, `onServerMessages(messages)`, `onError(error)` |
+| `ThreadFetchListener` | `onCachedThreads(threads)`, `onServerThreads(threads)`, `onError(error)` |
+| `SingleMessageFetchListener` | `onCachedMessage(message)`, `onServerMessage(message)`, `onError(error)` |
+| `ConversationFetchListener` | `onCachedConversations(conversations)`, `onServerConversations(conversations)`, `onError(error)` |
+| `ConversationDetailsListener` | `onCachedConversation(conversation)`, `onServerConversation(conversation)`, `onError(error)` |
+| `UserFetchListener` | `onCachedUsers(users)`, `onServerUsers(users)`, `onError(error)` |
+| `GroupFetchListener` | `onCachedGroups(groups)`, `onServerGroups(groups)`, `onError(error)` |
+| `GroupMemberFetchListener` | `onCachedGroupMembers(members)`, `onServerGroupMembers(members)`, `onError(error)` |
+| `BannedMemberFetchListener` | `onCachedBannedGroupMembers(members)`, `onServerBannedGroupMembers(members)`, `onError(error)` |
+| `ReactionFetchListener` | `onCachedReactions(reactions)`, `onServerReactions(reactions)`, `onError(error)` |
+| `NotificationFeedFetchListener` | `onCachedNotificationFeedItems(items)`, `onServerNotificationFeedItems(items)`, `onError(error)` |
+
+`SingleConversationFetchListener` is a deprecated alias of `ConversationDetailsListener`. `BannedMemberFetchListener` also still accepts the older `onCachedBannedMembers` / `onServerBannedMembers` names.
+
+### Send, sync, cache, and media listeners
+
+| Listener | Callbacks |
+|---|---|
+| `MessageSentListener` | `onMessagePending(message)`, `onMessageSent(message)`, `onError(error)` |
+| `OfflineMessageListener` | `onOutboxMessagePending(message)`, `onOutboxMessageSent(message)`, `onOutboxMessageFailed(message, reason)` |
+| `SyncListener` | `onConversationSyncStarted()`, `onConversationSyncCompleted(conversations)`, `onConversationSyncError(error)`, `onMessageSyncStarted()`, `onMessageSyncCompleted(messages, outcome)`, `onMessageSyncError(error)` |
+| `OfflineCacheResetListener` | `onOfflineCacheReset(reason, pendingMessagesLost)` |
+| `MediaCallbackListener` | `onSuccess(localPath)`, `onProgress(percent)`, `onError(error)` |
+| `MediaDownloadListener` | `onMediaDownloaded(messageId, attachmentId, localPath)`, `onMediaDownloadFailed(messageId, attachmentId, error)` |
+| `MessageListener` | `onMessagesSynced(messages)`, `onMediaDownloaded`, `onMediaDownloadFailed` (offline additions to the existing listener) |
+
+## Enums
+
+| Enum | Values |
+|---|---|
+| `MediaCachingMode` | `AUTO` (auto-download + retain received media), `OFF` (skip auto-download; metadata still cached) |
+| `PendingMessageOverflowPolicy` | `DROP_OLDEST` (evict the oldest pending to admit a newer send), `REJECT_NEWEST` (reject the new send) |
+| `EncryptionFailureBehavior` | `ONLINE_ONLY` (server-only; never plaintext), `PLAINTEXT_FALLBACK` (open an unencrypted cache) |
+| `MessageStatus` | `PENDING`, `SENDING`, `SENT`, `DELIVERED`, `READ`, `FAILED` |
+| `MessageFailureReason` | `TRANSIENT`, `PERMANENT`, `QUEUE_OVERFLOW`, `QUEUE_FULL`, `BYTE_OVERFLOW`, `DISCARDED` — see [Send While Offline](/sdk/javascript/offline-send#message-status-and-failure-reason) |
+| `MessageSource` | `REALTIME`, `SYNC` (delivered by a catch-up sweep) |
+| `SyncOutcome` | `CAUGHT_UP` (changelog fully replayed), `PARTIAL` (a page was ingested but more remains) |
+| `SyncState` | `IDLE`, `SYNCING` |
+| `OfflineCacheResetReason` | `KEY_LOST`, `WRONG_KEY`, `CORRUPTED`, `ESCALATED`, `ENCRYPTION_CHANGED` |
+
+All enums are string-valued and exposed on the `CometChat` namespace (for example `CometChat.MessageStatus.PENDING`).
+
+## Model fields
+
+| Field | Type | Meaning |
+|---|---|---|
+| `BaseMessage.getLocalMessageId()` | `string` | SDK-generated local handle for an optimistic/offline send; the stable key to reconcile the row when the server ack arrives. |
+| `BaseMessage.getStatus()` | `MessageStatus` | Lifecycle status of the message. A status set by your app with `setStatus()` is never read by the offline layer. |
+| `BaseMessage.getFailureReason()` | `MessageFailureReason` | Why the send failed; set only while `FAILED`. |
+| `BaseMessage.getSource()` | `MessageSource` | `REALTIME` for a live message, `SYNC` for one delivered by catch-up. |
+| `BaseMessage.getUpdatedAt()` | `number` | Last update time of the message. |
+| `Attachment.getLocalPath()` | `string` | Storage handle of cached media bytes (`opfs:…` in a browser, a file path on Electron), or empty when not cached. Never sent to the server. |
+
+## Error codes
+
+The offline-related codes the SDK raises:
+
+| Code | Raised when |
+|---|---|
+| `FAILED_TO_FETCH` | A request got no answer (offline or dropped): a two-phase read's `onError` after the cached page, the not-connected notice on a queued send, and sync errors. |
+| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | `forceSyncNow(callback)` was called while a sync is already running. Constant: `CometChat.ERROR_OFFLINE_SYNC_IN_PROGRESS`. |
+| `ERROR_OFFLINE_SYNC_COOLDOWN` | `forceSyncNow(callback)` was called within the 10-second cooldown; `details.retryAfterMs` has the remaining time. Constant: `CometChat.ERROR_OFFLINE_SYNC_COOLDOWN`. |
+| `ERROR_OFFLINE_DISABLED` | An offline-only operation was called while offline support is not active. Constant: `CometChat.ERROR_OFFLINE_DISABLED`. |
+| `ERROR_OFFLINE_MESSAGE_DISCARDED` | A queued message was removed before it could send. Constant: `CometChat.ERROR_OFFLINE_MESSAGE_DISCARDED`. |
+| `ERROR_OFFLINE_MESSAGE_IN_FLIGHT` | `retryMessage` / `discardLocalMessage` targeted a message that is already being sent. Constant: `CometChat.ERROR_OFFLINE_MESSAGE_IN_FLIGHT`. |
+| `ERROR_OFFLINE_MESSAGE_NOT_FOUND` | `retryMessage` / `discardLocalMessage` found no unsent message with the given local id. Constant: `CometChat.ERROR_OFFLINE_MESSAGE_NOT_FOUND`. |
+| `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` | The local cache was briefly unavailable, so `getPendingMessages`, `retryMessage`, `retryAllFailedMessages` or `discardLocalMessage` could not read it. Try again. |
+| `QUEUE_FULL` | A send was refused because the outbox is full; `details.maxPendingMessages` holds the cap. |
+| `ERROR_OFFLINE_MEDIA_NO_URL` | `downloadMedia` was given no attachment, or an attachment without a URL. |
+| `ERROR_JSON_EXCEPTION` | A sync request got a 2xx answer with an unreadable body; `details.httpStatus` holds the status. |
+| `USER_NOT_LOGED_IN` | `forceSyncNow(callback)` was called with nobody logged in (spelling as the SDK returns it). |
+
+## Cache-supported filters
+
+A cached read answers only if **every** filter on the request can be computed locally. One server-only filter empties the cached phase while the server phase still answers in full.
+
+| Request | Answered from the cache | Server-only (empties the cached phase) |
+|---|---|---|
+| `MessagesRequest` | categories, types, parent message id, hide replies, hide deleted messages, tags, hide quoted messages, has mentions, mentioned UIDs, unread, has attachments, attachment types, has reactions, hide messages from blocked users | `searchKeyword`, `hasLinks`, `updatesOnly`, `interactionGoalCompletedOnly` |
+| `ConversationsRequest` | conversation type, unread, tags, user tags, group tags, hide/only agentic, with-blocked-info, include blocked users, pinned-by | `searchKeyword` |
+
+Enrichment flags (`withTags`, `withParent`, mentions-with-tag/blocked info, `withUserAndGroupTags`) decorate a result without narrowing it and never gate the cache. Entity lists (users, groups, members, banned members, reactions, notification feed, threads) are replayed from recorded requests rather than filtered locally.
+
+## Deprecated overloads and replacements
+
+Nothing is deprecated for the main fetch and send methods: the listener is an **additive optional argument** and every method keeps returning its Promise. With offline off, a listener-bearing call behaves like the single-callback form. The only deprecated names are `SingleConversationFetchListener` (use `ConversationDetailsListener`) and the `onCachedBannedMembers` / `onServerBannedMembers` callback names (use the `…BannedGroupMembers` names).
diff --git a/sdk/javascript/offline-send.mdx b/sdk/javascript/offline-send.mdx
new file mode 100644
index 000000000..56fe1b0b0
--- /dev/null
+++ b/sdk/javascript/offline-send.mdx
@@ -0,0 +1,401 @@
+---
+title: "Send While Offline"
+sidebarTitle: "Send While Offline"
+description: "Queue messages sent without a connection, track their status, and retry failed sends with the CometChat JavaScript SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```javascript
+// Send with the offline-aware listener (queued when offline, auto-sent on reconnect)
+CometChat.sendMessage(
+ textMessage,
+ new CometChat.MessageSentListener({
+ onMessagePending: (message) => { /* status === PENDING; queued on the device */ },
+ onMessageSent: (message) => { /* server ack: real id + sentAt */ },
+ onError: (error) => { /* FAILED_TO_FETCH = queued offline, not failed */ },
+ })
+).catch(() => {}); // the returned Promise also rejects while offline
+
+// Read offline state off a message
+message.getLocalMessageId(); // stable client handle while offline is running
+message.getStatus(); // PENDING, SENDING, SENT, DELIVERED, READ, FAILED
+message.getFailureReason(); // MessageFailureReason, set only while FAILED
+
+// Inspect and recover the outbox
+await CometChat.getPendingMessages(conversationId?); // PENDING + SENDING + FAILED
+await CometChat.retryMessage(localMessageId, sentListener); // one message
+await CometChat.retryAllFailedMessages(sentListener); // transient failures only
+await CometChat.discardLocalMessage(localMessageId); // delete an unsent message
+
+// Observe every outbox transition, app-wide
+CometChat.addOfflineMessageListener("LISTENER_ID", new CometChat.OfflineMessageListener({
+ onOutboxMessagePending: (message) => {},
+ onOutboxMessageSent: (message) => {},
+ onOutboxMessageFailed: (message, reason) => {},
+}));
+```
+
+
+
+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 a page reload or app restart and is sent automatically when the connection returns. This page shows the offline-aware send listener, 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.3.0** or later. Queuing only happens once you [enable it](/sdk/javascript/offline-enable) **and** pass a `MessageSentListener`. A bare `await CometChat.sendMessage(message)` with no listener is never queued: offline, it rejects at once, as before.
+
+
+## Send a Message
+
+Pass a `MessageSentListener` to `sendMessage`. Its lifecycle is `onMessagePending` first (the message is durably queued), then exactly one final outcome — `onMessageSent` (the server accepted it) or, for a real failure, `onError`.
+
+
+
+```typescript
+const textMessage: CometChat.TextMessage = new CometChat.TextMessage(
+ "UID",
+ "Hello!",
+ CometChat.RECEIVER_TYPE.USER
+);
+
+CometChat.sendMessage(
+ textMessage,
+ new CometChat.MessageSentListener({
+ onMessagePending: (message: CometChat.BaseMessage) => {
+ // Queued and persisted; message.getStatus() === "PENDING"
+ console.log("Pending, local id:", message.getLocalMessageId());
+ },
+ onMessageSent: (message: CometChat.BaseMessage) => {
+ // Server ack: message now carries its real id and sentAt
+ console.log("Sent:", message.getId());
+ },
+ onError: (error: CometChat.CometChatException) => {
+ // FAILED_TO_FETCH while offline means "queued", not "failed" — see below
+ console.log("Send error:", error.code);
+ },
+ })
+).catch(() => {});
+```
+
+
+```javascript
+const textMessage = new CometChat.TextMessage(
+ "UID",
+ "Hello!",
+ CometChat.RECEIVER_TYPE.USER
+);
+
+CometChat.sendMessage(
+ textMessage,
+ new CometChat.MessageSentListener({
+ onMessagePending: (message) => {
+ // Queued and persisted; message.getStatus() === "PENDING"
+ console.log("Pending, local id:", message.getLocalMessageId());
+ },
+ onMessageSent: (message) => {
+ // Server ack: message now carries its real id and sentAt
+ console.log("Sent:", message.getId());
+ },
+ onError: (error) => {
+ // FAILED_TO_FETCH while offline means "queued", not "failed" — see below
+ console.log("Send error:", error.code);
+ },
+ })
+).catch(() => {});
+```
+
+
+
+The same `MessageSentListener` is accepted by all four send methods:
+
+```javascript
+CometChat.sendMessage(textMessage, listener); // TextMessage
+CometChat.sendMediaMessage(mediaMessage, listener); // MediaMessage
+CometChat.sendCustomMessage(customMessage, listener); // CustomMessage
+CometChat.sendInteractiveMessage(interactiveMessage, listener); // InteractiveMessage
+```
+
+
+The returned Promise is **not** replaced by the listener. It means "was it sent now?": offline it **rejects**, and the eventual send is observed through `onMessageSent` or `getPendingMessages`, never the Promise. Always attach a `.catch()` when you rely on the listener alone.
+
+
+## Queued, Not Failed
+
+When the device is offline, the send still fires `onMessagePending`, then fires `onError` **once** with the code `FAILED_TO_FETCH`. This is a **notification, not a failure**: the message stays durably `PENDING`, and `onMessageSent` fires **later**, on reconnect, when it actually goes. **Do not re-send on that error.**
+
+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 message's status to tell the two apart — keep the `localMessageId` from `onMessagePending`, then look the message up in the outbox:
+
+| `status` after `onError` | Meaning | What to do |
+|---|---|---|
+| `PENDING` | Queued; not sent yet. Will auto-send on reconnect. | Show as sending/queued; nothing to do. |
+| `FAILED` | A real failure; will not retry on its own. | Offer the user a manual retry (see below). |
+
+
+
+```typescript
+let localId: string | undefined;
+
+CometChat.sendMessage(
+ textMessage,
+ new CometChat.MessageSentListener({
+ onMessagePending: (message: CometChat.BaseMessage) => {
+ localId = message.getLocalMessageId();
+ },
+ onError: async () => {
+ const outbox = await CometChat.getPendingMessages();
+ const queued = outbox.find((m) => m.getLocalMessageId() === localId);
+ if (queued?.getStatus() === CometChat.MessageStatus.PENDING) {
+ // Queued offline — it will send automatically on reconnect
+ } else if (queued?.getStatus() === CometChat.MessageStatus.FAILED) {
+ // Real failure — surface a retry action
+ }
+ },
+ })
+).catch(() => {});
+```
+
+
+```javascript
+let localId;
+
+CometChat.sendMessage(
+ textMessage,
+ new CometChat.MessageSentListener({
+ onMessagePending: (message) => {
+ localId = message.getLocalMessageId();
+ },
+ onError: async () => {
+ const outbox = await CometChat.getPendingMessages();
+ const queued = outbox.find((m) => m.getLocalMessageId() === localId);
+ if (queued?.getStatus() === CometChat.MessageStatus.PENDING) {
+ // Queued offline — it will send automatically on reconnect
+ } else if (queued?.getStatus() === CometChat.MessageStatus.FAILED) {
+ // Real failure — surface a retry action
+ }
+ },
+ })
+).catch(() => {});
+```
+
+
+
+
+For a global view, `onOutboxMessageFailed` on the `OfflineMessageListener` (below) fires only for real failures and carries the reason — a simpler place to drive a "failed, tap to retry" state.
+
+
+A send made while an earlier message in the **same conversation** is still unsent waits behind it and gets no notice: no `onError`, and its Promise does not reject. It resolves, with `onMessageSent`, when it is sent.
+
+### Message status and failure reason
+
+Every message exposes its offline state. `getLocalMessageId()` returns the SDK-generated handle that is stamped at compose (while offline is running) and stays stable across the whole lifecycle — use it to retry or discard the message.
+
+| Accessor | Type | Notes |
+|---|---|---|
+| `getLocalMessageId()` | `string` | Client handle; present while offline is running. |
+| `getStatus()` | `MessageStatus` | `PENDING`, `SENDING`, `SENT`, `DELIVERED`, `READ`, `FAILED`. |
+| `getFailureReason()` | `MessageFailureReason` | Set only while `FAILED`; otherwise empty. |
+
+`MessageFailureReason` values:
+
+| Reason | When |
+|---|---|
+| `TRANSIENT` | A temporary server error (for example 5xx or rate-limited), or no answer after five reconnect attempts. Retryable. |
+| `PERMANENT` | The server rejected the message, the error was not recognized, or copying an attachment into the store timed out. A manual retry will not help. |
+| `QUEUE_FULL` | A new send was refused because the queue is full (count cap). |
+| `QUEUE_OVERFLOW` | An older queued message was evicted to admit a newer one (`DROP_OLDEST` only). |
+| `BYTE_OVERFLOW` | A media send was refused because it exceeds the queue's byte budget. |
+| `DISCARDED` | The message was removed before it could send (for example a cache clear or a conversation purge). |
+
+## Inspect the Queue
+
+`getPendingMessages` returns the **entire un-acked outbox** — every `PENDING`, `SENDING`, and `FAILED` message, ordered by local compose time. Sent messages are not included. Pass a conversation id to limit it to one conversation. Read each message's `status` to group them.
+
+
+
+```typescript
+CometChat.getPendingMessages().then(
+ (messages: CometChat.BaseMessage[]) => {
+ messages.forEach((m) => console.log(m.getLocalMessageId(), "->", m.getStatus()));
+ },
+ (error: CometChat.CometChatException) => {
+ console.log("Could not read the outbox:", error.code);
+ }
+);
+```
+
+
+```javascript
+CometChat.getPendingMessages().then(
+ (messages) => {
+ messages.forEach((m) => console.log(m.getLocalMessageId(), "->", m.getStatus()));
+ },
+ (error) => {
+ console.log("Could not read the outbox:", error.code);
+ }
+);
+```
+
+
+
+A queued media message sent from local files comes back with its files (`message.files`). With offline off, or nobody signed in, it resolves with an empty list. If the local store is briefly unavailable (busy, locked, full, or still reopening), it **rejects** with `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` instead of returning an empty list — try again.
+
+### Observe the queue app-wide
+
+The per-call `MessageSentListener` lives only as long as the call. To watch every queue transition — a live send, a reconnect auto-flush, a manual retry, or a resumed send after a restart — register a global `OfflineMessageListener`. Its failed callback carries the `MessageFailureReason`.
+
+
+
+```typescript
+CometChat.addOfflineMessageListener(
+ "LISTENER_ID",
+ new CometChat.OfflineMessageListener({
+ onOutboxMessagePending: (message: CometChat.BaseMessage) => {},
+ onOutboxMessageSent: (message: CometChat.BaseMessage) => {},
+ onOutboxMessageFailed: (message: CometChat.BaseMessage, reason: string) => {
+ console.log("Failed:", message.getLocalMessageId(), reason);
+ },
+ })
+);
+
+// Remove it when no longer needed
+CometChat.removeOfflineMessageListener("LISTENER_ID");
+```
+
+
+```javascript
+CometChat.addOfflineMessageListener(
+ "LISTENER_ID",
+ new CometChat.OfflineMessageListener({
+ onOutboxMessagePending: (message) => {},
+ onOutboxMessageSent: (message) => {},
+ onOutboxMessageFailed: (message, reason) => {
+ console.log("Failed:", message.getLocalMessageId(), reason);
+ },
+ })
+);
+
+// Remove it when no longer needed
+CometChat.removeOfflineMessageListener("LISTENER_ID");
+```
+
+
+
+## Retry and Discard
+
+There is no automatic retry of a `FAILED` message — recovery is explicit and user-invoked. Each method accepts a message or its `localMessageId`.
+
+- **`retryMessage(message, listener?)`** 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. The optional `MessageSentListener` and the returned Promise receive that attempt's outcome. With no answer, it fires the not-connected notice once (`FAILED_TO_FETCH`) and the message stays `PENDING`.
+- **`retryAllFailedMessages(listener?)`** retries **transient** failures only; permanent ones are skipped.
+- **`discardLocalMessage(message, listener?)`** deletes an unsent (`PENDING` or `FAILED`) message from the device, including its copied media. The optional listener is `{ onSuccess, onError }`; `onSuccess` receives the `localMessageId`.
+
+
+
+```typescript
+await CometChat.retryMessage(localMessageId, sentListener);
+
+await CometChat.retryAllFailedMessages(sentListener);
+
+CometChat.discardLocalMessage(localMessageId, {
+ onSuccess: (discardedLocalId: string) => {},
+ onError: (error: CometChat.CometChatException) => {
+ console.log("Discard rejected:", error.code);
+ },
+}).catch(() => {});
+```
+
+
+```javascript
+await CometChat.retryMessage(localMessageId, sentListener);
+
+await CometChat.retryAllFailedMessages(sentListener);
+
+CometChat.discardLocalMessage(localMessageId, {
+ onSuccess: (discardedLocalId) => {},
+ onError: (error) => {
+ console.log("Discard rejected:", error.code);
+ },
+}).catch(() => {});
+```
+
+
+
+A retry is never refused for the queue cap and never evicts other messages, so a retry may take the outbox over `maxPendingMessages`.
+
+`retryMessage` and `discardLocalMessage` reject, changing nothing, when:
+
+| Error code | Reason |
+|---|---|
+| `ERROR_OFFLINE_MESSAGE_IN_FLIGHT` | The message is already sending (`SENDING`, or a timed-out request still awaiting its answer). |
+| `ERROR_OFFLINE_MESSAGE_NOT_FOUND` | No unsent message has that id (unknown, or already sent). |
+| `ERROR_OFFLINE_DISABLED` | Offline support is not running. |
+| `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` | The local cache was briefly unavailable. Try again. |
+
+## Auto-send on Reconnect
+
+By default, queued `PENDING` messages are sent automatically when the connection is re-established. This is controlled by `autoSendPendingOnReconnect` (default `true`); see [Enable Offline Support](/sdk/javascript/offline-enable) and the [reference](/sdk/javascript/offline-reference). With it off, a send that gets no answer fails at once with reason `TRANSIENT` (its listener gets one `onError` with `FAILED_TO_FETCH` and the message is `FAILED`), which you recover with `retryMessage`.
+
+A send that gets no answer for 15 seconds (media: 40 seconds plus upload time) counts as unanswered. After five reconnect attempts with no answer it fails with `TRANSIENT`.
+
+Reconnect also runs a catch-up sync for received messages — see [Stay in Sync](/sdk/javascript/offline-sync).
+
+## Queue Limits
+
+The outbox holds at most `maxPendingMessages` messages still trying to send (`PENDING` + `SENDING`); `FAILED` messages do not count. When the queue is full, `pendingQueueOverflowPolicy` decides what happens:
+
+| `PendingMessageOverflowPolicy` | Behaviour on overflow |
+|---|---|
+| `REJECT_NEWEST` (**default**) | The new send is refused and reported with reason `QUEUE_FULL`. Nothing already queued is lost. |
+| `DROP_OLDEST` | The oldest `PENDING` message is evicted (reason `QUEUE_OVERFLOW`) to admit the new one. |
+
+A media send that exceeds the queue's byte budget is refused with reason `BYTE_OVERFLOW`.
+
+A **refused** send (`QUEUE_FULL` / `BYTE_OVERFLOW`) is never queued, so it fires **no** `onMessagePending`; it reports `onError`, and the global `OfflineMessageListener` receives `onOutboxMessageFailed(message, reason)` carrying the exact reason. For `QUEUE_FULL` the error's `code` is `QUEUE_FULL`, with `details.maxPendingMessages`.
+
+
+A refused or evicted message (`QUEUE_FULL`, `QUEUE_OVERFLOW`, `BYTE_OVERFLOW`) has **no durable row** — it does not appear in `getPendingMessages`, and `retryMessage` on its id does nothing. Capture these from the failure callback and handle them at that moment. Prefer the default `REJECT_NEWEST`, 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 fail with the not-connected error if the request gets no answer.
+
+## Durable Media Uploads
+
+A multi-attachment upload survives a restart: the SDK re-attaches to an in-progress batch instead of restarting it. Fetch the existing request for a conversation with `getExistingUploadFileRequest` (it resolves `null` when there is none, or offline is off), read each file's progress with `getItems()`, and retry an individual file with `retryAttachment(fileId)`. Uploads never auto-resume: restored files come back retryable and wait for the user. A `FAILED` file with `localCopyMissing: true` cannot be retried — ask the user to re-add it.
+
+
+
+```typescript
+const request = await CometChat.getExistingUploadFileRequest("UID", CometChat.RECEIVER_TYPE.USER);
+
+if (request) {
+ for (const item of request.getItems()) {
+ console.log(item.fileId, "->", item.status); // "queued", "uploading", "uploaded", "failed", "rejected", …
+ if (item.status === "failed") {
+ request.retryAttachment(item.fileId);
+ }
+ }
+}
+```
+
+
+```javascript
+const request = await CometChat.getExistingUploadFileRequest("UID", CometChat.RECEIVER_TYPE.USER);
+
+if (request) {
+ for (const item of request.getItems()) {
+ console.log(item.fileId, "->", item.status); // "queued", "uploading", "uploaded", "failed", "rejected", …
+ if (item.status === "failed") {
+ request.retryAttachment(item.fileId);
+ }
+ }
+}
+```
+
+
+
+See [Media Caching](/sdk/javascript/offline-media-caching) for downloading and caching received attachments.
+
+
+For the full list of offline methods, listeners, enums, model fields, and error codes, see the [Configuration & API Reference](/sdk/javascript/offline-reference).
+
diff --git a/sdk/javascript/offline-sync.mdx b/sdk/javascript/offline-sync.mdx
new file mode 100644
index 000000000..8e7942f3d
--- /dev/null
+++ b/sdk/javascript/offline-sync.mdx
@@ -0,0 +1,295 @@
+---
+title: "Stay in Sync"
+sidebarTitle: "Stay in Sync"
+description: "Catch up on everything that changed while offline, observe sync progress, trigger a manual sync, and manage the local cache with the CometChat JavaScript SDK."
+---
+
+{/* TL;DR for Agents and Quick Reference */}
+
+
+```javascript
+// Observe the reconnect catch-up sync (conversation phase, then message phase)
+CometChat.addSyncListener("SYNC_LISTENER", new CometChat.SyncListener({
+ onConversationSyncCompleted: (conversations) => { /* blind-replace list */ },
+ onMessageSyncCompleted: (messages, outcome) => { /* merge by id */ },
+ onMessageSyncError: (error) => {},
+}));
+CometChat.removeSyncListener("SYNC_LISTENER");
+
+// Poll sync state
+const state = CometChat.getSyncState(); // "IDLE" | "SYNCING"
+const lastSync = await CometChat.getLastSyncTimestamp(); // server epoch seconds, 0 if never
+
+// Trigger a manual catch-up (callback is told whether it was accepted)
+CometChat.forceSyncNow((error) => { /* IN_PROGRESS / COOLDOWN / DISABLED / USER_NOT_LOGED_IN */ });
+
+// Observe cache rebuilds
+CometChat.addOfflineCacheResetListener("RESET_LISTENER", new CometChat.OfflineCacheResetListener({
+ onOfflineCacheReset: (reason, pendingMessagesLost) => {},
+}));
+
+// Inspect / clear the cache
+const bytes = await CometChat.getCacheSize();
+await CometChat.clearOfflineCache(); // destructive; drops the un-sent outbox
+```
+
+
+
+When the connection comes back, the SDK runs a **catch-up sync**: it refreshes the conversation list, then replays everything that changed in your messages since the last sync, and merges the results into the local cache. This page covers how to observe that sync, trigger one on demand, and manage the cache it maintains.
+
+
+Everything on this page is a no-op unless offline support is active for the logged-in user (`CometChat.isOfflineEnabled()` is `true`). See [Enable Offline Support](/sdk/javascript/offline-enable).
+
+
+## How catch-up works
+
+A sync runs automatically when the connection settles after a reconnect. It never runs on a brief connect-then-drop flap, and overlapping triggers coalesce into a single sweep. It proceeds in two phases, in order:
+
+1. **Conversation phase** — the conversation list is fetched as one fresh page (up to `maxConversations`). A conversation-list UI can blind-replace its list with the result.
+2. **Message phase** — the SDK replays the message changelog from the last sync point. New messages and updates to already-cached messages are merged into the cache.
+
+The sync is incremental and safe to repeat: it only moves forward from the last successful point, and a failed or interrupted phase simply resumes on the next reconnect. A first-ever sync (cold start) seeds its starting point from the newest conversation and fetches no message history — older history is reached by scrolling online.
+
+## Listen for sync progress
+
+Register a `CometChat.SyncListener` to observe the lifecycle of every automatic and manual sync. Each phase fires `started`, then exactly one of `completed` / `error`.
+
+
+
+```typescript
+CometChat.addSyncListener(
+ "SYNC_LISTENER",
+ new CometChat.SyncListener({
+ onConversationSyncStarted: () => {},
+ onConversationSyncCompleted: (conversations: CometChat.Conversation[]) => {
+ // The full, fresh conversation page. Blind-replace your list.
+ },
+ onConversationSyncError: (error: CometChat.CometChatException) => {},
+ onMessageSyncStarted: () => {},
+ onMessageSyncCompleted: (messages: CometChat.BaseMessage[], outcome: string) => {
+ // New + changed messages reconciled this sync. Merge by message id.
+ },
+ onMessageSyncError: (error: CometChat.CometChatException) => {},
+ })
+);
+```
+
+
+```javascript
+CometChat.addSyncListener(
+ "SYNC_LISTENER",
+ new CometChat.SyncListener({
+ onConversationSyncStarted: () => {},
+ onConversationSyncCompleted: (conversations) => {
+ // The full, fresh conversation page. Blind-replace your list.
+ },
+ onConversationSyncError: (error) => {},
+ onMessageSyncStarted: () => {},
+ onMessageSyncCompleted: (messages, outcome) => {
+ // New + changed messages reconciled this sync. Merge by message id.
+ },
+ onMessageSyncError: (error) => {},
+ })
+);
+```
+
+
+
+| Callback | When it fires |
+|---|---|
+| `onConversationSyncStarted()` | The conversation refresh began. |
+| `onConversationSyncCompleted(conversations)` | The refresh succeeded; `conversations` is the full conversation page. |
+| `onConversationSyncError(error)` | A transport or parse failure; the cache is left untouched and the refresh retries on the next reconnect. |
+| `onMessageSyncStarted()` | The message changelog sweep began. |
+| `onMessageSyncCompleted(messages, outcome)` | The sweep finished; `messages` carries the new and changed messages. |
+| `onMessageSyncError(error)` | The sweep failed; it resumes on the next reconnect. |
+
+The two phases are independent — a conversation-phase error does not stop the message phase. A phase error is informational: the cache is never left in a wrong state and the sweep resumes automatically. The error always carries a `code`: `FAILED_TO_FETCH` when the request got no answer, `ERROR_JSON_EXCEPTION` when the server answered 2xx with an unreadable body (`details.httpStatus` holds the status), otherwise the server's own code.
+
+`onMessageSyncCompleted` reports the outcome:
+
+| `SyncOutcome` | Meaning |
+|---|---|
+| `CAUGHT_UP` | The changelog was fully replayed; the cache reflects every server change up to the sync point. |
+| `PARTIAL` | A page was ingested and the sync point advanced, but a backlog remains; it drains one page per reconnect. |
+
+Remove the listener with `CometChat.removeSyncListener("SYNC_LISTENER")`. Registrations are kept across login, logout and restarts, so register once.
+
+## Receiving synced messages
+
+If you already use a `CometChat.MessageListener`, synced messages still arrive there, so you usually do not need a `SyncListener` to keep your chat up to date:
+
+- **Genuinely new** messages arrive one-by-one on `onTextMessageReceived` (and the other typed variants), with `message.getSource()` set to `SYNC`.
+- **Updates** to already-cached messages arrive together on `onMessagesSynced(messages)`.
+
+
+
+```typescript
+CometChat.addMessageListener(
+ "MSG_LISTENER",
+ new CometChat.MessageListener({
+ onTextMessageReceived: (message: CometChat.TextMessage) => {
+ if (message.getSource() === CometChat.MessageSource.SYNC) {
+ // Delivered by a catch-up sync. Suppress notification spam if you wish.
+ }
+ },
+ onMessagesSynced: (messages: CometChat.BaseMessage[]) => {
+ // Updates to already-cached messages. Merge by id.
+ },
+ })
+);
+```
+
+
+```javascript
+CometChat.addMessageListener(
+ "MSG_LISTENER",
+ new CometChat.MessageListener({
+ onTextMessageReceived: (message) => {
+ if (message.getSource() === CometChat.MessageSource.SYNC) {
+ // Delivered by a catch-up sync. Suppress notification spam if you wish.
+ }
+ },
+ onMessagesSynced: (messages) => {
+ // Updates to already-cached messages. Merge by id.
+ },
+ })
+);
+```
+
+
+
+## Check sync state
+
+Poll the current sync state and the time of the last completed sync at any time.
+
+
+
+```typescript
+const state: string = CometChat.getSyncState(); // "IDLE" or "SYNCING"
+const lastSync: number = await CometChat.getLastSyncTimestamp(); // server epoch seconds; 0 if never synced
+```
+
+
+```javascript
+const state = CometChat.getSyncState(); // "IDLE" or "SYNCING"
+const lastSync = await CometChat.getLastSyncTimestamp(); // server epoch seconds; 0 if never synced
+```
+
+
+
+`getSyncState()` returns `SYNCING` while a sweep is in flight, otherwise `IDLE` (also when offline support is disabled). `getLastSyncTimestamp()` returns a **server-provided** epoch value in seconds, never the device clock, and `0` if the cache has never synced.
+
+## Trigger a manual sync
+
+Call `forceSyncNow` to run a catch-up immediately — for example behind a pull-to-refresh. It routes through the same throttle as the automatic sync, so it will not start a second overlapping sweep or hammer the backend, and it enforces a 10-second cooldown between forced syncs.
+
+Pass a callback to learn whether the request was accepted; it receives no error (`null`) as soon as the catch-up starts. The sync's actual progress and outcome still arrive on your registered `SyncListener`.
+
+
+
+```typescript
+CometChat.forceSyncNow((error?: CometChat.CometChatException) => {
+ if (error) {
+ // Rejected — see the code.
+ } else {
+ // Accepted and started.
+ }
+});
+```
+
+
+```javascript
+CometChat.forceSyncNow((error) => {
+ if (error) {
+ // Rejected — see the code.
+ } else {
+ // Accepted and started.
+ }
+});
+```
+
+
+
+If you do not need the acceptance result, call `CometChat.forceSyncNow()` with no argument; a rejected request is silently dropped.
+
+| Rejection code | Meaning |
+|---|---|
+| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | A sync (automatic or manual) is already running. |
+| `ERROR_OFFLINE_SYNC_COOLDOWN` | A sync ran too recently; `details.retryAfterMs` carries the remaining cooldown. |
+| `ERROR_OFFLINE_DISABLED` | A user is logged in but offline support is not active. |
+| `USER_NOT_LOGED_IN` | Nobody is logged in (spelling as the SDK returns it). |
+
+## When the cache resets
+
+In rare cases the SDK cannot keep the existing cache — for example the encryption key became unrecoverable, or the database is corrupt. When this happens it rebuilds the cache from scratch and notifies an `OfflineCacheResetListener`. Any un-sent messages that were queued are lost in the rebuild; `pendingMessagesLost` reports how many, or `-1` (`CometChat.OFFLINE_PENDING_COUNT_UNKNOWN`) when the count is unknown. Each lost message that could still be read fails with `DISCARDED` on `onOutboxMessageFailed`. A logout never fires it.
+
+
+
+```typescript
+CometChat.addOfflineCacheResetListener(
+ "RESET_LISTENER",
+ new CometChat.OfflineCacheResetListener({
+ onOfflineCacheReset: (reason: string, pendingMessagesLost: number) => {
+ // The cache was rebuilt. Re-fetch anything you need; it will re-sync from the server.
+ },
+ })
+);
+```
+
+
+```javascript
+CometChat.addOfflineCacheResetListener(
+ "RESET_LISTENER",
+ new CometChat.OfflineCacheResetListener({
+ onOfflineCacheReset: (reason, pendingMessagesLost) => {
+ // The cache was rebuilt. Re-fetch anything you need; it will re-sync from the server.
+ },
+ })
+);
+```
+
+
+
+| `OfflineCacheResetReason` | Cause |
+|---|---|
+| `KEY_LOST` | The encryption key could not be recovered (for example browser site data was partly cleared); a fresh key was minted. |
+| `WRONG_KEY` | The stored key does not open the cache. |
+| `CORRUPTED` | The cache is damaged. |
+| `ESCALATED` | An unclassified open failure recurred on three consecutive launches and was escalated to a permanent rebuild. |
+| `ENCRYPTION_CHANGED` | The `encryption` setting changed, so the cache was rebuilt in the configured format. |
+
+Remove the listener with `CometChat.removeOfflineCacheResetListener("RESET_LISTENER")`. See [Encrypt the Cache](/sdk/javascript/offline-encryption) for the encryption-related reasons.
+
+
+A cache that another tab (or process) holds locked or busy is never rebuilt; offline stays off for that session instead.
+
+
+## Manage the cache
+
+Read the current cache size, or clear the cache entirely.
+
+
+
+```typescript
+const bytes: number = await CometChat.getCacheSize(); // cached content + media bytes
+await CometChat.clearOfflineCache(); // wipe the entire per-user cache
+```
+
+
+```javascript
+const bytes = await CometChat.getCacheSize(); // cached content + media bytes
+await CometChat.clearOfflineCache(); // wipe the entire per-user cache
+```
+
+
+
+`getCacheSize()` returns the size in bytes using the same measure as `maxStorageSize` — cached content before encryption, plus cached media bytes. It is not the size on disk and not everything your origin stores. It returns `0` when offline support is disabled.
+
+
+`clearOfflineCache()` is destructive and irreversible. It empties the entire per-user cache — cached messages, conversations, lists, media bytes **and the un-sent outbox**. Each still-queued `PENDING` / `SENDING` message fails with reason `DISCARDED` (`ERROR_OFFLINE_MESSAGE_DISCARDED` on its per-send listener); one already `FAILED` is removed silently. It is a no-op unless offline support is active, and the encryption key is kept.
+
+
+
+You never need to clear the cache on logout — the SDK does it for you. The cache is stored **per app and user**: a reload with a still-valid session reuses it, and it is deleted on logout, token expiry, an app id change, or when a different user logs in. With offline off in your config, any offline data an earlier build left on the device is deleted when the session ends, and `logout()` resolves after the deletion (in a browser this can take up to about 5 seconds per database while another tab still has it open). See [Offline Support](/sdk/javascript/offline-overview).
+
diff --git a/sdk/react-native/offline-cached-data.mdx b/sdk/react-native/offline-cached-data.mdx
new file mode 100644
index 000000000..f8bb39bbe
--- /dev/null
+++ b/sdk/react-native/offline-cached-data.mdx
@@ -0,0 +1,268 @@
+---
+title: "Show Cached Data"
+sidebarTitle: "Show Cached Data"
+description: "Render cached messages, conversations, and lists instantly and reconcile them against the server with the CometChat React Native Chat SDK."
+---
+
+
+
+| Field | Value |
+| --- | --- |
+| Applies to | `fetchNext()` / `fetchPrevious()` on every request class, plus `getConversation()` and `getMessageDetails()` |
+| How to opt in | Pass a listener. A read with no listener gets the server result only. |
+| Order | `onCached…` (instant, may be empty) → `onServer…` (replaces the same slice) |
+| Offline | After the cached page the read ends with `onError` (`FAILED_TO_FETCH`) and the Promise rejects |
+| Prerequisites | [Offline support enabled](/sdk/react-native/offline-enable) |
+
+```typescript
+const request = new CometChat.MessagesRequestBuilder().setUID("UID").setLimit(30).build();
+
+request.fetchPrevious({
+ onCachedMessages: (messages) => { /* Phase 1: local cache. Render immediately. */ },
+ onServerMessages: (messages) => { /* Phase 2: server. Reconcile, don't append. */ },
+ onError: (error) => { /* Offline ends here, after the cached page. */ },
+});
+
+CometChat.getConversation("UID", CometChat.RECEIVER_TYPE.USER, conversationDetailsListener);
+CometChat.getMessageDetails(messageId, messageDetailsListener);
+```
+
+
+
+With offline support enabled, every list and lookup the SDK already offers can answer from the on-device cache first and then from the server. Your UI paints instantly from the cache, then reconciles when the server replies. This page describes that contract, the reads it applies to, and how to merge the two phases.
+
+## The two-phase contract
+
+Pass a listener to a read and it fires **up to two** callbacks, always in this order:
+
+
+
+The SDK delivers a page from the local cache **first**. It fires **even when the page is empty** — an empty cached page is indistinguishable from a cache miss, by design. Render it right away.
+
+
+After a successful server response, the SDK delivers the server's copy for the **same slice** and writes it through to the cache before the callback. Reconcile your displayed list against it.
+
+
+
+Three rules follow from this:
+
+- **The Promise is unchanged.** The read still returns a Promise that settles on the **server** result. The cached page never settles it, and a read with no listener receives no cached phase. Consume the final result from one channel — the Promise or the `onServer…` callback.
+- **Offline, the read ends with an error.** After the cached page, a read whose server phase cannot be produced because the device is offline settles with `onError` and rejects the Promise with the SDK's not-connected error, code `FAILED_TO_FETCH`. The call always settles — it never hangs.
+- **The server phase never fires offline.** It fires only on a genuine server response. Never treat a cached page as "end of history"; an empty cached page only means the end of the *cached* window.
+
+
+With offline support **off**, these listeners behave like a plain read: the cached phase never fires, and the server result arrives in `onServer…` alongside the Promise. Nothing changes until you enable offline ([Enable Offline Support](/sdk/react-native/offline-enable)). Requires SDK **4.2.0**.
+
+
+## Read messages
+
+`MessagesRequest` supports both directions. `fetchPrevious` pages toward older messages; `fetchNext` pages toward newer ones. Both accept a `MessageFetchListener`.
+
+
+
+```typescript
+const request: CometChat.MessagesRequest = new CometChat.MessagesRequestBuilder()
+ .setUID("UID")
+ .setLimit(30)
+ .build();
+
+request.fetchPrevious({
+ onCachedMessages: (messages: CometChat.BaseMessage[]) => {
+ // Render the cached page immediately.
+ },
+ onServerMessages: (messages: CometChat.BaseMessage[]) => {
+ // Reconcile this slice against the cached page, keyed by message id.
+ },
+ onError: (error: CometChat.CometChatException) => {
+ console.log("Fetch failed:", error.code);
+ },
+});
+```
+
+
+```javascript
+const request = new CometChat.MessagesRequestBuilder()
+ .setUID("UID")
+ .setLimit(30)
+ .build();
+
+request.fetchPrevious({
+ onCachedMessages: (messages) => {
+ // Render the cached page immediately.
+ },
+ onServerMessages: (messages) => {
+ // Reconcile this slice against the cached page, keyed by message id.
+ },
+ onError: (error) => {
+ console.log("Fetch failed:", error.code);
+ },
+});
+```
+
+
+
+## Read conversations
+
+`ConversationsRequest` pages in one direction with `fetchNext`, accepting a `ConversationFetchListener`.
+
+
+
+```typescript
+const request: CometChat.ConversationsRequest = new CometChat.ConversationsRequestBuilder()
+ .setLimit(30)
+ .build();
+
+request.fetchNext({
+ onCachedConversations: (conversations: CometChat.Conversation[]) => {
+ // Render the cached list immediately.
+ },
+ onServerConversations: (conversations: CometChat.Conversation[]) => {
+ // Reconcile, keyed by conversation id.
+ },
+ onError: (error: CometChat.CometChatException) => {
+ console.log("Fetch failed:", error.code);
+ },
+});
+```
+
+
+```javascript
+const request = new CometChat.ConversationsRequestBuilder()
+ .setLimit(30)
+ .build();
+
+request.fetchNext({
+ onCachedConversations: (conversations) => {
+ // Render the cached list immediately.
+ },
+ onServerConversations: (conversations) => {
+ // Reconcile, keyed by conversation id.
+ },
+ onError: (error) => {
+ console.log("Fetch failed:", error.code);
+ },
+});
+```
+
+
+
+## Read the other lists
+
+Every remaining list read accepts the same optional listener. Each listener has cached/server callbacks of the matching type plus `onError`.
+
+| Request | Fetch method(s) | Listener | Cached / server callback |
+|---|---|---|---|
+| `MessagesRequest` | `fetchPrevious`, `fetchNext` | `MessageFetchListener` | `onCachedMessages` / `onServerMessages` |
+| `ConversationsRequest` | `fetchNext` | `ConversationFetchListener` | `onCachedConversations` / `onServerConversations` |
+| `ThreadsRequest` | `fetchNext` | `ThreadFetchListener` (alias `ThreadsFetchListener`) | `onCachedThreads` / `onServerThreads` |
+| `UsersRequest` | `fetchNext` | `UserFetchListener` | `onCachedUsers` / `onServerUsers` |
+| `BlockedUsersRequest` | `fetchNext` | `UserFetchListener` | `onCachedUsers` / `onServerUsers` |
+| `GroupsRequest` | `fetchNext` | `GroupFetchListener` | `onCachedGroups` / `onServerGroups` |
+| `GroupMembersRequest` | `fetchNext` | `GroupMemberFetchListener` | `onCachedGroupMembers` / `onServerGroupMembers` |
+| `BannedMembersRequest` | `fetchNext` | `BannedGroupMemberFetchListener` | `onCachedBannedGroupMembers` / `onServerBannedGroupMembers` |
+| `ReactionsRequest` | `fetchPrevious`, `fetchNext` | `ReactionFetchListener` | `onCachedReactions` / `onServerReactions` |
+| `NotificationFeedRequest` | `fetchNext` | `FeedItemFetchListener` (alias `NotificationFeedFetchListener`) | `onCachedNotificationFeedItems` / `onServerNotificationFeedItems` |
+
+The call shape is identical to the conversations example above — swap the request, the listener, and the callback names.
+
+
+
+```typescript
+const request: CometChat.UsersRequest = new CometChat.UsersRequestBuilder()
+ .setLimit(30)
+ .build();
+
+request.fetchNext({
+ onCachedUsers: (users: CometChat.User[]) => { /* render */ },
+ onServerUsers: (users: CometChat.User[]) => { /* reconcile, keyed by uid */ },
+ onError: (error: CometChat.CometChatException) => console.log(error.code),
+});
+```
+
+
+```javascript
+const request = new CometChat.UsersRequestBuilder()
+ .setLimit(30)
+ .build();
+
+request.fetchNext({
+ onCachedUsers: (users) => { /* render */ },
+ onServerUsers: (users) => { /* reconcile, keyed by uid */ },
+ onError: (error) => console.log(error.code),
+});
+```
+
+
+
+
+For `ReactionsRequest`, `fetchPrevious` and `fetchNext` are cached independently — each direction keeps its own cached pages. Users, groups, members, banned members, reactions, and the notification feed are served from a per-request snapshot rather than a shared store; the number of snapshots and how long they stay valid are set by `maxSnapshotQueries` and `snapshotTtlDays` in the [Configuration & API Reference](/sdk/react-native/offline-reference).
+
+
+## Read a single item
+
+`getConversation` and `getMessageDetails` each accept an optional listener as a last argument. Unlike the list reads, the cached callback fires **only when the item is cached** — never with an empty or undefined object. A cache miss goes straight to the server phase online, or straight to `onError` offline. The Promise still settles on the server result.
+
+
+
+```typescript
+CometChat.getConversation("UID", CometChat.RECEIVER_TYPE.USER, {
+ onCachedConversation: (conversation?: CometChat.Conversation) => {
+ // Fires only on a cache hit.
+ },
+ onServerConversation: (conversation: CometChat.Conversation) => {
+ // Server copy, written through before delivery.
+ },
+ onError: (error: CometChat.CometChatException) => {
+ console.log("Lookup failed:", error.code);
+ },
+});
+
+CometChat.getMessageDetails(messageId, {
+ onCachedMessage: (message?: CometChat.BaseMessage) => { /* cache hit only */ },
+ onServerMessage: (message: CometChat.BaseMessage) => { /* server copy */ },
+ onError: (error: CometChat.CometChatException) => console.log(error.code),
+});
+```
+
+
+```javascript
+CometChat.getConversation("UID", CometChat.RECEIVER_TYPE.USER, {
+ onCachedConversation: (conversation) => {
+ // Fires only on a cache hit.
+ },
+ onServerConversation: (conversation) => {
+ // Server copy, written through before delivery.
+ },
+ onError: (error) => {
+ console.log("Lookup failed:", error.code);
+ },
+});
+
+CometChat.getMessageDetails(messageId, {
+ onCachedMessage: (message) => { /* cache hit only */ },
+ onServerMessage: (message) => { /* server copy */ },
+ onError: (error) => console.log(error.code),
+});
+```
+
+
+
+The single-conversation listener is `ConversationDetailsListener`; the single-message listener is `MessageDetailFetchListener` (alias `MessageDetailsListener`).
+
+## Merge the two phases
+
+The server phase **replaces the same slice** — it is a reconcile, not a second batch. Render the cached page straight away, then on the server phase diff it into your displayed list keyed by the item's stable id (message id, conversation id, uid, guid). Do not append the server page to the cached one.
+
+
+Key your list by id so the two phases merge cleanly: update items that changed, add new ones, and drop cached items the server slice omits. To page further back, issue the next `fetchPrevious()` / `fetchNext()` rather than treating the server phase as more pages.
+
+
+## When the cached page is empty
+
+The cache answers a read **only if every filter on it is locally computable**. A single server-only filter makes the entire cached phase return an empty list — with no error and no warning — while the server phase still answers in full. The guiding rule is *better empty than wrong*.
+
+The most common server-only filter is `searchKeyword`, which blanks the cached phase on every `MessagesRequest` and `ConversationsRequest`. Other examples on `MessagesRequest` are `hasLinks`, `updatesOnly`, `interactionGoalCompletedOnly`, setting both `UID` and `GUID` outside a thread, and the pinned-only and saved-only reads. Presence filters such as `status` on the user and member lists also fall back to the server.
+
+
+An offline-critical screen must use only cache-supported filters — one innocuous builder flag can silently disable the cached phase for a whole screen. The full per-builder matrix lives in the [Configuration & API Reference](/sdk/react-native/offline-reference).
+
diff --git a/sdk/react-native/offline-enable.mdx b/sdk/react-native/offline-enable.mdx
new file mode 100644
index 000000000..694a43593
--- /dev/null
+++ b/sdk/react-native/offline-enable.mdx
@@ -0,0 +1,205 @@
+---
+title: "Enable Offline Support"
+sidebarTitle: "Enable Offline Support"
+description: "Turn on local caching and the durable send outbox, and verify offline support is active with the CometChat React Native Chat SDK."
+---
+
+
+
+| Field | Value |
+| --- | --- |
+| Package | `@cometchat/chat-sdk-react-native` (4.2.0 or later) |
+| Config class | `CometChat.OfflineConfig` — `new CometChat.OfflineConfig({ enabled: true })` |
+| Attach to | `new CometChat.AppSettingsBuilder().setOfflineConfig(config)` (or the `.offline(true)` shorthand) |
+| Install (peers) | `@op-engineering/op-sqlite` 9.0.0+ (required), `react-native-fs`, `react-native-keychain` |
+| Verify | `CometChat.isOfflineEnabled()` → `boolean` |
+| Constraints | Off by default. Not available in Expo Go — use a development build or `expo prebuild`. |
+
+```typescript
+const offlineConfig = new CometChat.OfflineConfig({ enabled: true });
+
+const appSettings = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig(offlineConfig) // explicit config; its `enabled` wins
+ // .offline(true) // shorthand: enable with all defaults
+ .build();
+
+await CometChat.init("APP_ID", appSettings);
+
+const active: boolean = CometChat.isOfflineEnabled();
+```
+
+
+
+Offline support is **off by default**. You turn it on once, at initialization, by attaching an `OfflineConfig` to your `AppSettings`. Until you do, the SDK behaves exactly as before: no local store is opened and no native module is loaded. This page covers the minimal setup; for every tunable option see the [Configuration & API Reference](/sdk/react-native/offline-reference).
+
+
+
+
+Offline support requires CometChat React Native Chat SDK **4.2.0** or later.
+
+The local database is provided by a native module that you install in your app. It is an optional peer dependency of the SDK, so installing the SDK never requires it:
+
+| Package | Needed for |
+| --- | --- |
+| `@op-engineering/op-sqlite` (9.0.0 or later) | The local store. **Required for offline.** Without it the SDK runs online-only. |
+| `react-native-fs` | Media caching and durable media sends. |
+| `react-native-keychain` | Storing the encryption key, only with [encryption](/sdk/react-native/offline-encryption). |
+
+```bash
+npm install @op-engineering/op-sqlite react-native-fs
+```
+
+
+`@op-engineering/op-sqlite` is a native module. In Expo, use a development build or `expo prebuild` — offline does not run in Expo Go (the SDK then runs online-only). Pick an `op-sqlite` release that supports your React Native version.
+
+
+If you are new to the SDK, complete the [standard setup](/sdk/react-native/setup-sdk) first; the steps below only add offline configuration to your existing `init`.
+
+
+
+Construct an `OfflineConfig` and set `enabled: true`. Every other option has a sensible default, so set only what you need. Invalid values are normalised to the default; they are never rejected.
+
+
+
+```typescript
+const offlineConfig: CometChat.OfflineConfig = new CometChat.OfflineConfig({
+ enabled: true,
+});
+```
+
+
+```javascript
+const offlineConfig = new CometChat.OfflineConfig({
+ enabled: true,
+});
+```
+
+
+
+
+
+Pass the config to `AppSettingsBuilder.setOfflineConfig(...)`. To enable with all defaults instead of building a config, use the `offline(boolean)` shorthand.
+
+
+
+```typescript
+const appSettings: CometChat.AppSettings = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig(offlineConfig) // explicit config
+ // .offline(true) // shorthand: enable with defaults
+ .build();
+```
+
+
+```javascript
+const appSettings = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig(offlineConfig) // explicit config
+ // .offline(true) // shorthand: enable with defaults
+ .build();
+```
+
+
+
+
+If you call both `setOfflineConfig(...)` and `offline(boolean)`, the explicit `OfflineConfig` wins — its `enabled` value is authoritative.
+
+
+
+
+Pass the settings to `CometChat.init(...)` as you do today. No other call changes.
+
+
+
+```typescript
+CometChat.init("APP_ID", appSettings).then(
+ () => {
+ console.log("Initialization completed successfully");
+ },
+ (error: CometChat.CometChatException) => {
+ console.log("Initialization failed with error:", error);
+ }
+);
+```
+
+
+```javascript
+CometChat.init("APP_ID", appSettings).then(
+ () => {
+ console.log("Initialization completed successfully");
+ },
+ (error) => {
+ console.log("Initialization failed with error:", error);
+ }
+);
+```
+
+
+
+
+
+Call `CometChat.isOfflineEnabled()`. It returns `true` only while offline support is actually active for the logged-in user — so it is `false` before anyone logs in, even when you enabled it in `AppSettings`. It is also `false` if the native store could not open or if CometChat has switched offline off for your app. Read it at the moment of each decision and keep no copy of the result.
+
+
+
+```typescript
+const active: boolean = CometChat.isOfflineEnabled();
+console.log("Offline support active:", active);
+```
+
+
+```javascript
+const active = CometChat.isOfflineEnabled();
+console.log("Offline support active:", active);
+```
+
+
+
+
+
+
+## Enable from a settings file
+
+If you initialize with `CometChat.initFromSettings(settings)`, pass your parsed `cometchat-settings.json` object and add an `offline` key under `chatSDK`. It accepts a boolean shorthand or a full configuration object.
+
+
+In the object form, `enabled` is `false` unless you set it to `true` — passing other options alone does not turn offline on.
+
+
+```json cometchat-settings.json
+{
+ "appId": "APP_ID",
+ "region": "REGION",
+ "chatSDK": {
+ "offline": true
+ }
+}
+```
+
+
+
+```typescript
+import settings from "./cometchat-settings.json";
+
+CometChat.initFromSettings(settings).then(
+ () => console.log("Initialization completed successfully"),
+ (error: CometChat.CometChatException) => console.log("Initialization failed with error:", error)
+);
+```
+
+
+```javascript
+import settings from "./cometchat-settings.json";
+
+CometChat.initFromSettings(settings).then(
+ () => console.log("Initialization completed successfully"),
+ (error) => console.log("Initialization failed with error:", error)
+);
+```
+
+
+
+## Next steps
+
+Offline support is now active for the logged-in user. Continue with [Show Cached Data](/sdk/react-native/offline-cached-data) to adopt the two-phase listeners, and [Send While Offline](/sdk/react-native/offline-send) for the durable outbox. Tune caps, TTLs, media, and encryption from the [Configuration & API Reference](/sdk/react-native/offline-reference).
diff --git a/sdk/react-native/offline-encryption.mdx b/sdk/react-native/offline-encryption.mdx
new file mode 100644
index 000000000..07d7ed25b
--- /dev/null
+++ b/sdk/react-native/offline-encryption.mdx
@@ -0,0 +1,185 @@
+---
+title: "Encrypt the Cache"
+sidebarTitle: "Encrypt the Cache"
+description: "Encrypt the offline message cache at rest with SQLCipher, choose what happens when encryption cannot run, and inspect the live encryption status with the CometChat React Native Chat SDK."
+---
+
+
+
+| Field | Value |
+| --- | --- |
+| 1. SQLCipher in your app | Add `{"op-sqlite": {"sqlcipher": true}}` to **your app's** `package.json`, then rebuild |
+| 2. A key store | Install `react-native-keychain`, or call `CometChat.setOfflineKeyVault(vault)` before `init` |
+| 3. Turn it on | `new CometChat.OfflineConfig({ enabled: true, encryption: true })` |
+| Failure behavior | `encryptionFailureBehavior`: `ONLINE_ONLY` (default) or `PLAINTEXT_FALLBACK` |
+| Inspect | `CometChat.getOfflineEncryptionStatus()` → `{ enabled, atRest, keyStorage, cipherAvailable, cipher, leakage }` |
+
+```typescript
+// package.json (your app) — then rebuild
+// "op-sqlite": { "sqlcipher": true }
+
+const offlineConfig = new CometChat.OfflineConfig({
+ enabled: true,
+ encryption: true,
+ encryptionFailureBehavior: CometChat.EncryptionFailureBehavior.ONLINE_ONLY, // default; never writes plaintext
+});
+
+const status = CometChat.getOfflineEncryptionStatus();
+status.enabled; // what was configured
+status.atRest; // "SQLCIPHER" or "NONE"
+status.keyStorage; // "KEYSTORE", "SOFTWARE" or "NONE"
+status.cipherAvailable; // is SQLCipher present in this build?
+status.cipher; // engine cipher_version once proven, else null
+status.leakage; // what is never covered (media files on disk)
+```
+
+
+
+Offline support keeps the logged-in user's messages in a local database on the device. Turn encryption on to store that database **encrypted at rest** using [SQLCipher](https://www.zetetic.net/sqlcipher/). Encryption is **opt-in** and off by default; nothing changes until you set `encryption: true`. The minimum SDK version is **4.2.0**.
+
+
+Encryption covers the **message database only**. Downloaded media files (photos, voice notes, documents) stay as ordinary unencrypted files on disk, because their local paths are handed to image and video loaders. See [What is and isn't encrypted](#what-is-and-isn-t-encrypted). This is not end-to-end encryption: CometChat's servers still see message content.
+
+
+## Prepare your app
+
+The SDK does **not** bundle the cipher engine or a key store, so apps that do not use encryption pay nothing for them. If you enable encryption you must provide both, on your side.
+
+
+
+The local database is provided by `@op-engineering/op-sqlite`. Add its SQLCipher flag to **your app's** `package.json` and rebuild the native project:
+
+```json package.json
+{
+ "op-sqlite": {
+ "sqlcipher": true
+ }
+}
+```
+
+
+Install `react-native-keychain`; the SDK detects it automatically. Alternatively, supply your own store with `CometChat.setOfflineKeyVault(vault)` before `init`. A vault implements `get(service)`, `set(service, hexKey)`, `delete(service)` and `kind()`, where `kind()` reports `"KEYSTORE"`, `"SOFTWARE"`, or `"NONE"`. Pass `null` to return to auto-detection.
+
+
+
+
+If either is missing, the cache **cannot run encrypted**. What happens then is decided by [`encryptionFailureBehavior`](#choose-what-happens-when-encryption-cannot-run) — by default the session runs online-only (no local cache). The SDK never creates a database that is labelled encrypted but is not.
+
+
+## Turn on encryption
+
+Set `encryption: true` on the `OfflineConfig` you pass to the SDK during initialization (see [Enable Offline Support](/sdk/react-native/offline-enable)). Like the other options, it is read once at init and fixed for the session.
+
+
+
+```typescript
+const offlineConfig: CometChat.OfflineConfig = new CometChat.OfflineConfig({
+ enabled: true,
+ encryption: true,
+ encryptionFailureBehavior: CometChat.EncryptionFailureBehavior.ONLINE_ONLY,
+});
+```
+
+
+```javascript
+const offlineConfig = new CometChat.OfflineConfig({
+ enabled: true,
+ encryption: true,
+ encryptionFailureBehavior: CometChat.EncryptionFailureBehavior.ONLINE_ONLY,
+});
+```
+
+
+
+## Choose what happens when encryption cannot run
+
+`encryptionFailureBehavior` decides what the cache does when encryption **cannot be applied at all** on this build or device — for example SQLCipher is not compiled into the app, or there is no secure key store. It governs only that case: a transient failure still retries without writing plaintext, and a lost or wrong key self-heals by wiping and rebuilding the cache **encrypted**.
+
+| Value | Behavior | Writes plaintext? |
+|---|---|---|
+| `ONLINE_ONLY` *(default)* | Do not open a local cache at all — the session runs online-only. Makes a setup mistake visible: offline simply does not work until the missing piece is added. | No |
+| `PLAINTEXT_FALLBACK` | Open an **unencrypted** cache so offline keeps working. | Yes |
+
+
+`PLAINTEXT_FALLBACK` means cached message content may be stored **unencrypted** on disk whenever encryption cannot run. This availability-over-guarantee trade is wrong for compliance use — keep the default `ONLINE_ONLY` if cached content must never be written in the clear.
+
+
+## Key storage
+
+You do not generate or manage the encryption key. The SDK derives a per-user key (scoped to the app ID and the logged-in user's UID) and keeps it in the platform keychain through `react-native-keychain`, or in the vault you supplied. The key never leaves the device and is never returned by any API. If the key is lost or the stored database no longer matches it, the cache resets and rebuilds — see [Cache resets](#cache-resets-on-key-loss).
+
+## Check encryption status
+
+`CometChat.getOfflineEncryptionStatus()` returns an object describing what encryption is **actually** doing — never what was merely requested. It is synchronous, safe to call at any time, and never returns the key or any part of it.
+
+
+
+```typescript
+const status = CometChat.getOfflineEncryptionStatus();
+console.log(
+ `enabled=${status.enabled} atRest=${status.atRest} keyStorage=${status.keyStorage}` +
+ ` cipherAvailable=${status.cipherAvailable} cipher=${status.cipher}`
+);
+```
+
+
+```javascript
+const status = CometChat.getOfflineEncryptionStatus();
+console.log(
+ `enabled=${status.enabled} atRest=${status.atRest} keyStorage=${status.keyStorage}` +
+ ` cipherAvailable=${status.cipherAvailable} cipher=${status.cipher}`
+);
+```
+
+
+
+| Field | Type | Meaning |
+|---|---|---|
+| `enabled` | `boolean` | What was **configured** (`encryption`), not what was achieved. `false` while CometChat has offline switched off for your app. |
+| `atRest` | `string` | Coverage actually achieved: `"SQLCIPHER"` once an encrypted open is proven, otherwise `"NONE"`. |
+| `keyStorage` | `string` | `"KEYSTORE"` when the key came from the platform keychain; `"SOFTWARE"` when a vault reports a software fallback; `"NONE"` when this session holds no key. Read `NONE` as "no key in use", not "no key exists". |
+| `cipherAvailable` | `boolean` | Whether the SQLCipher engine is present in this build. `false` when encryption is off or SQLCipher is missing. |
+| `cipher` | `string \| null` | The engine's `cipher_version` once an encrypted open is proven, else `null`. |
+| `leakage` | `string` | What encryption at rest never covers — the media files stored outside the database. |
+
+
+The two most useful support signals are `enabled === true` with `cipherAvailable === false` (SQLCipher is not compiled into the app — add the `op-sqlite` flag and rebuild) and `enabled === true` with `keyStorage === "NONE"` (no secure key store — install `react-native-keychain` or set a vault).
+
+
+## What is and isn't encrypted
+
+| Data | Encrypted at rest? |
+|---|---|
+| Message database (messages, conversations, metadata, the pending-send outbox) | Yes, when `atRest` is `SQLCIPHER` — the whole database file, including indexes and journals |
+| Downloaded media files on disk (images, video, audio, documents) | **No** — stored as ordinary files outside the database |
+
+Media bytes live outside the database because `Attachment.getLocalPath()` is public API and those paths are passed straight to media loaders. "Encrypted at rest" here always means the message database, never the media cache.
+
+## Cache resets on key loss
+
+If the key can no longer decrypt the stored database, the SDK wipes the local cache and rebuilds it **encrypted** — it never silently falls back to plaintext for a cache that could be encrypted. Any unsent messages still queued in the outbox at that point are lost and each is failed through its send listener. Changing the `encryption` setting between launches also rebuilds the cache in the new format.
+
+Register an `OfflineCacheResetListener` to be told when this happens:
+
+
+
+```typescript
+CometChat.addOfflineCacheResetListener("LISTENER_ID", {
+ onOfflineCacheReset: (reason: CometChat.OfflineCacheResetReason, pendingMessagesLost: number) => {
+ console.warn("Cache reset:", reason, "pending lost:", pendingMessagesLost);
+ },
+});
+```
+
+
+```javascript
+CometChat.addOfflineCacheResetListener("LISTENER_ID", {
+ onOfflineCacheReset: (reason, pendingMessagesLost) => {
+ console.warn("Cache reset:", reason, "pending lost:", pendingMessagesLost);
+ },
+});
+```
+
+
+
+The encryption-related reasons are `KEY_LOST`, `WRONG_KEY`, `CORRUPTED`, and `ENCRYPTION_CHANGED` (you turned encryption on or off between launches). `pendingMessagesLost` is `-1` (`CometChat.OFFLINE_PENDING_COUNT_UNKNOWN`) when the queued messages could not be counted. See [Stay in Sync](/sdk/react-native/offline-sync) for the full cache-reset flow and the complete list of reasons, and the [API Reference](/sdk/react-native/offline-reference) for every field and enum.
diff --git a/sdk/react-native/offline-media-caching.mdx b/sdk/react-native/offline-media-caching.mdx
new file mode 100644
index 000000000..89f1a00f3
--- /dev/null
+++ b/sdk/react-native/offline-media-caching.mdx
@@ -0,0 +1,186 @@
+---
+title: "Media Caching"
+sidebarTitle: "Media Caching"
+description: "Cache image, video, audio and file attachments on the device so media stays viewable offline with the CometChat React Native Chat SDK."
+---
+
+
+
+| Field | Value |
+| --- | --- |
+| Install | `react-native-fs` (peer dependency) — without it media is not cached |
+| Mode (init, fixed for the session) | `new CometChat.OfflineConfig({ enabled: true, mediaCaching: CometChat.MediaCachingMode.AUTO })` — `AUTO` (default) or `OFF` |
+| Budget | `maxMediaCacheSize` (bytes, default 500 MB, `0` = unlimited) |
+| Read the mode | `CometChat.getMediaCachingMode()` — `OFF` whenever offline is not active |
+| Cached path | `attachment.getLocalPath()` · `await CometChat.getCachedMediaPath(attachment)` |
+| Download on demand | `await CometChat.downloadMedia(attachment, listener?)` → `string \| null` |
+| Background downloads | `await CometChat.addMediaDownloadListener(id, listener)` / `removeMediaDownloadListener(id)` |
+
+```typescript
+const offlineConfig = new CometChat.OfflineConfig({
+ enabled: true,
+ mediaCaching: CometChat.MediaCachingMode.AUTO,
+ maxMediaCacheSize: 500 * 1024 * 1024,
+});
+
+const attachment = mediaMessage.getAttachment();
+const localPath = attachment.getLocalPath(); // set when bytes are cached
+const cached = await CometChat.getCachedMediaPath(attachment); // null if not cached
+
+const path = await CometChat.downloadMedia(attachment, {
+ onMediaProgress: (messageId, attachmentKey, percent) => {},
+ onMediaDownloaded: (messageId, attachmentKey, localPath) => {},
+ onMediaDownloadFailed: (messageId, attachmentKey, error) => {},
+});
+
+await CometChat.addMediaDownloadListener("MEDIA_LISTENER", {
+ onMediaDownloaded: (messageId, attachmentKey, localPath) => {},
+ onMediaDownloadFailed: (messageId, attachmentKey, error) => {},
+});
+```
+
+
+
+With offline support on, the SDK can cache attachment bytes durably on the device so media stays viewable offline — even after the server's presigned URL has expired. This page covers the caching modes, what downloads automatically, how to read cached bytes, and how to download media on demand.
+
+
+Media caching applies only while offline support is active for the logged-in user (`CometChat.isOfflineEnabled()` is `true`), and needs the optional `react-native-fs` peer dependency. See [Enable Offline Support](/sdk/react-native/offline-enable).
+
+
+
+On recent React Native versions, `react-native-fs` 2.20.0 can crash the app on Android when a download loses the network part-way. Use the maintained fork under the same module name (`"react-native-fs": "npm:@dr.pogodin/react-native-fs@~2.40.4"`; it needs the New Architecture and React Native 0.73+, and has no default export), patch `RNFSManager.reject`, or set `mediaCaching` to `OFF`.
+
+
+## Caching modes
+
+The caching mode is set once, in the `OfflineConfig` you pass at initialization, and is fixed for the SDK session. There is no runtime switch — to change it, re-initialize the SDK with a new config.
+
+| `MediaCachingMode` | Behavior |
+|---|---|
+| `AUTO` (default) | Incoming media bytes are eagerly downloaded and cached on any network, then LRU-managed. The outgoing copy is retained after the server acknowledges the send. |
+| `OFF` | Incoming media bytes are **not** cached. The message metadata (including the file URL) is still cached, so the message appears offline and the UI loads media live from the URL when online. The outgoing copy is dropped after the send is acknowledged. |
+
+Sending media always works in both modes.
+
+```typescript
+const offlineConfig = new CometChat.OfflineConfig({
+ enabled: true,
+ mediaCaching: CometChat.MediaCachingMode.AUTO,
+ maxMediaCacheSize: 500 * 1024 * 1024,
+});
+```
+
+Read the mode in effect at runtime with `CometChat.getMediaCachingMode()`. It returns `MediaCachingMode.OFF` whenever offline support is not active, whatever the app configured, so a UI layer can rely on it without reading the config itself.
+
+## What downloads automatically
+
+In `AUTO` mode, media bytes are downloaded and cached automatically on the two live receive paths:
+
+- a **realtime** media message received while connected, and
+- opening a chat **online** (the server read refreshes and caches its media).
+
+A media message that arrives through a reconnect catch-up [sync](/sdk/react-native/offline-sync) is cached as **metadata only** and renders as a placeholder; its bytes download lazily the next time you open that chat online. This holds in both `AUTO` and `OFF`.
+
+In `OFF` mode, nothing is auto-downloaded on any path; media loads live from its URL when online.
+
+## Reading cached media
+
+When bytes are already cached, the SDK **hydrates the local path automatically** on every message it delivers — you do not have to ask. Read it straight off the attachment:
+
+```typescript
+const attachment = mediaMessage.getAttachment();
+const localPath = attachment.getLocalPath(); // defined when bytes are cached
+if (localPath) {
+ // Render directly from disk — no network fetch.
+}
+```
+
+To look up the cached path explicitly, call `getCachedMediaPath`. It never downloads, and resolves `null` when the bytes are not cached (call `downloadMedia` to fetch them). It accepts a `MediaMessage` or a bare `Attachment`.
+
+```typescript
+const path: string | null = await CometChat.getCachedMediaPath(attachment);
+```
+
+
+Each attachment of a multi-attachment message resolves independently. An attachment with no cached bytes has no local path and the UI loads it from the URL as before.
+
+
+## Downloading on demand
+
+Call `downloadMedia` to fetch and cache an attachment's bytes — for example when the user taps a placeholder. The SDK resolves cached bytes first, then a valid URL, then a re-signed URL if the presigned one expired. The Promise resolves with the local path, and also sets it on the attachment. It resolves `null` if the bytes could not be obtained, or if offline support is off.
+
+
+
+```typescript
+const path: string | null = await CometChat.downloadMedia(attachment, {
+ onMediaDownloaded: (messageId: string, attachmentKey: string, localPath: string) => {
+ // Bytes cached; render from localPath.
+ },
+ onMediaProgress: (messageId: string, attachmentKey: string, percent: number) => {
+ // 0..100, best-effort.
+ },
+ onMediaDownloadFailed: (messageId: string, attachmentKey: string, error: { code: string; message: string }) => {
+ console.log(error.code); // MEDIA_OFFLINE_NOT_CACHED | MEDIA_DOWNLOAD_FAILED | ERROR_OFFLINE_DISABLED
+ },
+});
+```
+
+
+```javascript
+const path = await CometChat.downloadMedia(attachment, {
+ onMediaDownloaded: (messageId, attachmentKey, localPath) => {
+ // Bytes cached; render from localPath.
+ },
+ onMediaProgress: (messageId, attachmentKey, percent) => {
+ // 0..100, best-effort.
+ },
+ onMediaDownloadFailed: (messageId, attachmentKey, error) => {
+ console.log(error.code); // MEDIA_OFFLINE_NOT_CACHED | MEDIA_DOWNLOAD_FAILED | ERROR_OFFLINE_DISABLED
+ },
+});
+```
+
+
+
+`onMediaProgress(…, percent)` reports `0..100`, best-effort. It is emitted only when the server reports a content length; for an unknown-length download it is skipped and the call goes straight to `onMediaDownloaded`. Progress never blocks or fails the download. All three callbacks are optional.
+
+The listener also accepts the shorter shape `{ onSuccess(localPath), onProgress(percent), onError(error) }`.
+
+## Observe background downloads
+
+In `AUTO` mode, incoming media downloads in the background with no per-call callback. Register a media download listener to learn when the bytes land (or fail) so a UI tile can refresh.
+
+
+
+```typescript
+await CometChat.addMediaDownloadListener("MEDIA_LISTENER", {
+ onMediaDownloaded: (messageId: string, attachmentKey: string, localPath: string) => {
+ // messageId: the message's server id (or local id if not yet sent)
+ // attachmentKey: the attachment URL without its query string
+ // localPath: where the bytes landed
+ },
+ onMediaDownloadFailed: (messageId: string, attachmentKey: string, error: { code: string; message: string }) => {},
+});
+```
+
+
+```javascript
+await CometChat.addMediaDownloadListener("MEDIA_LISTENER", {
+ onMediaDownloaded: (messageId, attachmentKey, localPath) => {
+ // messageId: the message's server id (or local id if not yet sent)
+ // attachmentKey: the attachment URL without its query string
+ // localPath: where the bytes landed
+ },
+ onMediaDownloadFailed: (messageId, attachmentKey, error) => {},
+});
+```
+
+
+
+Remove the listener with `CometChat.removeMediaDownloadListener("MEDIA_LISTENER")`. The background listener carries no progress callback — progress is reported only on the explicit `downloadMedia` path above.
+
+## Media cache size limit
+
+Cached media bytes are bounded by `maxMediaCacheSize` (default **500 MB**; `0` means unlimited). When the cache exceeds the limit, the least recently accessed media is deleted first until it is back under budget. Media belonging to un-sent messages and in-progress uploads is never evicted and does not count toward the budget.
+
+Media is stored as files in an app-private, per-user directory; the location is not configurable. Eviction runs silently in the background — there is no cache-limit callback. To reason about size, use `CometChat.getCacheSize()` and the configured limits. See the [configuration reference](/sdk/react-native/offline-reference) for the full set of options.
diff --git a/sdk/react-native/offline-overview.mdx b/sdk/react-native/offline-overview.mdx
new file mode 100644
index 000000000..d780e89d4
--- /dev/null
+++ b/sdk/react-native/offline-overview.mdx
@@ -0,0 +1,95 @@
+---
+title: "Offline Support"
+sidebarTitle: "Offline Support"
+description: "Understand how local caching, the send outbox, and reconnect catch-up keep chat working without a connection with the CometChat React Native Chat SDK."
+---
+
+
+
+| Field | Value |
+| --- | --- |
+| Package | `@cometchat/chat-sdk-react-native` (4.2.0 or later) |
+| Default | **Off.** Enable with `new CometChat.OfflineConfig({ enabled: true })` on `AppSettingsBuilder.setOfflineConfig()` |
+| Native peers | `@op-engineering/op-sqlite` (required), `react-native-fs` (media), `react-native-keychain` (encryption) |
+| Check it is active | `CometChat.isOfflineEnabled()` |
+| Reads | Pass a listener to `fetchNext()` / `fetchPrevious()` — `onCached…` fires first, then `onServer…` |
+| Sends | Pass a `MessageSentListener` to `sendMessage()` — `onMessagePending`, then `onMessageSent` or `onError` |
+| Related | [Enable](/sdk/react-native/offline-enable) · [Cached data](/sdk/react-native/offline-cached-data) · [Send](/sdk/react-native/offline-send) · [Sync](/sdk/react-native/offline-sync) · [Reference](/sdk/react-native/offline-reference) |
+
+```typescript
+const offlineConfig = new CometChat.OfflineConfig({ enabled: true });
+
+const appSettings = new CometChat.AppSettingsBuilder()
+ .setRegion("REGION")
+ .setOfflineConfig(offlineConfig) // or .offline(true) for defaults
+ .build();
+
+await CometChat.init("APP_ID", appSettings);
+
+const active: boolean = CometChat.isOfflineEnabled();
+```
+
+
+
+Offline support keeps the logged-in user's conversations and messages on the device so your app can show them instantly, lets the user send messages while disconnected, and catches up automatically when the connection returns. It is **off by default** — nothing changes in your app until you enable it ([Enable Offline Support](/sdk/react-native/offline-enable)). This page explains the mental model; the other pages show the exact APIs.
+
+
+Offline support requires CometChat React Native Chat SDK **4.2.0** or later.
+
+
+## How it works
+
+Three ideas carry the whole feature.
+
+
+
+Every read that you pass a listener to goes through the local cache first. The SDK delivers the cached page immediately — even when it is empty — then fetches from the server and delivers the fresh result. Your UI renders what is on the device without waiting for the network, then updates when the server responds. See [Show Cached Data](/sdk/react-native/offline-cached-data).
+
+
+A message sent while offline is saved to a durable outbox and reported as pending instead of failing. It survives app restarts and is sent when the connection returns. See [Send While Offline](/sdk/react-native/offline-send).
+
+
+When the device reconnects, the SDK syncs the conversations and messages that changed while it was away and flushes the outbox, so the local cache matches the server again. See [Stay in Sync](/sdk/react-native/offline-sync).
+
+
+
+```mermaid
+flowchart LR
+ A[App read] --> B[(Local cache)]
+ B -- cached page first --> A
+ B --> C{Online?}
+ C -- yes --> D[Server]
+ D -- fresh result --> A
+ C -- no --> E[onError:\nFAILED_TO_FETCH]
+ F[Send while offline] --> G[(Durable outbox)]
+ G -- on reconnect --> D
+```
+
+## What changes in your app
+
+With offline support enabled, the read and send methods you already call keep working, and gain two things. Reads still return a Promise that settles on the server result; if you also pass a listener, it receives the cached page first and then the server page. Sends still return a Promise; if you also pass a `MessageSentListener`, it is told when the message is queued (`onMessagePending`) and when it is finally sent. To receive the cached and pending stages you must pass these listeners — the Promise-only forms are unchanged. With offline support off, every API behaves exactly as it did before.
+
+## One cache per user
+
+The SDK keeps a single local cache for the user who is logged in, per app.
+
+- Logging the **same** user back in reuses that cache, so the data is there instantly on next launch.
+- Logging out, a session expiring, or logging in as a **different** user wipes the cache. You never clear it yourself — the SDK does it.
+
+See [Stay in Sync](/sdk/react-native/offline-sync) for cache resets and logout behaviour.
+
+## How much is kept
+
+By default the cache is bounded by size, not age: it holds up to **100 conversations**, up to **500 messages per conversation**, around **250 MB** of data, and up to **500 MB** of cached media. Older entries are evicted first as these limits are reached. Every limit is tunable — see the [Configuration & API Reference](/sdk/react-native/offline-reference).
+
+## Next steps
+
+
+ Turn it on and verify it is active.
+ The cached-first, server-next read contract.
+ The durable outbox and pending messages.
+ Reconnect catch-up, cache resets, logout.
+ Download and cache attachments for offline viewing.
+ Encrypt the local database at rest.
+ Every option, method, listener, enum, and error.
+
diff --git a/sdk/react-native/offline-reference.mdx b/sdk/react-native/offline-reference.mdx
new file mode 100644
index 000000000..c9f379d48
--- /dev/null
+++ b/sdk/react-native/offline-reference.mdx
@@ -0,0 +1,180 @@
+---
+title: "Offline Configuration & API Reference"
+sidebarTitle: "Configuration & API Reference"
+description: "A complete reference for the offline configuration options, public methods, listeners, enums, model fields, and error codes with the CometChat React Native Chat SDK."
+---
+
+
+
+| Field | Value |
+| --- | --- |
+| Config | `new CometChat.OfflineConfig({ enabled: true, ... })` → `AppSettingsBuilder.setOfflineConfig()` (or `.offline(true)`) |
+| Introspection | `isOfflineEnabled()` · `getMediaCachingMode()` · `getOfflineEncryptionStatus()` · `getSyncState()` · `await getLastSyncTimestamp()` · `await getCacheSize()` |
+| Outbox | `await getPendingMessages()` · `retryMessage(localId)` · `retryAllFailedMessages()` · `discardLocalMessage(localId)` |
+| Control | `forceSyncNow()` · `clearOfflineCache()` |
+| Minimum SDK | 4.2.0 |
+
+```typescript
+const offlineConfig = new CometChat.OfflineConfig({
+ enabled: true,
+ maxConversations: 100,
+ maxMessagesPerConversation: 500,
+ maxPendingMessages: 50,
+ mediaCaching: CometChat.MediaCachingMode.AUTO,
+ encryption: false,
+});
+```
+
+
+
+This page is the full surface of CometChat's offline support in the React Native Chat SDK: configuration options, public methods, listeners, enums, model fields, and error codes. The minimum SDK version is **4.2.0**, and offline support is **off by default**. For task-focused guides see [Enable Offline Support](/sdk/react-native/offline-enable), [Show Cached Data](/sdk/react-native/offline-cached-data), [Send While Offline](/sdk/react-native/offline-send), [Stay in Sync](/sdk/react-native/offline-sync), [Media Caching](/sdk/react-native/offline-media-caching), and [Encrypt the Cache](/sdk/react-native/offline-encryption).
+
+## OfflineConfig options
+
+Construct with `new CometChat.OfflineConfig(options)`; every key is optional and pre-set to its default, so pass only the options you want to change. The resulting `OfflineConfig` is immutable. Invalid values are normalised, never rejected: a wrong type or unknown enum value falls back to the default, and a negative duration becomes `0`. Byte caps and the TTL treat `0` as **unlimited** (that dimension's eviction is disabled); the count caps are clamped and have no "unlimited".
+
+| Option | Type | Default | Range / clamp | Effect |
+|---|---|---|---|---|
+| `enabled` | `boolean` | `false` | — | Master gate for the whole offline engine. With it off the SDK behaves exactly as before. |
+| `cacheDurationDays` | `number` (int) | `0` (unlimited) | `>= 0`; negative treated as `0` | Message retention TTL in days. `0` = no age-based prune; eviction is by the byte/count caps instead. |
+| `maxStorageSize` | `number` bytes | `262144000` (250 MB) | `0` = unlimited | Byte ceiling for the message/metadata database. |
+| `maxMessagesPerConversation` | `number` | `500` | clamped `[1, 1000]`; `0`/over → `1000` | Per-conversation cached-message window. |
+| `maxConversations` | `number` | `100` | clamped `[1, 1000]`; `0`/over → `1000` | Cached-conversation cap. |
+| `maxPendingMessages` | `number` | `50` | clamped `[1, 100]`; `0`/over → `100` | Pending-send outbox capacity. |
+| `autoSendPendingOnReconnect` | `boolean` | `true` | — | Auto-flush queued `PENDING` sends on reconnect. Gates only the outbox flush; the reconnect sync sweep runs regardless. |
+| `pendingQueueOverflowPolicy` | `PendingMessageOverflowPolicy` | `REJECT_NEWEST` | — | What the outbox does when it is full. |
+| `maxMediaCacheSize` | `number` bytes | `524288000` (500 MB) | `0` = unlimited | Media-only byte budget (LRU). |
+| `mediaCaching` | `MediaCachingMode` | `AUTO` | unknown → `AUTO` | Receive-side media mode. Fixed at init. |
+| `encryption` | `boolean` | `false` | — | Encrypt the cache database at rest. See [Encrypt the Cache](/sdk/react-native/offline-encryption). |
+| `encryptionFailureBehavior` | `EncryptionFailureBehavior` | `ONLINE_ONLY` | unknown → `ONLINE_ONLY` | What happens when encryption cannot run at all. |
+| `maxSnapshotQueries` | `number` | `100` | `>= 1`; `0`/negative → `100` | Most entity-list snapshots (users, groups, group members) kept for offline replay; LRU. |
+| `snapshotTtlDays` | `number` | `7` | `>= 1`; `0`/negative → `7` | Days an entity-list snapshot is kept after it was first recorded. |
+
+Attach the config at init via `AppSettingsBuilder.setOfflineConfig(OfflineConfig)`, or enable with defaults via the `offline(boolean)` shorthand — see [Enable Offline Support](/sdk/react-native/offline-enable). With `initFromSettings`, use `chatSDK.offline` (a boolean or an options object; `enabled` must be set to turn it on).
+
+The config object also exposes read-only getters: `isEnabled()`, `isEncryptionEnabled()`, `getEncryptionFailureBehavior()`, `getCacheDurationDays()`, `getMaxStorageSize()`, `getMaxMessagesPerConversation()`, `getMaxConversations()`, `getMaxSnapshotQueries()`, `getSnapshotTtlDays()`, `getMaxPendingMessages()`, `isAutoSendPendingOnReconnect()`, `getPendingQueueOverflowPolicy()`, `getMediaCaching()` and `getMaxMediaCacheSize()`.
+
+## Public methods on CometChat
+
+| Method | Returns | Purpose |
+|---|---|---|
+| `isOfflineEnabled()` | `boolean` | Whether offline support is active for the current session. |
+| `getMediaCachingMode()` | `MediaCachingMode` | The effective media caching mode (`OFF` whenever offline is not active). |
+| `getOfflineEncryptionStatus()` | `{ enabled, atRest, keyStorage, cipherAvailable, cipher, leakage }` | What encryption at rest is actually doing. |
+| `setOfflineKeyVault(vault \| null)` | `void` | Supply your own encryption-key store. Call before `init`. |
+| `getPendingMessages()` | `Promise` | All queued/failed messages in the outbox. |
+| `retryMessage(localMessageId, listener?)` | `Promise` (or `void` with a listener) | Retry one failed or pending message by its local id. |
+| `retryAllFailedMessages(listener?)` | `Promise` (or `void` with a listener) | Retry every `TRANSIENT` failed message in the outbox. |
+| `discardLocalMessage(localMessageId, listener?)` | `Promise` (or `void` with a listener) | Remove one unsent message from the outbox. |
+| `forceSyncNow(callback?)` | `Promise` | Trigger a catch-up sync immediately. Resolves when accepted; rejects with the reason it could not start. |
+| `getSyncState()` | `SyncState` | Whether a catch-up sweep is currently running. |
+| `getLastSyncTimestamp()` | `Promise` | Server time of the last successful sync, in epoch seconds. |
+| `clearOfflineCache()` | `Promise` | Empty the local cache in place (and fail any unsent messages as `DISCARDED`). |
+| `getCacheSize()` | `Promise` | Current on-disk cache size in bytes (database plus media). |
+| `downloadMedia(messageOrAttachment, listener?)` | `Promise` | Download and cache an attachment's bytes. |
+| `getCachedMediaPath(messageOrAttachment)` | `Promise` | Local path to already-cached bytes, or `null`. |
+| `getExistingUploadFileRequest(receiverId, receiverType, parentMessageId?)` | `Promise` | Re-attach to an in-progress offline upload. |
+
+### Listener registration
+
+| Method | Listener |
+|---|---|
+| `addMessageListener(id, MessageListener)` / `removeMessageListener(id)` | `MessageListener` (adds `onMessagesSynced`) |
+| `addOfflineMessageListener(id, OfflineMessageListener)` / `removeOfflineMessageListener(id)` | `OfflineMessageListener` |
+| `addSyncListener(id, SyncListener)` / `removeSyncListener(id)` | `SyncListener` |
+| `addOfflineCacheResetListener(id, OfflineCacheResetListener)` / `removeOfflineCacheResetListener(id)` | `OfflineCacheResetListener` |
+| `addMediaDownloadListener(id, MediaDownloadListener)` / `removeMediaDownloadListener(id)` | `MediaDownloadListener` |
+
+## Listeners and callbacks
+
+### Two-phase fetch listeners
+
+Pass one as the optional argument of a request's `fetchNext()` / `fetchPrevious()` (or of `getConversation()` / `getMessageDetails()`). Each delivers the cached page first (when offline is active and a user is logged in), then the server result, then errors. With offline off the cached callback never fires. The cached callback is optional on every listener; the server and error callbacks are required.
+
+| Listener | Callbacks |
+|---|---|
+| `MessageFetchListener` | `onCachedMessages(messages)`, `onServerMessages(messages)`, `onError(error)` |
+| `ThreadFetchListener` (alias `ThreadsFetchListener`) | `onCachedThreads(threads)`, `onServerThreads(threads)`, `onError(error)` |
+| `MessageDetailFetchListener` (alias `MessageDetailsListener`) | `onCachedMessage(message)`, `onServerMessage(message)`, `onError(error)` |
+| `ConversationFetchListener` | `onCachedConversations(conversations)`, `onServerConversations(conversations)`, `onError(error)` |
+| `ConversationDetailsListener` (alias `ConversationDetailFetchListener`) | `onCachedConversation(conversation)`, `onServerConversation(conversation)`, `onError(error)` |
+| `UserFetchListener` | `onCachedUsers(users)`, `onServerUsers(users)`, `onError(error)` |
+| `GroupFetchListener` | `onCachedGroups(groups)`, `onServerGroups(groups)`, `onError(error)` |
+| `GroupMemberFetchListener` | `onCachedGroupMembers(members)`, `onServerGroupMembers(members)`, `onError(error)` |
+| `BannedGroupMemberFetchListener` | `onCachedBannedGroupMembers(members)`, `onServerBannedGroupMembers(members)`, `onError(error)` |
+| `ReactionFetchListener` | `onCachedReactions(reactions)`, `onServerReactions(reactions)`, `onError(error)` |
+| `FeedItemFetchListener` (alias `NotificationFeedFetchListener`) | `onCachedNotificationFeedItems(items)`, `onServerNotificationFeedItems(items)`, `onError(error)` |
+
+### Send, sync, cache, and media listeners
+
+| Listener | Callbacks |
+|---|---|
+| `MessageSentListener` | `onMessagePending(message)`, `onMessageSent(message)`, `onError(error, reason?)` |
+| `OfflineMessageListener` | `onOutboxMessagePending(message)`, `onOutboxMessageSent(message)`, `onOutboxMessageFailed(message, reason)` |
+| `DiscardMessageListener` | `onSuccess(localMessageId)`, `onError(error)` |
+| `SyncListener` | `onConversationSyncStarted()`, `onConversationSyncCompleted(conversations)`, `onConversationSyncError(error)`, `onMessageSyncStarted()`, `onMessageSyncCompleted(messages, outcome)`, `onMessageSyncError(error)` |
+| `OfflineCacheResetListener` | `onOfflineCacheReset(reason, pendingMessagesLost)` |
+| `DownloadMediaListener` (per call) | `onMediaDownloaded(messageId, attachmentKey, localPath)`, `onMediaProgress(messageId, attachmentKey, percent)`, `onMediaDownloadFailed(messageId, attachmentKey, error)`; also `onSuccess(localPath)`, `onProgress(percent)`, `onError(error)` |
+| `MediaDownloadListener` (global) | `onMediaDownloaded(messageId, attachmentKey, localPath)`, `onMediaDownloadFailed(messageId, attachmentKey, error)` |
+| `MessageListener` | `onMessagesSynced(messages)` (offline addition to the existing listener) |
+
+## Enums
+
+| Enum | Values |
+|---|---|
+| `MediaCachingMode` | `AUTO` (auto-download + retain received media), `OFF` (skip auto-download; metadata still cached) |
+| `PendingMessageOverflowPolicy` | `DROP_OLDEST` (evict the oldest pending to admit a newer send), `REJECT_NEWEST` (reject the new send) |
+| `EncryptionFailureBehavior` | `ONLINE_ONLY` (online-only; never plaintext), `PLAINTEXT_FALLBACK` (open an unencrypted cache) |
+| `MessageStatus` | `PENDING`, `SENDING`, `SENT`, `DELIVERED`, `READ`, `FAILED` |
+| `MessageFailureReason` | `TRANSIENT` (retry likely helps), `PERMANENT` (retry won't help), `QUEUE_OVERFLOW` (evicted to admit a newer send), `QUEUE_FULL` (new send rejected, queue full), `BYTE_OVERFLOW` (media exceeded the byte cap), `DISCARDED` (removed before it could send) |
+| `MessageSource` | `REALTIME`, `SYNC` |
+| `SyncOutcome` | `CAUGHT_UP` (gap replayed exactly), `PARTIAL` (a page was ingested but more remains) |
+| `SyncState` | `IDLE`, `SYNCING` |
+| `OfflineCacheResetReason` | `KEY_LOST`, `WRONG_KEY`, `CORRUPTED`, `ESCALATED` (unclassified failure recurred and was escalated), `ENCRYPTION_CHANGED` (the `encryption` setting changed between launches) |
+| Upload item `state` | `QUEUED`, `UPLOADING`, `UPLOADED`, `FAILED`, `REJECTED` |
+
+All enums are available on `CometChat` (for example `CometChat.MessageStatus.PENDING`). `CometChat.OFFLINE_PENDING_COUNT_UNKNOWN` is `-1`.
+
+## Model fields
+
+| Field | Type | Meaning |
+|---|---|---|
+| `BaseMessage.getLocalMessageId()` | `string \| undefined` | SDK-generated local handle for an optimistic/offline send; the stable key to reconcile the row when the server ack arrives. `undefined` for messages that never went through the outbox. |
+| `BaseMessage.getMessageStatus()` | `MessageStatus` | Derived lifecycle status of the message. Not the same as `getStatus()`. |
+| `BaseMessage.getFailureReason()` | `MessageFailureReason \| undefined` | Why a queued send is `FAILED`; otherwise `undefined`. |
+| `BaseMessage.getSource()` | `MessageSource` | `SYNC` for a message delivered by a catch-up sync, otherwise `REALTIME`. |
+| `Attachment.getLocalPath()` | `string \| undefined` | Absolute device path to cached media bytes, or `undefined` when not cached. Never sent to the server. |
+
+## Error codes
+
+Compare `CometChatException.code` against these values. The offline codes are available on `CometChat.OfflineErrorCode` (`MESSAGE_DISCARDED`, `MESSAGE_IN_FLIGHT`, `MESSAGE_NOT_FOUND`, `AUTO_FLUSH_CAP_REACHED`, `PENDING_QUEUE_LIMIT`, `DISABLED`, `SYNC_IN_PROGRESS`, `SYNC_COOLDOWN`, `MESSAGE_PERSIST_FAILED`); compare the others as string values.
+
+| Code | Raised when |
+|---|---|
+| `FAILED_TO_FETCH` | A read reached the server phase with no connection (fires after the cached page on a two-phase fetch, and rejects the Promise). On a send, the same code with `errorParams.queued === true` is the "queued, not failed" notice. |
+| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | `forceSyncNow()` was called while a sync is already running. |
+| `ERROR_OFFLINE_SYNC_COOLDOWN` | `forceSyncNow()` was called within the 10-second throttle cooldown; `details.retryAfterMs` says when to retry. |
+| `ERROR_OFFLINE_DISABLED` | An offline-only operation was called while offline support is not active. |
+| `USER_NOT_LOGED_IN` | `forceSyncNow()` was called with no logged-in user. |
+| `ERROR_OFFLINE_MESSAGE_DISCARDED` | A queued message was removed before it could send (for example `clearOfflineCache()`, a cache rebuild, or logout). |
+| `ERROR_OFFLINE_MESSAGE_IN_FLIGHT` | `retryMessage` / `discardLocalMessage` targeted a message that is still being sent. |
+| `ERROR_OFFLINE_MESSAGE_NOT_FOUND` | `retryMessage` / `discardLocalMessage` found no unsent message with the given local id. |
+| `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` | The local cache was briefly unavailable, so `getPendingMessages`, `retryMessage`, `retryAllFailedMessages` or `discardLocalMessage` changed nothing. Try again. |
+| `ERROR_OFFLINE_MESSAGE_UNSENDABLE` | A queued message could not be restored for sending (corrupt data or missing media); it fails `PERMANENT`. |
+| `ERROR_OUTBOX_AUTO_FLUSH_CAP_REACHED` | A queued message got no answer on 5 reconnects in a row; it is now `FAILED` with reason `TRANSIENT`. |
+| `ERROR_PENDING_QUEUE_LIMIT` | A send was refused or evicted because the outbox is full or the media is too large; the reason is `QUEUE_FULL`, `QUEUE_OVERFLOW` or `BYTE_OVERFLOW`. |
+| `MEDIA_OFFLINE_NOT_CACHED` / `MEDIA_DOWNLOAD_FAILED` | The `error.code` passed to a media download failure callback. |
+
+## Which filters the cache can answer
+
+A cached phase is served only when every filter on the request is locally computable; otherwise the cached phase is empty and only the server phase answers.
+
+| Request | Filters that blank the cached phase |
+|---|---|
+| `MessagesRequest` | `searchKeyword`, `hasLinks`, `updatesOnly`, `interactionGoalCompletedOnly`, `UID` and `GUID` together (outside a thread), pinned-only, saved-only |
+| `ConversationsRequest` | `searchKeyword` |
+| `UsersRequest` | `searchKeyword`, user `status` |
+| `GroupMembersRequest` | `searchKeyword`, member `status` |
+| `GroupsRequest`, `BannedMembersRequest` | `searchKeyword` |
+
+All other filters — including message `tags`, mentions, attachment and reaction filters, `hideReplies`, `hideDeletedMessages`, and conversation `tags` and `unread` — are answered from the cache.
diff --git a/sdk/react-native/offline-send.mdx b/sdk/react-native/offline-send.mdx
new file mode 100644
index 000000000..ad066911e
--- /dev/null
+++ b/sdk/react-native/offline-send.mdx
@@ -0,0 +1,348 @@
+---
+title: "Send While Offline"
+sidebarTitle: "Send While Offline"
+description: "Queue messages sent without a connection, track their status, and retry failed sends with the CometChat React Native Chat SDK."
+---
+
+
+
+| Field | Value |
+| --- | --- |
+| Send | `CometChat.sendMessage(message, listener)` — also `sendMediaMessage`, `sendCustomMessage`, `sendInteractiveMessage` |
+| Listener | `MessageSentListener`: `onMessagePending` → `onMessageSent` or `onError(error, reason)` |
+| Message state | `message.getLocalMessageId()`, `message.getMessageStatus()`, `message.getFailureReason()` |
+| Outbox | `getPendingMessages()`, `retryMessage(localId)`, `retryAllFailedMessages()`, `discardLocalMessage(localId)` |
+| App-wide events | `addOfflineMessageListener(id, listener)` / `removeOfflineMessageListener(id)` |
+| Prerequisites | [Offline support enabled](/sdk/react-native/offline-enable) |
+
+```typescript
+CometChat.sendMessage(textMessage, {
+ onMessagePending: (message) => { /* getMessageStatus() === PENDING, fires immediately */ },
+ onMessageSent: (message) => { /* server ack: real id + sentAt */ },
+ onError: (error, reason) => { /* reason == null: queued, not failed. reason set: real failure */ },
+});
+
+const localId = message.getLocalMessageId();
+const status = message.getMessageStatus(); // PENDING | SENDING | SENT | DELIVERED | READ | FAILED
+const why = message.getFailureReason(); // set only while FAILED
+
+await CometChat.getPendingMessages(); // PENDING + SENDING + FAILED
+await CometChat.retryMessage(localId);
+await CometChat.retryAllFailedMessages(); // transient failures only
+await CometChat.discardLocalMessage(localId); // delete an unsent message
+```
+
+
+
+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 when the connection returns. This page shows the offline-aware send listener, 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](/sdk/react-native/offline-enable); with offline off, a send over no connection fails exactly as before.
+
+
+## Send a Message
+
+Pass a `MessageSentListener` as the second argument to `sendMessage`. Its lifecycle is `onMessagePending` first (the message is durably queued), then exactly one final outcome — `onMessageSent` (the server accepted it) or, for a real failure, `onError`.
+
+
+
+```typescript
+const textMessage: CometChat.TextMessage = new CometChat.TextMessage(
+ "UID",
+ "Hello!",
+ CometChat.RECEIVER_TYPE.USER
+);
+
+CometChat.sendMessage(textMessage, {
+ onMessagePending: (message: CometChat.BaseMessage) => {
+ // Queued and persisted; message.getMessageStatus() === CometChat.MessageStatus.PENDING
+ console.log("Pending, local id:", message.getLocalMessageId());
+ },
+ onMessageSent: (message: CometChat.BaseMessage) => {
+ // Server ack: message now carries its real id and sentAt
+ console.log("Sent:", message.getId());
+ },
+ onError: (error: CometChat.CometChatException, reason?: CometChat.MessageFailureReason | null) => {
+ // reason == null: queued, not failed. Otherwise a real failure.
+ console.log("Send error:", error.code, reason);
+ },
+});
+```
+
+
+```javascript
+const textMessage = new CometChat.TextMessage(
+ "UID",
+ "Hello!",
+ CometChat.RECEIVER_TYPE.USER
+);
+
+CometChat.sendMessage(textMessage, {
+ onMessagePending: (message) => {
+ // Queued and persisted; message.getMessageStatus() === CometChat.MessageStatus.PENDING
+ console.log("Pending, local id:", message.getLocalMessageId());
+ },
+ onMessageSent: (message) => {
+ // Server ack: message now carries its real id and sentAt
+ console.log("Sent:", message.getId());
+ },
+ onError: (error, reason) => {
+ // reason == null: queued, not failed. Otherwise a real failure.
+ console.log("Send error:", error.code, reason);
+ },
+});
+```
+
+
+
+The same `MessageSentListener` is accepted by all four send methods:
+
+```typescript
+CometChat.sendMessage(textMessage, listener); // TextMessage
+CometChat.sendMediaMessage(mediaMessage, listener); // MediaMessage
+CometChat.sendCustomMessage(customMessage, listener); // CustomMessage
+CometChat.sendInteractiveMessage(interactiveMessage, listener); // InteractiveMessage
+```
+
+
+Passing a listener does not change the return value — each method still returns its Promise, and the Promise settles on the server outcome. Consume the final result from **one** channel, the Promise or the listener, not both. `onMessagePending` fires only while offline support is active.
+
+
+## Queued, Not Failed
+
+When the device is offline, the send still fires `onMessagePending`, then fires `onError` **once** with the not-connected error: code `FAILED_TO_FETCH`, `errorParams.queued === true`, and `reason` equal to `null`. The Promise rejects with the same error. This is a **notification, not a failure**: the message stays durably `PENDING`, and `onMessageSent` fires **later**, on reconnect, when it actually goes. Do not send it again.
+
+So for a single send, `onError` and `onMessageSent` can **both** fire (offline, then reconnect), and `onError` does not always mean the message failed. Use `reason` (or `error.errorParams.queued`) to tell the two apart:
+
+| `onError` arguments | Meaning | What to do |
+|---|---|---|
+| `reason == null`, `error.errorParams.queued === true` | Queued; not sent yet. Will auto-send on reconnect. | Show as sending/queued; nothing to do. |
+| `reason` is a `MessageFailureReason` | A real failure; will not retry on its own. | Offer the user a manual retry (see below). |
+
+
+
+```typescript
+onError: (error: CometChat.CometChatException, reason?: CometChat.MessageFailureReason | null) => {
+ if (reason == null) {
+ // Queued offline — auto-sends on reconnect
+ } else {
+ // Real failure — surface a retry action
+ }
+},
+```
+
+
+```javascript
+onError: (error, reason) => {
+ if (reason == null) {
+ // Queued offline — auto-sends on reconnect
+ } else {
+ // Real failure — surface a retry action
+ }
+},
+```
+
+
+
+### Message status and failure reason
+
+Every message exposes its offline state. `getLocalMessageId()` returns the SDK-generated handle that is stamped at compose (while offline is running) and stays stable across the whole lifecycle — use it to retry or discard the message.
+
+| Accessor | Type | Notes |
+|---|---|---|
+| `getLocalMessageId()` | `string \| undefined` | Client handle; present while offline is running. |
+| `getMessageStatus()` | `MessageStatus` | `PENDING`, `SENDING`, `SENT`, `DELIVERED`, `READ`, `FAILED`. |
+| `getFailureReason()` | `MessageFailureReason \| undefined` | Set only while `FAILED`; otherwise `undefined`. |
+
+
+Read the offline status with `getMessageStatus()`. `BaseMessage.getStatus()` is the existing call/message status field and is **not** the offline status.
+
+
+`MessageFailureReason` values:
+
+| Reason | When |
+|---|---|
+| `TRANSIENT` | A temporary server error (for example 5xx or rate-limited), or no answer after 5 reconnect attempts in a row. Retryable. |
+| `PERMANENT` | The server rejected the message, or the error was not recognized. A manual retry will not help. |
+| `QUEUE_FULL` | A new send was refused because the queue is full (count cap). |
+| `QUEUE_OVERFLOW` | An older queued message was evicted to admit a newer one (`DROP_OLDEST` only). |
+| `BYTE_OVERFLOW` | A media send was refused because it exceeds the queue's byte budget. |
+| `DISCARDED` | The message was removed before it could send (for example `clearOfflineCache()`). |
+
+## Inspect the Queue
+
+`getPendingMessages` returns the **entire un-acked outbox** — every `PENDING`, `SENDING`, and `FAILED` message, ordered by local compose time. Sent messages are not included. Read each message's status to group them. It returns an empty list when offline support is off.
+
+
+
+```typescript
+CometChat.getPendingMessages().then(
+ (messages: CometChat.BaseMessage[]) => {
+ messages.forEach((m) => console.log(m.getLocalMessageId(), "->", m.getMessageStatus()));
+ },
+ (error: CometChat.CometChatException) => {
+ console.log("Could not read the outbox:", error.code);
+ }
+);
+```
+
+
+```javascript
+CometChat.getPendingMessages().then(
+ (messages) => {
+ messages.forEach((m) => console.log(m.getLocalMessageId(), "->", m.getMessageStatus()));
+ },
+ (error) => {
+ console.log("Could not read the outbox:", error.code);
+ }
+);
+```
+
+
+
+If the local store is briefly unavailable, the call rejects with `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` rather than returning an empty list — try again.
+
+### Observe the queue app-wide
+
+The per-call `MessageSentListener` lives only as long as the call. 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 `OfflineMessageListener`. Its failed callback carries the `MessageFailureReason`.
+
+
+
+```typescript
+CometChat.addOfflineMessageListener("LISTENER_ID", {
+ onOutboxMessagePending: (message: CometChat.BaseMessage) => {},
+ onOutboxMessageSent: (message: CometChat.BaseMessage) => {},
+ onOutboxMessageFailed: (message: CometChat.BaseMessage, reason: CometChat.MessageFailureReason) => {
+ console.log("Failed:", message.getLocalMessageId(), reason);
+ },
+});
+
+// Remove it when no longer needed
+CometChat.removeOfflineMessageListener("LISTENER_ID");
+```
+
+
+```javascript
+CometChat.addOfflineMessageListener("LISTENER_ID", {
+ onOutboxMessagePending: (message) => {},
+ onOutboxMessageSent: (message) => {},
+ onOutboxMessageFailed: (message, reason) => {
+ console.log("Failed:", message.getLocalMessageId(), reason);
+ },
+});
+
+// Remove it when no longer needed
+CometChat.removeOfflineMessageListener("LISTENER_ID");
+```
+
+
+
+## Retry and Discard
+
+There is no automatic retry of a `FAILED` message — recovery is explicit and user-invoked. Use the message's local id. Each method takes an optional listener as a second argument; omit it and you get a Promise instead (never both).
+
+- **`retryMessage(localMessageId)`** 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. The result is that attempt's outcome.
+- **`retryAllFailedMessages()`** retries **transient** failures only; permanent ones are skipped. The Promise form resolves once the retries are issued; pass a `MessageSentListener` to see each outcome.
+- **`discardLocalMessage(localMessageId)`** deletes an unsent (`PENDING` or `FAILED`) message from the device, including its copied media. The discard's own listener (`DiscardMessageListener`) gets `onSuccess(localMessageId)`; the original send's listener is released with no final outcome.
+
+
+
+```typescript
+await CometChat.retryMessage(localMessageId);
+
+await CometChat.retryAllFailedMessages();
+
+CometChat.discardLocalMessage(localMessageId).then(
+ () => console.log("Discarded"),
+ (error: CometChat.CometChatException) => console.log("Discard rejected:", error.code)
+);
+```
+
+
+```javascript
+await CometChat.retryMessage(localMessageId);
+
+await CometChat.retryAllFailedMessages();
+
+CometChat.discardLocalMessage(localMessageId).then(
+ () => console.log("Discarded"),
+ (error) => console.log("Discard rejected:", error.code)
+);
+```
+
+
+
+`retryMessage` and `discardLocalMessage` reject, changing nothing, when:
+
+| Error code | Reason |
+|---|---|
+| `ERROR_OFFLINE_MESSAGE_IN_FLIGHT` | The message is already sending (`SENDING`). |
+| `ERROR_OFFLINE_MESSAGE_NOT_FOUND` | No unsent message has that id (unknown, or already sent). |
+| `ERROR_OFFLINE_DISABLED` | Offline support is not running. |
+| `ERROR_OFFLINE_MESSAGE_PERSIST_FAILED` | The local cache was briefly unavailable. Try again. |
+
+These codes are available as `CometChat.OfflineErrorCode.MESSAGE_IN_FLIGHT`, `MESSAGE_NOT_FOUND`, `DISABLED` and `MESSAGE_PERSIST_FAILED`.
+
+## Auto-send on Reconnect
+
+By default, queued `PENDING` messages are sent automatically when the realtime connection is re-established. This is controlled by `OfflineConfig.autoSendPendingOnReconnect` (default `true`); see [Enable Offline Support](/sdk/react-native/offline-enable) and the [reference](/sdk/react-native/offline-reference). 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](/sdk/react-native/offline-sync).
+
+## Queue Limits
+
+The outbox holds at most `maxPendingMessages` messages still trying to send (`PENDING` + `SENDING`); `FAILED` messages do not count. When the queue is full, `OfflineConfig.pendingQueueOverflowPolicy` decides what happens:
+
+| `PendingMessageOverflowPolicy` | Behaviour on overflow |
+|---|---|
+| `REJECT_NEWEST` (**default**) | The new send is refused and reported with reason `QUEUE_FULL`. Nothing already queued is lost. |
+| `DROP_OLDEST` | The oldest `PENDING` message is evicted (reason `QUEUE_OVERFLOW`) to admit the new one. |
+
+A media send that exceeds the queue's byte budget is refused with reason `BYTE_OVERFLOW`.
+
+A **refused** send (`QUEUE_FULL` / `BYTE_OVERFLOW`) is never queued, so it fires **no** `onMessagePending`; it reports `onError` with the code `ERROR_PENDING_QUEUE_LIMIT`, and the global `OfflineMessageListener` receives `onOutboxMessageFailed(message, reason)` carrying the exact reason.
+
+
+A refused or evicted message (`QUEUE_FULL`, `QUEUE_OVERFLOW`, `BYTE_OVERFLOW`) has **no durable row** — it does not appear in `getPendingMessages`, and calling `retryMessage` on its id does nothing. Capture these from the failure callback and handle them at that moment. Prefer the default `REJECT_NEWEST`, 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 reject with the not-connected error if the request gets no answer. Their effect reaches the cache only once the server accepts them.
+
+## Durable Media Uploads
+
+A multi-attachment upload survives an app restart: the SDK re-attaches to an in-progress batch instead of restarting it. Fetch the existing request for a conversation with `getExistingUploadFileRequest` (a Promise that resolves `null` when there is none), and read each file's progress through `getItems()`, which returns a list of items, each with a `state` of `QUEUED`, `UPLOADING`, `UPLOADED`, `FAILED`, or `REJECTED` (which cannot be retried). Retry an individual file with `retryAttachment(fileId)`. With offline on, each item also carries `localCopyPath`, the SDK's copy of the bytes, for previews of a restored tray.
+
+
+
+```typescript
+const request = await CometChat.getExistingUploadFileRequest("UID", CometChat.RECEIVER_TYPE.USER);
+
+request?.getItems().forEach((item) => {
+ console.log(item.fileId, "->", item.state);
+ if (item.state === "FAILED") {
+ request.retryAttachment(item.fileId);
+ }
+});
+```
+
+
+```javascript
+const request = await CometChat.getExistingUploadFileRequest("UID", CometChat.RECEIVER_TYPE.USER);
+
+request?.getItems().forEach((item) => {
+ console.log(item.fileId, "->", item.state);
+ if (item.state === "FAILED") {
+ request.retryAttachment(item.fileId);
+ }
+});
+```
+
+
+
+See [Media Caching](/sdk/react-native/offline-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](/sdk/react-native/offline-reference).
+
diff --git a/sdk/react-native/offline-sync.mdx b/sdk/react-native/offline-sync.mdx
new file mode 100644
index 000000000..b6a23185b
--- /dev/null
+++ b/sdk/react-native/offline-sync.mdx
@@ -0,0 +1,298 @@
+---
+title: "Stay in Sync"
+sidebarTitle: "Stay in Sync"
+description: "Catch up on everything that changed while offline, observe sync progress, trigger a manual sync, and manage the local cache with the CometChat React Native Chat SDK."
+---
+
+
+
+| Field | Value |
+| --- | --- |
+| Observe sync | `CometChat.addSyncListener(id, listener)` / `removeSyncListener(id)` |
+| Poll state | `CometChat.getSyncState()` → `IDLE` \| `SYNCING`; `await CometChat.getLastSyncTimestamp()` → server epoch seconds, `0` if never |
+| Manual sync | `await CometChat.forceSyncNow()` — resolves when the sweep is accepted |
+| Cache resets | `CometChat.addOfflineCacheResetListener(id, listener)` |
+| Cache size / clear | `await CometChat.getCacheSize()` (bytes) · `await CometChat.clearOfflineCache()` (destructive) |
+| Prerequisites | [Offline support enabled](/sdk/react-native/offline-enable) |
+
+```typescript
+CometChat.addSyncListener("SYNC_LISTENER", {
+ onConversationSyncCompleted: (conversations) => { /* blind-replace list */ },
+ onMessageSyncCompleted: (messages, outcome) => { /* merge by id */ },
+ onMessageSyncError: (error) => {},
+});
+CometChat.removeSyncListener("SYNC_LISTENER");
+
+const state = CometChat.getSyncState(); // IDLE | SYNCING
+const lastSync: number = await CometChat.getLastSyncTimestamp();
+
+await CometChat.forceSyncNow(); // rejects: IN_PROGRESS / COOLDOWN / DISABLED / NOT_LOGGED_IN
+
+CometChat.addOfflineCacheResetListener("RESET_LISTENER", {
+ onOfflineCacheReset: (reason, pendingMessagesLost) => {},
+});
+
+const bytes: number = await CometChat.getCacheSize();
+await CometChat.clearOfflineCache(); // destructive; drops the un-sent outbox
+```
+
+
+
+When the device comes back online, the SDK runs a **catch-up sync**: it refreshes the conversation list, then replays everything that changed in your messages since the last sync, and merges the results into the local cache. This page covers how to observe that sync, trigger one on demand, and manage the cache it maintains.
+
+
+Everything on this page is a no-op unless offline support is active for the logged-in user (`CometChat.isOfflineEnabled()` is `true`). See [Enable Offline Support](/sdk/react-native/offline-enable). The message catch-up depends on the server's `updatedAt` message changelog; against a server without it, each sweep returns nothing and missed edits, deletes, and reactions are not synced.
+
+
+## How catch-up works
+
+A sync runs automatically when the connection settles after a reconnect (the connection must hold for about 2.5 seconds). It never runs on a brief connect-then-drop flap, and overlapping triggers coalesce into a single sweep. The outbox is flushed first, and the sweep starts after the flush finishes or after 20 seconds, whichever comes first. The sweep proceeds in two phases, in order:
+
+1. **Conversation phase** — the conversation list is fetched as one fresh page. A conversation-list UI can blind-replace its list with the result.
+2. **Message phase** — the SDK replays the message changelog from the last sync point. New messages and updates to already-cached messages are merged into the cache.
+
+The sync is incremental and safe to repeat: it only moves forward from the last successful point, it never deletes cached messages, and a failed or interrupted phase simply resumes on the next reconnect. A single sweep request that gets no answer is aborted after 60 seconds and counts as a failed phase. A first-ever sync (cold start) seeds its starting point from the newest conversation and fetches no message history — older history is reached by scrolling online.
+
+## Listen for sync progress
+
+Register a `SyncListener` to observe the lifecycle of every automatic and manual sync. Each phase fires `started`, then exactly one of `completed` / `error`. Every callback is optional.
+
+
+
+```typescript
+CometChat.addSyncListener("SYNC_LISTENER", {
+ onConversationSyncStarted: () => {},
+
+ onConversationSyncCompleted: (conversations: CometChat.Conversation[]) => {
+ // The full, fresh conversation page. Blind-replace your list.
+ },
+
+ onConversationSyncError: (error: CometChat.CometChatException) => {},
+
+ onMessageSyncStarted: () => {},
+
+ onMessageSyncCompleted: (messages: CometChat.BaseMessage[], outcome: CometChat.SyncOutcome) => {
+ // New + changed messages reconciled this sync. Merge by message id.
+ },
+
+ onMessageSyncError: (error: CometChat.CometChatException) => {},
+});
+```
+
+
+```javascript
+CometChat.addSyncListener("SYNC_LISTENER", {
+ onConversationSyncStarted: () => {},
+
+ onConversationSyncCompleted: (conversations) => {
+ // The full, fresh conversation page. Blind-replace your list.
+ },
+
+ onConversationSyncError: (error) => {},
+
+ onMessageSyncStarted: () => {},
+
+ onMessageSyncCompleted: (messages, outcome) => {
+ // New + changed messages reconciled this sync. Merge by message id.
+ },
+
+ onMessageSyncError: (error) => {},
+});
+```
+
+
+
+| Callback | When it fires |
+|---|---|
+| `onConversationSyncStarted()` | The conversation refresh began. |
+| `onConversationSyncCompleted(conversations)` | The refresh succeeded; `conversations` is the full conversation page. |
+| `onConversationSyncError(error)` | A transport or parse failure; the cache is left untouched and the refresh retries on the next reconnect. |
+| `onMessageSyncStarted()` | The message changelog sweep began. Never fires on a cold start. |
+| `onMessageSyncCompleted(messages, outcome)` | The sweep finished; `messages` carries the new and changed messages. |
+| `onMessageSyncError(error)` | The sweep failed; it resumes on the next reconnect. |
+
+The two phases are independent — a conversation-phase error does not stop the message phase. A phase error is informational: the cache is never left in a wrong state and the sweep resumes automatically.
+
+The `SyncListener` is self-sufficient: `onMessageSyncCompleted` carries the swept messages, including `action` and `call` messages that the `MessageListener` does not deliver, so you can use it without a `MessageListener`.
+
+`onMessageSyncCompleted` reports the outcome:
+
+| `SyncOutcome` | Meaning |
+|---|---|
+| `CAUGHT_UP` | The changelog was fully replayed; the cache reflects every server change up to the sync point. |
+| `PARTIAL` | A page was ingested and the sync point advanced, but a backlog remains; it drains one page per reconnect. |
+
+Remove the listener with `CometChat.removeSyncListener("SYNC_LISTENER")`.
+
+## Receiving synced messages
+
+If you already use a `CometChat.MessageListener`, synced messages still arrive there, so you usually do not need a `SyncListener` to keep your chat up to date:
+
+- **Genuinely new** messages arrive one-by-one on `onTextMessageReceived` (and the other typed variants), with `getSource()` equal to `MessageSource.SYNC`.
+- **Updates** to already-cached messages arrive together on `onMessagesSynced(messages)`. Upsert the batch by id; never append it.
+
+
+
+```typescript
+CometChat.addMessageListener(
+ "MSG_LISTENER",
+ new CometChat.MessageListener({
+ onTextMessageReceived: (message: CometChat.TextMessage) => {
+ if (message.getSource() === CometChat.MessageSource.SYNC) {
+ // Delivered by a catch-up sync. Suppress notification spam if you wish.
+ }
+ },
+ onMessagesSynced: (messages: CometChat.BaseMessage[]) => {
+ // Updates to already-cached messages. Merge by id.
+ },
+ })
+);
+```
+
+
+```javascript
+CometChat.addMessageListener(
+ "MSG_LISTENER",
+ new CometChat.MessageListener({
+ onTextMessageReceived: (message) => {
+ if (message.getSource() === CometChat.MessageSource.SYNC) {
+ // Delivered by a catch-up sync. Suppress notification spam if you wish.
+ }
+ },
+ onMessagesSynced: (messages) => {
+ // Updates to already-cached messages. Merge by id.
+ },
+ })
+);
+```
+
+
+
+## Check sync state
+
+Poll the current sync state and the time of the last completed sync at any time.
+
+
+
+```typescript
+const state: CometChat.SyncState = CometChat.getSyncState(); // IDLE or SYNCING
+const lastSync: number = await CometChat.getLastSyncTimestamp(); // server epoch seconds; 0 if never synced
+```
+
+
+```javascript
+const state = CometChat.getSyncState(); // IDLE or SYNCING
+const lastSync = await CometChat.getLastSyncTimestamp(); // server epoch seconds; 0 if never synced
+```
+
+
+
+`getSyncState()` returns `SyncState.SYNCING` while a sweep is in flight, otherwise `SyncState.IDLE` (also when offline support is disabled). `getLastSyncTimestamp()` returns a **server-provided** epoch value in seconds, never the device clock, and `0` if the cache has never synced.
+
+## Trigger a manual sync
+
+Call `forceSyncNow` to run a catch-up immediately — for example behind a pull-to-refresh. It routes through the same throttle as the automatic sync, so it will not start a second overlapping sweep or hammer the backend; manual syncs must be at least 10 seconds apart.
+
+The returned Promise resolves when the sweep is **accepted**, not when it completes. The sync's actual progress and outcome arrive on your registered `SyncListener`. You can instead pass an optional `{ onSuccess, onError }` callback object.
+
+
+
+```typescript
+CometChat.forceSyncNow().then(
+ () => {
+ // Accepted and started.
+ },
+ (error: CometChat.CometChatException) => {
+ // Rejected — see the code.
+ console.log(error.code, error.details);
+ }
+);
+```
+
+
+```javascript
+CometChat.forceSyncNow().then(
+ () => {
+ // Accepted and started.
+ },
+ (error) => {
+ // Rejected — see the code.
+ console.log(error.code, error.details);
+ }
+);
+```
+
+
+
+| Rejection code | Meaning |
+|---|---|
+| `ERROR_OFFLINE_SYNC_IN_PROGRESS` | A sync (automatic or manual) is already running. |
+| `ERROR_OFFLINE_SYNC_COOLDOWN` | A sync ran too recently; `error.details.retryAfterMs` carries the remaining cooldown in milliseconds. |
+| `ERROR_OFFLINE_DISABLED` | A user is logged in but offline support is not active. |
+| `USER_NOT_LOGED_IN` | Nobody is logged in. The spelling is the SDK's existing code. |
+
+## When the cache resets
+
+In rare cases the SDK cannot keep the existing cache — for example the encryption key became unrecoverable, or the database file is corrupt. When this happens it rebuilds the cache from scratch and notifies an `OfflineCacheResetListener`. Any un-sent messages that were queued are lost in the rebuild; `pendingMessagesLost` reports how many, or `CometChat.OFFLINE_PENDING_COUNT_UNKNOWN` (`-1`) when they could not be counted — that value means "unknown", not "none".
+
+
+
+```typescript
+CometChat.addOfflineCacheResetListener("RESET_LISTENER", {
+ onOfflineCacheReset: (reason: CometChat.OfflineCacheResetReason, pendingMessagesLost: number) => {
+ // The cache was rebuilt. Re-fetch anything you need; it will re-sync from the server.
+ },
+});
+```
+
+
+```javascript
+CometChat.addOfflineCacheResetListener("RESET_LISTENER", {
+ onOfflineCacheReset: (reason, pendingMessagesLost) => {
+ // The cache was rebuilt. Re-fetch anything you need; it will re-sync from the server.
+ },
+});
+```
+
+
+
+| `OfflineCacheResetReason` | Cause |
+|---|---|
+| `KEY_LOST` | The device key could not be recovered (for example cleared app data or a restored backup); a fresh key was minted. |
+| `WRONG_KEY` | The database exists but does not decrypt under the current key. |
+| `CORRUPTED` | The database file is corrupt on disk. |
+| `ESCALATED` | An unclassified failure recurred across launches and was escalated to a permanent rebuild. |
+| `ENCRYPTION_CHANGED` | The `encryption` setting changed, so the on-disk cache was rebuilt in the configured format. |
+
+Remove the listener with `CometChat.removeOfflineCacheResetListener("RESET_LISTENER")`. See [Encrypt the Cache](/sdk/react-native/offline-encryption) for the encryption-related reasons.
+
+## Manage the cache
+
+Read the current cache size, or clear the cache entirely.
+
+
+
+```typescript
+const bytes: number = await CometChat.getCacheSize(); // DB + media byte store, in bytes
+
+await CometChat.clearOfflineCache(); // empty the per-user cache in place
+```
+
+
+```javascript
+const bytes = await CometChat.getCacheSize(); // DB + media byte store, in bytes
+
+await CometChat.clearOfflineCache(); // empty the per-user cache in place
+```
+
+
+
+`getCacheSize()` returns the total cache size in bytes (local database plus the media byte store) as a single figure, or `0` when offline support is disabled.
+
+
+`clearOfflineCache()` is destructive and irreversible. It empties the entire per-user cache — cached messages, conversations, media bytes **and the un-sent outbox** — while the store stays open. Each still-queued `PENDING` / `SENDING` message fails with reason `DISCARDED`; a message already on the wire waits for its answer and, if accepted, is reported sent. Messages already `FAILED` are removed silently. It is a no-op unless offline support is active.
+
+
+
+You never need to clear the cache on logout — the SDK does it for you. The cache is stored **per user**: it is kept across re-login as the same user, and wiped automatically on logout, token expiry, or when a different user logs in. See [Offline Support](/sdk/react-native/offline-overview).
+