People open banking apps in lifts, on planes and on weak mobile data. This page explains what each ContentFlow SDK does with the events your app records when the network is missing or failing, how long it keeps them, when it gives up, and what your app should do before the person signs out or leaves the app. Content (blocks and strings) has its own offline behaviour, covered in Blocks: refresh and offline.
In short
Nothing is queued before analytics consent: events tracked without it are dropped on the device, not held for a later grant.
JavaScript and Flutter keep events in memory and, while the device is offline, in a stored outbox that survives a restart; Swift keeps them in memory only; Kotlin sends each event at once and doesn't queue it.
Every queue holds at most 500 items (100 by default in JavaScript) and drops the oldest first; events in an outbox (and in the Swift queue) expire after 7 days.
Flush before sign-out (flush, flushOfflineQueue, flushEvents): Flutter's logout() clears both queues without sending them.
Only a network failure, a 429 or a 5xx is retried, never sooner than the server's Retry-After; any other 4xx is dropped.
Reference
At a glance
JavaScript (browser)
React Native
Swift
Kotlin
Flutter
Before analytics consent
Dropped, reported as consent_not_granted
Dropped: .droppedNoConsent
Dropped: DroppedNoConsent
Dropped (logged with debug)
Where events wait
Memory queue; an outbox in browser storage while the browser reports offline
Memory queue (the outbox needs an online signal React Native doesn't give)
Memory queue; ambiguous sends in an "uncertain" set
Nowhere: each call is one request
Memory queue, then a stored outbox (on by default)
Survives an app restart
Outbox only
No
No
Not applicable
Outbox only
Limit
maxOfflineQueueSize, default 100, at most 500, per queue
500 per queue
Not applicable
500 per queue
Stored events expire
7 days
Not applicable
7 days
Not applicable
7 days
Sent when the app goes to the background
Yes (a keepalive request on page hide)
Yes
Yes
Not applicable
No: call flush() yourself
When a queue is full the oldest item is dropped, and the drops are reported at most once a minute with the count since the last report. String-sensor signals and device location points have queues of their own with the same 500 limit (see Location and Sensors).
How it works
How an event waits
JavaScript: track → queue → send, or wait offline
Queued in memory; a repeat impression of the same instance in the same session is ignored.
If this step is skipped: Without analytics consent the call is dropped here and reported as consent_not_granted. It is not kept for a later grant.
Also sent when the app goes to the background and when the browser comes back online. Campaign taps are sent at once.
If this step is skipped: Before the device's first identify completes, sending waits for it.
Stored so it survives a reload, for up to 7 days.
If this step is skipped: If the outbox is full, the oldest stored event is dropped and reported.
Sent in order once the browser reports it is online.
Kept and retried with backoff: from 1 s, doubling, at most 5 minutes, never sooner than Retry-After (honoured up to 1 hour).
If this step is skipped: After 5 failed attempts the batch is dropped and reported (event retry limit reached).
The outcome is unknown, so it is reported through onEventsUncertain and not resent, unless you set eventRetryPolicy: 'at-least-once' (which may count it twice).
The server refused it; it is dropped and reported, never retried.
Per platform
JavaScript, React and React Native
Event queue (memory). Batches of eventBatchSize (default 10), sent every eventFlushMs (default 15,000 ms), when the app goes to the background, and when the browser comes back online. flush() sends it now, unless a retry backoff is running.
Offline outbox (stored). On by default (enableOfflineQueue: false turns it off). Used only while the platform reports the device offline: navigator.onLine in a browser. React Native and Node have no such signal, so there isOnline() is always true and the outbox stays empty; failed sends follow the retry rules above instead. Stored in localStorage (memory only where that is unavailable), sent 10 at a time, kept for 7 days.
Limits.maxOfflineQueueSize (default 100) sets the size of both queues and is capped at 500. maxRetries (default 5) is how many failed sends a batch gets before it is dropped.
Retry policy.eventRetryPolicy: 'report' (the default) never resends a batch whose outcome is unknown; 'at-least-once' does, and may double-count.
Seeing drops.subscribeEventDrops(listener) or onEventsDropped for every drop, local or server-side; onEventsUncertain for timeouts and 5xx.
Rate limiter.rateLimitPerSecond (default 10) and getRateLimiterStatus() exist, but in the current release no request goes through this limiter, so it neither delays nor drops anything. The server's limit is the one that applies (see REST guide: rate limits).
Per platform
Swift
Every trackEngagement call queues the event and sends the queue straight away, 25 per request. The queue is memory only: events waiting when the process is killed are lost. This is deliberate, so a person's behaviour isn't left on disk after they may have withdrawn consent.
A 429 puts the batch back at the front of the queue and sends it again after backoff (from 1 s, at most 5 minutes, never sooner than Retry-After); the call returns .queued.
A network failure, timeout or 5xx moves the batch to the uncertain set (at most 500), because it may have arrived. Nothing resends it on its own: call retryUncertainEvents() (may double-count) or discardUncertainEvents(). Signing out discards it.
Other 4xx: dropped. Events older than 7 days are dropped from the queue.
Going to the background sends what is queued; returning to the foreground sends again.
Per platform
Kotlin
The Kotlin SDK has no event queue. Each track call is one request, and its result says what happened: Accepted, DroppedNoConsent, or Rejected(reason) when the server refused it or the request failed. A rejected event isn't stored, so retry it yourself if it matters. ContentFlowConfiguration.offline (OfflineConfig) is a deprecated setting that does nothing. String-sensor signals and location points are queued (500 each) and retried on network failures, 429 and 5xx.
Per platform
Flutter
Event queue (memory). Sent every 15 s (eventFlushMs) or at 10 events (eventBatchSize). With the outbox on, "sent" means moved into the outbox.
Offline outbox (stored). On by default (enableOfflineQueue). Kept in shared preferences, so it survives a restart. Sent 10 at a time while the device has a connection, and again as soon as connectivity returns. At most 500 events; the oldest is dropped first. Events older than 7 days are dropped when the app starts.
Retries. A network failure, 429 or 5xx is retried after 1, 2, 4, 8, 16, then 30 s (plus up to half a second of jitter), never sooner than Retry-After; after 5 retries the event is dropped. Other 4xx: dropped.
Seeing drops. Reported as a CFDroppedEventsException through onError and the errors stream.
No background flush. The SDK doesn't send its memory queue when the app is paused. Call flush() from your own lifecycle observer (see below).
maxOfflineQueueSize and maxRetries in the configuration are not used by the outbox, which always applies 500 and 5. rateLimiterStatus reports a limiter that no request goes through.
Privacy
Before consent, and after it is withdrawn
No platform queues events while analytics consent is off: they are dropped on the device and never sent later, even if consent is granted a moment afterwards. Record consent first (on identify, or with setConsent) and track after; see Consent: analytics consent.
JavaScript: withdrawing consent (setConsent(false), revokeAnalyticsLocally(), deleteIdentity()) empties the event queue and the outbox.
Swift: withdrawing consent empties the queue and the uncertain set.
Flutter: withdrawing consent stops new events but doesn't empty the outbox, so events stored before the change can still be sent. Call clearOfflineQueue() when the person withdraws consent.
In the app
Check the queues
Useful on a diagnostics screen, or before you tell the person their action was recorded.
// 1. Is the device online, and how many events wait in the offline outbox?const online = client.isOnline(); // navigator.onLine in a browserconst waiting = client.getOfflineQueueSize(); // 0 when enableOfflineQueue is false// 2. Hear about every event that will never be sent, and why.const stop = client.subscribeEventDrops((drop) =>console.warn(`${drop.dropped} event(s) dropped`, drop.dropReasons));
// 3. Optional: the client-side limiter (undefined when rateLimitPerSecond is 0).const limiter = client.getRateLimiterStatus();
import { useEffect, useState } from'react';
import { useCF } from'@contentflow/sdk/react';
functionSyncStatus() {
const cf = useCF();
const [dropped, setDropped] = useState(0);
// 2. Hear about every event that will never be sent.useEffect(() => cf.subscribeEventDrops((drop) =>setDropped((n) => n + drop.dropped)), [cf]);
// 1. Online, and how many events wait in the offline outbox (read on each render).// 3. cf.getRateLimiterStatus() is available too.return<p>{cf.isOnline() ? 'Online' : 'Offline'}, {cf.getOfflineQueueSize()} waiting, {dropped} dropped</p>;
}
import { useEffect, useState } from'react';
import { Text } from'react-native';
import { useCF } from'@contentflow/sdk/react-native';
functionSyncStatus() {
const cf = useCF();
const [dropped, setDropped] = useState(0);
// 2. Hear about every event that will never be sent.useEffect(() => cf.subscribeEventDrops((drop) =>setDropped((n) => n + drop.dropped)), [cf]);
// 1. React Native has no navigator.onLine: isOnline() is always true and the outbox stays// empty, so the useful number here is the drops.// 3. cf.getRateLimiterStatus() is available too.return<Text>{dropped} event(s) dropped</Text>;
}
// 1. Events waiting in memory, and events whose delivery is uncertain.let waiting =await client.pendingEventCount
let uncertain =await client.uncertainEventIds().count
// 2. Decide what to do with the uncertain ones: resend (may double-count) or drop.if uncertain >0 {
await client.retryUncertainEvents() // or: await client.discardUncertainEvents()
}
// 3. Every track call also says what happened to it:// .accepted, .queued, .droppedNoConsent, .deliveryUncertain(eventIds:), ...let result =tryawait client.trackEngagement(.tap(block))
// 1-2. No event queue to inspect: each track call is one request (from a coroutine),// and its result says what happened to it.when (val result = client.track(CFEngagementEvent.of(block, CFEngagementEvent.TAP))) {
TrackingDisposition.Accepted -> Unit
TrackingDisposition.DroppedNoConsent -> Unit// no analytics consent: nothing was sentis TrackingDisposition.Rejected -> log(result.reason) // not stored: retry it yourself if it matters
TrackingDisposition.Queued -> Unit
}
// 3. There is no client-side rate limiter; the SDK honours the server's Retry-After.
final cf = ContentFlow.instance;
// 1. Online, and how many events wait (the outbox, or the memory queue when the outbox is off).final online = cf.isOnline;
final waiting = cf.pendingEventCount;
// 2. Hear about events that will never be sent.
cf.errors.listen((error) {
if (error is CFDroppedEventsException) debugPrint('${error.droppedCount} dropped: ${error.counts}');
});
// 3. Optional: the limiter status (no request goes through it in this release).final limiter = cf.rateLimiterStatus;
In the app
Before sign-out and background
Send what is queued while the person is still signed in, so their events are attributed to them, then sign out. In JavaScript, logout() also sends the queue before it switches identity, and reports anything it still can't send as dropped (identity changed before delivery); flushing first lets you wait for it. In Flutter, flushing first matters most: logout() clears both queues.
// 1. Send what is queued while the person is still signed in.await client.flush(); // the memory queue (does nothing while a retry backoff runs)await client.flushOfflineQueue(); // the offline outbox, if the browser is online// 2. Sign out. Going to the background is handled by the SDK (a keepalive request on page hide).await client.logout();
import { useCF } from'@contentflow/sdk/react';
functionSignOutButton({ onSignedOut }: { onSignedOut: () => void }) {
const cf = useCF();
constsignOut = async () => {
// 1. Send what is queued while the person is still signed in.await cf.flush();
await cf.flushOfflineQueue();
// 2. Sign out.await cf.logout();
onSignedOut();
};
return<buttononClick={signOut}>Sign out</button>;
}
import { Button } from'react-native';
import { useCF } from'@contentflow/sdk/react-native';
functionSignOutButton({ onSignedOut }: { onSignedOut: () => void }) {
const cf = useCF();
constsignOut = async () => {
// 1. Send what is queued while the person is still signed in.await cf.flush();
// 2. Sign out. The SDK flushes on its own when AppState goes to the background.await cf.logout();
onSignedOut();
};
return<Buttontitle="Sign out"onPress={signOut} />;
}
// 1. Send what is queued while the person is still signed in.// .acknowledged, .nothingToFlush, .rejected or .deliveryUncertainlet flushed =await client.flushEvents()
// 2. Sign out. Anything still queued is sent later as the signed-out device;// the uncertain set is discarded. Going to the background flushes on its own.tryawait client.logout()
// From a coroutine.// 1. Engagement events aren't queued in the Kotlin SDK, so there are none to send.// String-sensor signals are: send those while the person is still signed in.
client.extensions.flushStringSensorEvents()
// 2. Sign out.
client.logout()
final cf = ContentFlow.instance;
// 1. Send what is queued while the person is still signed in: flush() moves the// memory queue into the outbox, flushOfflineQueue() sends the outbox now if online.await cf.flush();
await cf.flushOfflineQueue();
// 2. Sign out. logout() clears both queues: whatever step 1 couldn't send is lost.await cf.logout();
// Background: the SDK doesn't flush on pause. In your WidgetsBindingObserver:// if (state == AppLifecycleState.paused) cf.flush();
Revoking push or channel consent and signing out are separate from analytics; see Consent: sign-out.
Content
Content offline
Blocks keep working without a network: the SDK paints the last good blocks from its cache, and on a cold start with no network it tries its cache, then your fallbackUrl mirror, then the bundledCatalog you ship. The details are in Blocks: refresh and offline. An in-app campaign is fetched when you ask for it (getCampaigns), so offline it returns nothing; keep a bundled default for that placement.
Troubleshooting
When it doesn't work
What you see
Why, and the fix
Events tracked at app start never arrive
They were tracked before analytics consent and dropped on the device (consent_not_granted). Record consent first; nothing is held for a later grant.
Events from the last screen before sign-out are missing (Flutter)
logout() clears the memory queue and the outbox. Call flush() and flushOfflineQueue() before it.
onEventsUncertain fires, and the events never appear
A timeout or 5xx may or may not have stored them, so the default policy doesn't resend. Set eventRetryPolicy: 'at-least-once' if a duplicate is better than a gap.
A drop report says event queue capacity exceeded or offline capacity exceeded
More events were waiting than the queue holds (default 100 in JavaScript), so the oldest were dropped. Raise maxOfflineQueueSize (up to 500) or track fewer events.
Swift: uncertainEventIds() keeps growing
Sends are failing on the network or with 5xx. Those events are never resent on their own: call retryUncertainEvents() or discardUncertainEvents() when you're back online.
Kotlin: track returns Rejected
The request failed or was refused, and the event wasn't stored. Retry it yourself if it matters; see the reason for which.