Skip to content
Skip to content
Goodspeed

BUILT INTO EVERY GOODSPEED APP

Offline-First Sync with Write Queue

Writes made while offline are queued in AsyncStorage with TTL and automatically replayed in order when connectivity returns, so users never lose data or see failed-action errors on a flaky connection.

  • Tier: Common
  • Status: Config-toggled
  • Config: features.offlineSync.enabled

WHY IT MATTERS

Mobile apps live on unreliable networks. A user on a train fills in a form, taps submit, and the request hangs because the tunnel just killed their signal. Without offline support, that data is gone: the app shows an error, the user taps again, or gives up. The write queue in lib/offline.ts fixes this at the infrastructure layer rather than per-screen. Every mutation goes through queueMutation() before it reaches the network. When the device is online the queue is flushed immediately. When offline, the mutation is written to AsyncStorage under a per-app-slug key with a retry counter starting at zero. The queue persists across app restarts, so a user can close the app, reconnect hours later, and the pending writes drain automatically in submission order the moment NetInfo reports connectivity restored.

The queue is designed to degrade gracefully under pressure. It caps at 100 items (MAX_QUEUE_SIZE): if the app generates more pending writes than the cap allows, the oldest are evicted and a PostHog offline_queue_overflow event fires so operators can see the pressure in their analytics dashboard. Each flush attempt increments the retry counter on failed items. After 5 retries the item is dropped and an offline_mutation_dropped event is captured -- a permanent record that the write could not be replayed. For apps with sensitive mutation payloads, setting gasConfig.features.offlineSync.encrypted to true moves the queue storage from AsyncStorage to expo-secure-store (iOS Keychain / Android Keystore), with automatic fallback to AsyncStorage if the payload exceeds the 2KB SecureStore limit. The module-level flushing lock ensures concurrent flush calls never process the same item twice, preventing double-submit even when the app is backgrounded and foregrounded rapidly on an intermittent connection.

HOW IT IS WIRED

Real code from the GAS template

The code below is drawn from lib/offline.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.

// lib/offline.ts -- write queue: enqueue offline, flush on reconnect

export async function queueMutation(
  payload: Omit<MutationPayload, 'retries'>
): Promise<void> {
  const raw = await readQueue();
  let queue: MutationPayload[];
  try { queue = raw ? JSON.parse(raw) : []; }
  catch { queue = []; captureEvent('offline_queue_corrupted', { action: 'queue_mutation' }); }
  queue.push({ ...payload, retries: 0 });
  if (queue.length > MAX_QUEUE_SIZE) queue = queue.slice(-MAX_QUEUE_SIZE);
  await writeQueue(JSON.stringify(queue));
  addBreadcrumb('offline', 'Mutation queued', { endpoint: payload.endpoint });
}

export async function flushQueue(
  executor: (p: MutationPayload) => Promise<void>
): Promise<FlushResult> {
  const result: FlushResult = { executed: 0, failed: 0, dropped: 0 };
  if (flushing) return result;
  flushing = true;
  try {
    const queue: MutationPayload[] = JSON.parse(await readQueue() ?? '[]');
    const remaining: MutationPayload[] = [];
    for (const item of queue) {
      try { await executor(item); result.executed++; }
      catch {
        if (item.retries < 5) { remaining.push({ ...item, retries: item.retries + 1 }); result.failed++; }
        else { result.dropped++; captureEvent('offline_mutation_dropped', { endpoint: item.endpoint }); }
      }
    }
    await writeQueue(JSON.stringify(remaining));
    return result;
  } finally { flushing = false; }
}

Source: goodspeed-apps/gas-template lib/offline.ts

HONEST LIMITS

When Offline-First Sync with Write Queue is the wrong choice

The write queue is the wrong choice for real-time collaborative features where multiple users can edit the same record simultaneously. If user A is offline and queues a write to record X while user B is online and has already changed that record, replaying user A's write overwrites user B's changes silently. The template does not implement CRDT merge logic or operational transforms -- it replays writes in the order they were queued, full stop. For shared whiteboards, co-edited documents, or any surface where concurrent offline writes from different users should be merged rather than overwritten, the realtime-presence and optimistic-mutations patterns are the correct foundation. Apps that run exclusively in environments with guaranteed connectivity -- internal enterprise tools behind a corporate VPN, kiosk apps on wired displays, always-on server-side rendered apps -- pay storage and code overhead for a feature they will never exercise. If your target users never go offline and the network is stable, skip the offline queue and call Supabase directly. The queue adds approximately 5KB of AsyncStorage writes per mutation and a NetInfo subscription on app mount, neither of which matters at scale, but the conceptual overhead of explaining offline queue semantics to future maintainers is a real cost for apps that will never need it.

Tier: Common · Config-toggled

  1. Evaluate your use case

    Check whether offline-first sync with write queue aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. Audit the config

    The `features.offlineSync.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.

  3. Seek alternatives

    If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.

APPS USING THIS FEATURE

Every generated Goodspeed app includes offline-first sync with write queue. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.

CAPABILITIES

Offline-First Sync with Write Queue capability breakdown

Concrete dimensions of what the built-in offline-first sync with write queue implementation covers. These reflect the actual template code, not a marketing summary.

ItemDescriptionStrength
Queue storagePending mutations are written to AsyncStorage under a per-app-slug key. The queue survives app restarts and is flushed on the next successful reconnect.AsyncStorage (persistent)
Encryption optionWhen gasConfig.features.offlineSync.encrypted is true, the queue moves to expo-secure-store (iOS Keychain / Android Keystore) with automatic fallback for payloads over 2KB.SecureStore (optional)
Flush triggerflushQueue() fires on reconnect via NetInfo.addEventListener and can be called manually. A module-level lock prevents concurrent flush runs from processing the same items twice.On reconnect + manual
Retry policyEach mutation carries a retry counter. Transient failures increment the counter; items are kept in queue. After 5 retries the item is dropped and a PostHog offline_mutation_dropped event fires.5-retry limit
Overflow evictionThe queue is capped at 100 items. When exceeded, the oldest items are evicted and an offline_queue_overflow PostHog event fires so operators can monitor pressure.100-item cap

COMMON QUESTIONS

Does the write queue work on both iOS and Android?

Yes. The queue uses AsyncStorage (@react-native-async-storage/async-storage) as its default storage layer, which works on both iOS and Android. When gasConfig.features.offlineSync.encrypted is true, the module attempts to use expo-secure-store instead, which maps to iOS Keychain on iOS and Android Keystore on Android. The 2KB fallback threshold is applied on both platforms: if the serialized queue exceeds the SecureStore value-size limit, the module writes to AsyncStorage and records a breadcrumb in Sentry so the fallback is observable. Web is supported via AsyncStorage's web adapter, which falls back to localStorage, though the offline use case is less relevant in browser environments where service workers are the conventional approach.

How are data conflicts handled when a queued write replays against a changed record?

The queue does not implement conflict resolution. It replays each mutation exactly as it was submitted, using the same endpoint, method, and body. If the remote record has changed since the mutation was queued, the replay will overwrite the remote state with the queued payload. This is the correct behavior for single-user apps where only one person mutates each record, which covers the vast majority of Goodspeed-generated apps. If you are building a multi-user app where two users can independently edit the same record while offline, you need to implement conflict detection in the executor function passed to flushQueue() and decide on a resolution strategy (last-write-wins, merge, or surface a conflict to the user). The queue itself is agnostic to the resolution strategy.

What happens if the app is force-quit while a flush is in progress?

The queue is durable. Because every mutation is written to AsyncStorage before the network call is attempted, the queue state on disk always reflects the pending mutations at the time of the crash. When the app restarts, the next flush attempt reads the queue from storage and replays any items that were not successfully executed before the crash. The module-level flushing lock is reset to false on module initialization, so there is no stale lock to clear after a restart. The only risk is a mutation that was successfully executed server-side but not yet removed from the local queue -- if the app crashes between the executor returning success and the writeQueue call that removes the item, the mutation will be replayed on the next flush. The executor should be idempotent for this edge case, which is satisfied by Supabase upsert operations and most REST endpoints that use a client-generated ID.

Can I inspect or clear the queue from the app for debugging?

Yes. getQueueLength() returns the current item count without modifying the queue, suitable for rendering a sync indicator badge. To clear the queue programmatically (for example, on sign-out to avoid replaying another user's writes), call removeQueue() which deletes the AsyncStorage key. The CACHE_PREFIX and QUEUE_KEY constants are derived from gasConfig.app.slug, so they are namespaced per-app and will not collide with other GAS apps on the same device. In development, the queue key can be read directly from AsyncStorage using a tool like Flipper or Expo Dev Menu to inspect pending items. The MAX_QUEUE_SIZE of 100 items ensures the serialized queue stays under 50KB in typical usage, keeping AsyncStorage reads fast.

GET IT BUILT INTO YOUR APP

Score your idea and get offline-first sync with write queue wired in from day one