◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-04-12Intermediate

Building Offline-First Apps with Rork — Practical Cache Strategies Using AsyncStorage and SQLite

A step-by-step guide to adding offline capabilities to Rork-generated apps, covering data persistence, background sync, and conflict resolution patterns that prevent user churn.

rork59offline-first3asyncstoragesqlitecache-strategy

You open the app on the subway. White screen. The spinner keeps spinning. Five seconds later: "Network Error." The user closes the app and never comes back. One-star reviews pile up on the App Store.

Apps built with Rork often rely on API data for their primary views. But an app that ignores unreliable network conditions is unusable in the real world, no matter how polished its features are. Offline support is not a nice-to-have — it is foundational infrastructure for app survival.

AsyncStorage vs SQLite: Choosing the Right Tool

React Native (the framework underlying Rork's generated code) offers two primary options for data persistence: AsyncStorage and SQLite. Choosing wrong means a painful migration later.

Use AsyncStorage for:

  • User preferences (theme, notification toggles, language)
  • Authentication tokens
  • Last-viewed screen IDs
  • Small JSON payloads under 10KB

Use SQLite for:

  • Structured data like article lists or product catalogs
  • Data that needs searching or filtering
  • Lists exceeding 100 items
  • Data with relational structure

The decision rule is simple: "Will I need to query this data conditionally?" If yes, use SQLite. If no, use AsyncStorage. AsyncStorage is a key-value store and is unsuitable for queries like "items in category X created after date Y."

Basic AsyncStorage caching pattern for a Rork-generated app:

import AsyncStorage from '@react-native-async-storage/async-storage';
 
// Write cache with TTL
const cacheData = async (key: string, data: unknown, ttlMinutes: number = 60) => {
  const cacheEntry = {
    data,
    timestamp: Date.now(),
    ttl: ttlMinutes * 60 * 1000,
  };
  await AsyncStorage.setItem(key, JSON.stringify(cacheEntry));
};
 
// Read cache with expiry check
const getCachedData = async <T>(key: string): Promise<T | null> => {
  const raw = await AsyncStorage.getItem(key);
  if (\!raw) return null;
 
  const entry = JSON.parse(raw);
  const isExpired = Date.now() - entry.timestamp > entry.ttl;
 
  if (isExpired) {
    await AsyncStorage.removeItem(key);
    return null;
  }
 
  return entry.data as T;
};

TTL (Time To Live) calibration matters. Too short and data disappears when users go offline; too long and stale content lingers. From experience, news feeds work well at 30 minutes, user profiles at 24 hours, and master data at 7 days.

SQLite for Offline List Screens

List screens with 100+ items need SQLite. Here is how to integrate expo-sqlite into a Rork project:

import * as SQLite from 'expo-sqlite';
 
const db = SQLite.openDatabaseSync('app_cache.db');
 
// Initialize tables (run once at app startup)
const initDatabase = () => {
  db.execSync(`
    CREATE TABLE IF NOT EXISTS articles (
      id TEXT PRIMARY KEY,
      title TEXT NOT NULL,
      category TEXT,
      content TEXT,
      cached_at INTEGER,
      is_synced INTEGER DEFAULT 1
    )
  `);
  db.execSync(`
    CREATE INDEX IF NOT EXISTS idx_articles_category 
    ON articles(category)
  `);
};
 
// Batch cache (API response → SQLite)
const cacheArticles = (articles: Article[]) => {
  const now = Date.now();
  const stmt = db.prepareSync(
    'INSERT OR REPLACE INTO articles (id, title, category, content, cached_at) VALUES (?, ?, ?, ?, ?)'
  );
 
  db.execSync('BEGIN TRANSACTION');
  for (const article of articles) {
    stmt.executeSync(article.id, article.title, article.category, article.content, now);
  }
  db.execSync('COMMIT');
};
 
// Category-filtered retrieval (fast search even offline)
const getArticlesByCategory = (category: string): Article[] => {
  return db.getAllSync<Article>(
    'SELECT * FROM articles WHERE category = ? ORDER BY cached_at DESC',
    category
  );
};

Wrapping bulk inserts in BEGIN TRANSACTION and COMMIT is critical. Inserting 100 rows individually takes around 5 seconds; with a transaction wrapper, it drops below 0.1 seconds. This is a major performance factor for Rork-generated apps.

UI State Management While Offline

Caching data alone is not enough. Users need to know they are viewing cached content:

import NetInfo from '@react-native-community/netinfo';
import { useEffect, useState, useCallback } from 'react';
 
const useNetworkAwareData = <T>(
  fetchFn: () => Promise<T>,
  cacheKey: string
) => {
  const [data, setData] = useState<T | null>(null);
  const [source, setSource] = useState<'network' | 'cache' | 'none'>('none');
  const [isRefreshing, setIsRefreshing] = useState(false);
 
  const loadData = useCallback(async () => {
    const netState = await NetInfo.fetch();
 
    if (netState.isConnected) {
      try {
        setIsRefreshing(true);
        const freshData = await fetchFn();
        setData(freshData);
        setSource('network');
        await cacheData(cacheKey, freshData);
      } catch {
        // Network available but API is down
        const cached = await getCachedData<T>(cacheKey);
        if (cached) {
          setData(cached);
          setSource('cache');
        }
      } finally {
        setIsRefreshing(false);
      }
    } else {
      // Offline: read from cache
      const cached = await getCachedData<T>(cacheKey);
      if (cached) {
        setData(cached);
        setSource('cache');
      }
    }
  }, [fetchFn, cacheKey]);
 
  useEffect(() => { loadData(); }, [loadData]);
 
  return { data, source, isRefreshing, refresh: loadData };
};

The source field is the key design element. Use it to show a contextual indicator:

const ArticleList = () => {
  const { data, source, isRefreshing, refresh } = useNetworkAwareData(
    fetchArticles,
    'articles_list'
  );
 
  return (
    <View>
      {source === 'cache' && (
        <View style={styles.offlineBanner}>
          <Text>Offline mode — showing last fetched data</Text>
        </View>
      )}
      <FlatList
        data={data}
        refreshing={isRefreshing}
        onRefresh={refresh}
        renderItem={({ item }) => <ArticleCard article={item} />}
      />
    </View>
  );
};

Optimistic Updates: Writing While Offline

Beyond reads, write operations (likes, bookmarks, comments) should also work offline. This is where the optimistic update pattern comes in:

const usePendingActions = () => {
  const addPendingAction = async (action: PendingAction) => {
    const pending = await getCachedData<PendingAction[]>('pending_actions') || [];
    pending.push({ ...action, createdAt: Date.now() });
    await cacheData('pending_actions', pending, 7 * 24 * 60); // Keep for 7 days
  };
 
  const syncPendingActions = async () => {
    const pending = await getCachedData<PendingAction[]>('pending_actions') || [];
    const failed: PendingAction[] = [];
 
    for (const action of pending) {
      try {
        await executeAction(action);
      } catch {
        failed.push(action);
      }
    }
 
    await cacheData('pending_actions', failed, 7 * 24 * 60);
    return { synced: pending.length - failed.length, failed: failed.length };
  };
 
  return { addPendingAction, syncPendingActions };
};

On the UI side, reflect the user's action immediately and sync in the background:

const handleLike = async (articleId: string) => {
  // 1. Update UI instantly (optimistic)
  setLiked(true);
  setLikeCount(prev => prev + 1);
 
  // 2. Queue the pending action
  await addPendingAction({
    type: 'like',
    payload: { articleId },
  });
};

When the network returns, syncPendingActions sends queued operations to the server. Ideally, use a NetInfo listener to detect reconnection and trigger sync automatically.

Your Next Move

Start by caching data for your most-visited screen using AsyncStorage. That alone eliminates the "white screen on the subway" problem. Offline write support can be introduced incrementally once read caching is stable.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-05-20
Fixing 0x8badf00d Watchdog Kills That Wipe Out Rork Apps at Launch
Your Rork iOS app dies right after launch on real devices, but never on your bench. Crashlytics shows exception code 0x8badf00d. Here is how to read the exact watchdog budget out of the crash log and rearrange your launch path so it stops.
⬡ Dev Tools2026-04-24
When Your Rork App Binary Tops 150MB: 5 Causes to Isolate and a Step-by-Step Slim-Down Plan
My Rork-built app archived at 180MB and App Store Connect stopped me cold. Here is how I broke the bloat into 5 measurable causes and got it down to 69MB — including the three cuts that backfired.
⬡ Dev Tools2026-10-15
When EXPO_PUBLIC_ Values Read Fine in Dev but Come Back undefined in Production: Telling the Spellings Apart
The API URL reads fine while developing and turns undefined once the app is built. The cause was how process.env was written. I lined up babel-preset-expo's output for dev and production and tabulated which spellings get replaced and which are left behind.