BUILT INTO EVERY GOODSPEED APP
Offline Sync
Writes made while the device is offline are captured in a persistent AsyncStorage queue and replayed in insertion order on reconnect, each retried up to five times before it is dropped and logged. A stale-while-revalidate read cache keeps screens rendering with no network.
- Tier: Common
- Status: Config-toggled
- Config: features.offlineSync.enabled
WHY IT MATTERS
An app that only works online fails in the exact moments users notice: an elevator, a subway, a dead zone mid-form. The template makes offline a first-class path with two pieces in lib/offline.ts. queueMutation appends a write (id, endpoint, method, body) to a persistent queue capped at 100 entries, stored in AsyncStorage, or in the device keychain through expo-secure-store when offlineSync.encrypted is on and the payload fits under 2 KB. getCached and withCache wrap reads in a stale-while-revalidate cache: data within its TTL is fresh, data within 2x its TTL is returned with a stale flag for instant display, and older data is evicted. A screen renders from cache immediately and refreshes when the network returns.
Replay is handled by flushQueue, driven automatically by useOfflineSync, which subscribes to NetInfo and fires a flush the moment the device transitions from offline to online. The queue is processed strictly in insertion order with a sequential loop, so mutations land server-side in the order the user made them. A failed mutation is re-queued with an incremented retry count and retried up to five times across flushes before it is dropped and an offline_mutation_dropped event is sent to PostHog, so silent data loss becomes a visible metric. A module-level lock stops two flushes running at once, and the queue is written to disk before queueMutation resolves, so a crash after that call still replays the write on next launch. The sync hook is a no-op on web, where browsers handle connectivity differently.
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
export async function flushQueue(
executor: (p: MutationPayload) => Promise<void>,
): Promise<FlushResult> {
const result: FlushResult = { executed: 0, failed: 0, dropped: 0 };
if (flushing) return result; // module-level lock: no concurrent flush
flushing = true;
try {
const raw = await readQueue();
if (!raw) return result;
let queue: MutationPayload[];
try { queue = JSON.parse(raw); }
catch {
await removeQueue();
captureEvent('offline_queue_corrupted', { action: 'flush_queue' });
return result;
}
const remaining: MutationPayload[] = [];
for (const item of queue) {
try {
await executor(item); // strict insertion order
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, retries: item.retries });
}
}
}
await writeQueue(JSON.stringify(remaining));
return result;
} finally {
flushing = false;
}
}Source: goodspeed-apps/gas-template → lib/offline.ts
HONEST LIMITS
When Offline Sync is the wrong choice
The queue replays a single device's writes in order, with no server-side idempotency key or merge built in. If the same user edits the same record from two devices while both are offline, the device that reconnects second overwrites the first device's flushed writes, and nothing detects the clash. There is no vector clock, CRDT, or last-write-wins resolver in the template; that logic has to live in your executor or in Supabase, for example an upsert keyed on a stable id or an RLS-guarded trigger. For genuinely collaborative, concurrently-edited state, reach for a purpose-built sync engine rather than this queue. The queue body is JSON in AsyncStorage. Storing raw base64 images or files there will blow past AsyncStorage size limits and balloon serialization, which trips the 100-entry overflow crop and can drop earlier writes. Upload binaries to storage first and queue only the resulting URL or key. The queue is built for small structured mutations, not file transfer, so keep large payloads out of it.
Tier: Common · Config-toggled
Evaluate your use case
Check whether offline sync aligns with your target audience, platform constraints, and regulatory environment before enabling it.
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.
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
Apps built with Offline Sync
These apps were generated by Goodspeed and use offline sync as a core part of their experience. Each link goes to the full app marketing page.
CAPABILITIES
Offline Sync capability breakdown
Concrete dimensions of what the built-in offline sync implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Persistent write queue | queueMutation appends {id, endpoint, method, body} to a queue capped at 100 entries; on overflow the oldest entries are dropped first and an offline_queue_overflow event fires. | 100-entry cap |
| Replay on reconnect | useOfflineSync subscribes to NetInfo; an offline-to-online transition triggers flushQueue automatically, guarded by a wasOffline ref so it does not fire redundantly. | Automatic |
| In-order, retried delivery | flushQueue runs the queue with a sequential await loop, re-queuing a failed item up to 5 times before dropping it and emitting offline_mutation_dropped. | 5 retries |
| Stale-while-revalidate reads | getCached returns fresh data within TTL, usable stale data within 2x TTL, and evicts beyond that, so a screen renders from cache offline and refreshes on reconnect. | SWR cache |
| Encrypted queue option | With offlineSync.encrypted on, the queue is written to the iOS Keychain / Android Keystore via expo-secure-store, falling back to AsyncStorage when the payload exceeds 2 KB. | SecureStore |
COMMON QUESTIONS
If the app crashes, is a queued write lost?
It depends on the timing. queueMutation writes the queue to disk with AsyncStorage.setItem (or SecureStore) before the function resolves, so a crash after the await leaves the item safely persisted, and it replays on the next flushQueue after relaunch. A crash in the window between the user's action and the await queueMutation call loses that one mutation, because it was never written. The practical rule is to await queueMutation before showing the user a success state, so what they were told succeeded is exactly what survives a crash.
Are mutations replayed in order, and does one failure block the rest?
flushQueue iterates the queue with a sequential for...of loop and awaits each executor call, so mutations replay strictly in the order they were enqueued. A failed item does not block the ones behind it: it is pushed onto a remaining array with an incremented retry count and the loop moves on, so the rest of the queue still flushes in the same pass. The failed item is retried on later flushes up to five times, then dropped with an offline_mutation_dropped event so you can see it in PostHog.
How are conflicts resolved when offline edits hit the server?
The template does no server-side conflict resolution for you. flushQueue hands each queued mutation to an executor you supply, and that executor owns idempotency, for example a Supabase upsert keyed on a stable id so a replay is safe. useBackgroundSync additionally offers a client-side onConflict(local, remote) hook for merge decisions, but it is advisory and runs only on that hook's path. For raw queued writes, design the executor and your RLS or triggers to make double-applies harmless.
How do reads behave with no network?
getCached reads from AsyncStorage regardless of connectivity, so cached data is always available offline, returned with a stale flag once it is past its TTL. withCache wraps a loader in stale-while-revalidate: it serves cache immediately, revalidates in the background, and falls back to stale data if the network fetch fails. If a key has no cache entry and the device is offline, the loader inside withCache throws and the error propagates, so seed the cache on first online load for anything a screen must show offline.
GET IT BUILT INTO YOUR APP