Engineering

Offline-First Mobile Analytics: the Durable Queue

How @clickstreamhq/react-native survives tunnels, airplane mode, and app kills: persist first, send second, retry with backoff — and be honest about what that does and doesn't guarantee.

July 2026 • 9 min read • Developer preview (0.1.0-alpha)

@clickstreamhq/react-native is 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/sdk is 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:

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 storage and 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:

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:

ResponseVerdictWhat the queue does
2xxAcceptedBatch removed from the queue head; queue re-persisted
4xx (except 429)Permanent rejectBatch dropped on purpose — no retry
429 / 5xxTransientRetry in place with exponential backoff
Network errorOfflineRetry 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:

And it does not guarantee:

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:

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

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.

Track Through the Tunnel

Wire AsyncStorage into the React Native developer preview, put your phone in airplane mode, and watch every event arrive when you land. That's the whole demo.

Start free