@clickstreamhq/react-nativeis a developer preview on the 0.1.0-alpha line. Everything below — storage keys, flush triggers, retry math, batch caps — is read straight from the SDK source and the collector's event route, not from a wishlist. Expect the surface to evolve before a stable release; of the ClickStream packages, only@clickstreamhq/sdkis stable today.
Why Offline Mobile Analytics Is a Different Problem
On the web, a page load implies a network. On mobile, the app opens wherever the user is: a subway tunnel, a parking garage, seat 23F with the radio off. Sessions don't pause for connectivity — people browse a cached catalog, tap through screens, and add things to carts with zero bars. An analytics call that does fetch() and hopes silently drops exactly those events.
Worse, the loss isn't random. Offline usage clusters around commutes, flights, and buildings with bad reception — so fire-and-forget tracking doesn't just lose some data, it systematically under-counts a specific slice of real behavior. And the network isn't the only adversary: the OS can suspend or kill a backgrounded app at any moment, taking anything held only in memory with it.
Offline mobile analytics therefore needs two things: a queue that survives both disconnection and process death, and delivery semantics that are honest about the failure cases. Here's how the ClickStream React Native SDK builds both.
Persist First, Send Second
Every public tracking call — screen(), tap(), trackEvent(), identify() — appends a fully-formed wire event to an in-memory queue and immediately writes the whole queue through an injected StorageAdapter. The write happens before any network attempt. The SDK persists exactly three keys, namespaced so they never collide with app storage:
cs_rn:visitor_id— the anonymous visitor id, minted once and reused across launches (the mobile half of cross-platform identity)cs_rn:session— the session id plus a last-activity timestamp for the 30-minute idle rotationcs_rn:queue— the durable offline queue itself, serialized as JSON
The canonical adapter is React Native's AsyncStorage, whose interface matches StorageAdapter exactly — you pass it straight in:
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import { createClickStream } from '@clickstreamhq/react-native';
export const cs = createClickStream({
apiKey: 'cs_mob_live_xxx', // dedicated mobile key from the dashboard
endpoint: 'https://t.example.com', // your first-party collector domain
storage: AsyncStorage, // the durable part
appStateProvider: AppState, // the flush-on-foreground part
appName: 'Acme',
appVersion: '2.1.0',
});
On the next launch, init() hydrates all three keys: the visitor id is reused, the session is checked against the idle window, and the persisted queue is loaded back into memory — so events recorded in yesterday's tunnel are first in line today. Two defensive details are worth knowing. A corrupt queue (unparseable JSON) is dropped rather than crashing the app, and a queue longer than the cap is trimmed to the newest 1,000 events on load.
One ordering detail matters more than it looks: the public API is fire-and-forget, but internally every call is chained through a serialized work queue, so events land in call order no matter how slow the storage adapter is. Your funnel doesn't reshuffle because AsyncStorage had a slow day.
Omit
storageand the SDK falls back to an in-memory store. That's deliberate — tests and SSR run with zero peer dependencies — but in-memory data does not survive a restart. Production apps must pass AsyncStorage.
When the Queue Flushes
Durability answers "what if we can't send?" — flush triggers answer "when do we try?" There are five, all verifiable in the tracker source:
- Buffer threshold — enqueueing the event that brings the queue to
batchSize(default 20) kicks off a flush. - Interval timer — every
flushIntervalMs(default 10 seconds) a flush runs. The timer isunref'd so it never keeps the JS event loop alive, andflushIntervalMs: 0disables it entirely. - Foregrounding — when AppState reports
'active', the SDK re-checks the session idle window and drains the queue. This is the trigger that empties a tunnel's worth of events the moment the user resurfaces. - Manual —
await cs.flush(), for moments you know matter (a completed purchase screen, say). - Teardown —
shutdown()stops the timers and flushes what remains.
Notice what's not on the list: a background-transition flush. The SDK doesn't race the OS suspend with a last-gasp network call, because it doesn't need to — the event was already on disk at enqueue time. Backgrounding costs nothing; the next foreground or launch picks the queue back up.
On the wire, a flush drains the queue front-to-back as POST /v1/events requests of at most 25 events each — that's the collector's hard schema cap (events: z.array(eventSchema).min(1).max(25)). Setting batchSize above 25 just buffers more between flushes; requests are always split to the cap.
Retry, Backoff, and the Fate of a Failed Batch
Each batch is delivered with the dedicated mobile key in an X-API-Key header. Mobile keys (cs_mob_live_*) are provenance-exempt at the collector — native apps have no Origin or Referer to send, and none is required. (The key families and why each behaves differently are covered in API Keys and Domain Gating.) What happens next depends entirely on the response class:
| Response | Verdict | What the queue does |
|---|---|---|
| 2xx | Accepted | Batch removed from the queue head; queue re-persisted |
| 4xx (except 429) | Permanent reject | Batch dropped on purpose — no retry |
| 429 / 5xx | Transient | Retry in place with exponential backoff |
| Network error | Offline | Retry in place, then stay queued and persisted |
The backoff schedule is 1 s, 2 s, 4 s, 8 s… capped at 30 seconds, for the initial attempt plus up to maxRetries retries per flush (default 5). When a batch exhausts its retries, it is not discarded — the flush loop stops, the batch stays at the front of the persisted queue, and the next trigger (or the next app launch) tries again with a fresh retry budget.
The deliberate exception is the permanent 4xx: a batch the collector rejects as malformed or unauthorized would jam everything behind it if retried forever — classic head-of-line blocking — so it's dropped instead. Ordering is otherwise strictly FIFO: batches leave the queue head only on acceptance (or permanent rejection), so events never reorder in transit.
At-Least-Once, With Dedup on the Other End
Put those rules together and the queue's contract is at-least-once delivery: an event is never lost merely because the network was down, but the same event can arrive twice. The classic case — the collector accepts a batch, and the 2xx response dies in the tunnel. The client can't distinguish "never arrived" from "arrived, reply lost," so it must retry.
Fire-and-forget is the opposite contract, at-most-once, and for mobile analytics it's the wrong one. A duplicate event is a solvable problem; a silently missing event is invisible — you can't fix what you never knew you lost, and as noted above, mobile's losses are systematically biased toward offline moments.
The duplicate half of the bargain is handled server-side. The collector computes a dedup signature for each event — visitor id, session id, timestamp rounded to the second, event type, and page URL — and a retried event whose signature was already processed within a 30-second window is skipped before the identity-graph write path. Two properties of the SDK make that signature line up: each event's timestamp is captured when the event is recorded (not when it's delivered), and retries resend the identical payload byte for byte.
To be precise about the boundaries, because this is where analytics vendors tend to hand-wave: that dedup window is an in-memory cache sized to absorb retry storms, not a global exactly-once ledger. A rare crash in the gap between the server accepting a batch and the client persisting its removal can replay events outside the window. The design accepts that trade knowingly — a rare duplicate beats a lost conversion event every time you have to choose.
What Is — and Isn't — Guaranteed
With AsyncStorage wired in, the SDK guarantees:
- Persist-before-send. Every event is written to disk before any delivery attempt, and survives app kills and restarts.
- Call-order preservation. Events queue in the order your code recorded them and drain FIFO.
- Bounded resource use. The queue caps at 1,000 events, backoff caps at 30 seconds, requests cap at 25 events.
- Consent is a hard gate.
setConsent({ analytics: false })stops both recording and transport — offline durability never overrides an opt-out.
And it does not guarantee:
- Delivery from an app that never comes back. Uninstall the app, or never reopen it, and the queued events are gone with it.
- Unbounded retention. Past 1,000 queued events, the oldest are evicted. A months-long airplane mode loses the beginning of the story, not the end.
- Delivery of permanently rejected batches. Non-429 4xx responses drop the batch by design.
- Durability without AsyncStorage. The in-memory default is for tests, full stop.
- Exactly-once delivery. As above: at-least-once, deduplicated best-effort on the server.
Storage writes themselves are best-effort, too — a full or broken store logs nothing and never crashes event tracking, at the price of durability for those writes. Analytics must never be the reason an app falls over. If that philosophy sounds familiar, it's the same fail-open posture the read side takes in Fail-Open Personalization: the SDK's failure mode is your app, unchanged.
Battery and Network Etiquette
A durable queue also happens to be the polite way to use a phone's radio. Waking the cellular modem is expensive; the dominant cost is wakeups, not bytes. Batching amortizes it — one request per 20 events or 10 seconds instead of one per tap. Beyond that:
- No connectivity polling. The SDK never sits in a loop probing for network. It tries on natural triggers — threshold, timer, foreground — and goes quiet in between.
- Timers never hold the process. Both the flush interval and backoff timers are
unref'd. - Backoff caps at 30 seconds. A struggling collector gets breathing room instead of a hammering.
- No background machinery. No background fetch, no silent pushes, no OS background tasks — disk persistence makes all of that unnecessary.
- Bounded disk. The whole queue lives under one AsyncStorage key with a 1,000-event ceiling.
Testing Offline Behavior in Development
The cheapest test is the honest one: put the device in airplane mode mid-session, tap around, kill the app, relaunch, restore the network, and watch the events arrive in your dashboard in order. On iOS simulators, Network Link Conditioner adds flaky-network profiles; the Android emulator's extended controls can degrade or cut data per-run.
While offline, you can watch the queue grow from a debug screen — the storage key is public and stable:
const raw = await AsyncStorage.getItem('cs_rn:queue');
const queued = raw ? JSON.parse(raw) : [];
console.log(`${queued.length} events waiting for a network`);
For deterministic tests, the config takes injection points for exactly the seams you need: fetchImpl (the transport), now (the clock), and storage. The SDK's own test suite uses them to prove the auto-flush threshold, the 25-event splitting, and retry behavior; yours can simulate a tunnel in three lines:
import { createClickStream, MemoryStorage } from '@clickstreamhq/react-native';
let online = false;
const flakyFetch = (async () => {
if (!online) throw new TypeError('Network request failed');
return { status: 202 } as Response;
}) as typeof fetch;
const cs = createClickStream({
apiKey: 'cs_mob_live_test',
endpoint: 'https://t.example.com',
storage: new MemoryStorage(),
fetchImpl: flakyFetch,
maxRetries: 0, // fail fast: one attempt per batch
flushIntervalMs: 0, // no timer; flushes only when you say so
});
cs.tap('add_to_cart');
await cs.ready(); // the fire-and-forget call has landed in the queue
await cs.flush(); // "offline": delivery fails, the batch stays queued
online = true;
await cs.flush(); // "tunnel exit": the queue drains to /v1/events
ready() is the piece people miss: tracking calls are fire-and-forget, so awaiting it guarantees the event has actually landed in the queue before you assert on a flush. (In fact flush() awaits the same pending work internally — the explicit call just makes the test read honestly.)
The Bottom Line
- Persist first, send second. Events hit AsyncStorage under
cs_rn:queuebefore any network attempt — tunnels, airplane mode, and app kills stop delivery, not recording. - Five flush triggers — 20-event threshold, 10-second timer, foregrounding, manual, teardown — and 25-event wire batches, matching the collector's schema cap.
- Transient failures retry and persist; permanent rejects drop. Exponential backoff capped at 30 s; FIFO order throughout.
- At-least-once beats fire-and-forget. Duplicates are absorbed by a server-side dedup signature; losses would be invisible and biased.
The tracking half is only half of the mobile story — once events flow, the same visitor gets scores you can read back on-device. That read side starts at the Signals getting-started guide, and the full mobile setup — screens, taps, identity, session rotation — is in the React Native instrumentation guide.
On mobile, the network is a sometimes thing. Persist first, send second, and let the server absorb the duplicates — the alternative is analytics that quietly forgets everyone's commute.