Offline-First Architecture
AppDNA SDKs keep working when the device has no internet connection. Onboarding flows, paywalls, in-app messages, surveys, feature flags, remote config and experiment assignment run from config stored on the device, and events are written to disk and sent later. What needs the network is listed in Module-Specific Offline Behavior. The behavior below is the native iOS and Android SDKs’. The Flutter and React Native SDKs are thin wrappers: they call the native SDK, so on iOS a Flutter or React Native app behaves like the iOS SDK and on Android like the Android SDK.Three-Tier Config Priority
Config comes from three sources. A fresher source replaces an older one:configure() does not wait for the network: it returns at once and the bootstrap runs in the background. The SDK becomes ready (onReady) once the bootstrap succeeds or fails. The cache and the bundled config are applied at that point, and the remote fetch starts if the bootstrap succeeded. The bootstrap is retried on network errors and 5xx. iOS makes 3 retries, after 1, 2 and 4 seconds, and gives up after 15 seconds in total. Android makes 2 retries, after 1 and 2 seconds. If it fails — for example, the app starts offline — the SDK becomes ready on cached and bundled config, reports the failure through onInitDegraded on all four SDKs — iOS, Android, Flutter and React Native (lastInitError holds it; lastInitError() in Flutter, getLastInitError() in React Native), and keeps retrying the bootstrap: when the network comes back, when the app comes to the foreground, and otherwise after 5 seconds, doubling to at most 5 minutes, each wait ±25 % so devices do not retry in step — at most 10 retries per configure(), none while the device is offline. Every trigger (foreground or network regained) waits a random 0–1 s before its retry, so a fleet that regains the network together does not reach the server at the same instant. A 401 or 403 means the server refused the API key: it ends the retries (one at configure() starts none). A 429 with a Retry-After holds the next retry back for that long (up to 120 seconds), even when the network comes back or the app comes to the foreground — the first retry too, when it was the bootstrap at configure() that got the 429. The network coming back or a foreground while a retry is in flight starts no extra retry: a failed retry is always followed by its backoff, or by a later trigger. The bootstrap is never answered from the HTTP cache, so an app that starts offline always takes this path. When a retry succeeds the SDK applies it as if the first bootstrap had succeeded: it fetches remote config (the config-changed notification fires when it arrives, as for the fetch at configure), starts the Firestore listeners of a signed-in user and applies the runtime lock the answer carries; lastInitError returns to null, and onReady does not fire again. Until then, refresh() serves the cache.Config TTL and Refresh
The default config TTL is 1 hour (configTTL: 3600, in seconds). From iOS 1.0.82 and Android 1.0.54 (and the Flutter and React Native releases whose appdna_feature_parity is 1.0.82), flushInterval, batchSize and configTTL follow one precedence: the option you pass to configure, then the value the bootstrap answer carries (settings, set by AppDNA for all apps and equal to the defaults below today), then the SDK’s built-in default. Passing an option’s default value (flushInterval 30, batchSize 100, configTTL 3600) is the same as not passing it, on every platform. Once the bootstrap succeeds, the SDK fetches remote config:
1
At configure
The SDK fetches the config whatever the cache’s age. On iOS, a fetch that reaches no server (offline) is retried up to five times, after 2, 4, 8, 16 and 30 seconds. Android has no such retry; it relies on the 15-minute refresh below.
2
When the TTL elapses
After each completed fetch, the SDK schedules the next one for when the config is older than the TTL.
3
On Android, every 15 minutes
A
ConfigRefreshWorker (WorkManager) fetches the config every 15 minutes while the device is online.4
When you ask
AppDNA.remoteConfig.refresh() and AppDNA.forceRefreshConfig() fetch at once, bypassing the TTL. Flutter exposes both; React Native exposes refresh().Event Queue
Every event is written to disk before the SDK tries to send it:- When
track()is called, the event is written to a persistent on-disk queue at once. - The queue is flushed every 30 seconds (
flushInterval), when the app goes to the background, and when it returns to the foreground. It is also flushed when the queue reaches the batch size for the current network: 100 events on Wi-Fi or a wired connection, metered or not; 20 on a cellular connection the system marks as expensive (iOS) or metered (Android), which is every cellular connection on iOS and most cellular plans on Android; 50 on any other cellular connection. Each upload sends at most that many events. With no network, the SDK does not try to flush. ThebatchSizeoption caps these numbers: withbatchSize: 10, the queue is flushed at 10 events and each upload sends at most 10, on every network; abatchSizeabove the network’s size changes nothing, and abatchSizebelow 1 is ignored (the SDK logs a warning and behaves as if you had not set it). Without the option, the bootstrap’ssettings.batchSizeis the cap (100 today: no cap); a bootstrap value below 1 is ignored too. If your app already passedbatchSizeto an earlier SDK version — where it had no effect — it is now a hard cap: each upload sends at most that many events. Remove it, or raise it, unless that is what you want.flushIntervaland the cap follow the precedence under Config TTL and Refresh. - If a flush fails with a network error, a 5xx, a 408 or a 429, the events stay in the queue and the SDK retries with exponential backoff (below).
- On a successful upload the events are removed from the queue.
Because events are on disk, they survive app restarts, force-quits, crashes and device reboots, and are sent when connectivity returns. The limits below still apply.
When Events Are Dropped
- A 4xx answer other than 408 or 429 (a malformed batch, an invalid API key): the batch is dropped, not retried — by the in-app upload and by the OS background upload alike. The background upload (which otherwise sends batch after batch, up to 50 per run, until the queue is empty) then ends its run; its next run sends the events behind the batch. A 401 or 403 (an invalid or revoked API key) also pauses uploads — in-app and background — until the app next comes to the foreground or you call
flush(): every later batch would be rejected the same way, and the paused events stay queued. - Storage limits: the queue holds at most 10,000 events and 5 MB of them, measured the same way on both platforms: each event’s JSON plus one byte. Each limit holds after every event queued: beyond either the oldest events are dropped (over 10,000, exactly the excess; over 5 MB, the oldest 10 % at a time until the rest fit). The files are larger than the events they hold: on iOS, sent and dropped events stay in the queue file until the SDK reclaims their space (once they pass 1 MB, or outweigh the queued ones), so it can reach about 6 MB; on Android, the SQLite database file also holds its indexes and free pages.
- Age: an event older than 7 days is dropped unsent. It would be past the server’s deduplication window.
- Consent: while analytics consent is off, new events are not queued. Revoking consent deletes the events already queued.
_sdk_events_dropped event.
Backoff Strategy
When a flush fails, the SDK backs off before retrying. The base delays are[1, 2, 4] seconds, each with ±25% random jitter. The SDK makes at most 3 retries per flush cycle, then gives up until the next cycle:
After the 3rd retry the flush is abandoned and retried on the next flush cycle. A server-supplied
Retry-After header, when present, overrides the computed delay. Each flush cycle starts its retries from the beginning. After 5 failed flush cycles in a row, uploads pause: the timer, the batch size and backgrounding no longer start an upload (backgrounding schedules no OS background upload, and one already scheduled uploads nothing while paused), and new events are still written to disk. Uploads resume when the app returns to the foreground or you call AppDNA.flush(); either one resets the count.
Storage per Platform
Each platform uses native storage:The anonymous ID is stored in the Keychain on iOS and in Encrypted SharedPreferences on Android. On iOS the ID survives an app reinstall, as long as the Keychain entry is kept. On an Android device whose keystore is unavailable, the SDK falls back to plain SharedPreferences.
Config Bundle Embedding
For a configured first launch without a network, you can ship a config bundle file in your app binary. The native SDK looks forappdna-config.json once the bootstrap has succeeded or failed. It uses the file to fill any section that has no cached config.
The file must hold the config sections at its top level. The download endpoint wraps them in data.content, so unwrap them while downloading:
POST /api/v1/sdk/config-bundle/generate) builds one at once. See Config Bundles in CI/CD.
- iOS
- Android
- Flutter
- React Native
Add
appdna-config.json to your Xcode project as a bundle resource:- Drag
appdna-config.jsoninto your Xcode project navigator. - Ensure it is added to your app target under Build Phases > Copy Bundle Resources.
- The SDK reads it from the main bundle.
Offline Experiment Assignment
Experiments work offline because assignment is computed on the device with MurmurHash3 (32-bit):userId is the identified user ID, or the anonymous ID before identify(). Whether the user is in the experiment at all (targeting rules and traffic allocation) is also decided on the device (see Who Is in an Experiment). The same user always gets the same variant for the same experiment, online or offline. A user who is identified later is bucketed under the new ID. All the SDK needs is the experiment definition (from remote, cached or bundled config) and that ID. getVariant() makes no network request.
Exposure events are queued like any other event and sent when connectivity returns, so experiment analytics include users who saw an experiment while offline.

