RORK LABJP
MAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode requiredSTACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decisionFOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generationBUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebaseFUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)PRICING — It is free to start, with paid plans from $25/month, so you can try before committingMAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode requiredSTACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decisionFOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generationBUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebaseFUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)PRICING — It is free to start, with paid plans from $25/month, so you can try before committing
Articles/Dev Tools
Dev Tools/2026-06-18Advanced

Retrofitting Offline-First Into a Rork App: Persistent Cache and a Write Queue

Reviews kept saying the app was blank on the subway. Polishing error screens was not enough, so I retrofitted TanStack Query persistence and an offline write queue into a Rork-generated Expo app. Optimistic updates, reconnect flushing, and keeping the layer safe from regeneration are all covered with code.

Rork418Expo85offline-first2TanStack Queryindie developer26

Premium Article

About six months ago I started getting a run of two-star reviews that all said roughly the same thing: open the app on the subway platform and it sits there blank. I reproduced it in a low-signal spot, and sure enough the home screen stayed empty with a spinner turning forever.

I had already built "loading" and "retry" error screens for flaky networks. But the people leaving those reviews were not asking for a nicer error screen. They wanted one thing: "show me what I saw yesterday, right now."

An error screen is a way to report failure politely. Offline-first is a way to avoid showing failure at all. A Rork-generated app assembles its screens around server responses by default, so when the connection drops there is nothing to draw. What I needed was a layer that paints last session's data on launch and never throws away an action taken offline.

This is a record of retrofitting TanStack Query v5 persistence and an offline write queue into a Rork (React Native) Expo project. As an indie developer running several apps, I want to focus on how to make this work under two specific constraints: it is a retrofit, and the AI regenerates the project.

Paint the last data before you polish error screens

A note on priorities first. "Offline support" usually starts with retry buttons and connectivity banners, but the change that moved the needle most was removing the blank screen on launch.

If the generated code already uses TanStack Query, it fetches with useQuery. By default that cache lives only in memory and is gone when the app closes, so the next cold start refetches from scratch, which means a blank screen when there is no signal.

Persistence is what fixes this. Write fetched data to storage, restore it into memory on launch, and even offline the user immediately sees "last time's content." Most of those reviews went quiet on this single change.

You use @tanstack/react-query-persist-client, and on Expo you can persist to @react-native-async-storage/async-storage.

// guarded/offline/queryClient.ts
import { QueryClient } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import AsyncStorage from "@react-native-async-storage/async-storage";
 
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // in-memory cache lifetime (was cacheTime)
      staleTime: 1000 * 60 * 5,    // do not refetch for 5 minutes
      retry: 2,
    },
  },
});
 
const persister = createAsyncStoragePersister({
  storage: AsyncStorage,
  key: "RORK_RQ_CACHE_V1", // bump the suffix to drop the cache on schema change
  throttleTime: 1000,      // throttle writes to once per second
});
 
export function setupPersistence() {
  persistQueryClient({
    queryClient,
    persister,
    maxAge: 1000 * 60 * 60 * 24, // do not restore persisted cache older than 24h
    dehydrateOptions: {
      // only persist successful queries; never bake in an error state
      shouldDehydrateQuery: (q) => q.state.status === "success",
    },
  });
}

I set maxAge to 24 hours because my baseline is data like wallpapers or articles: fine to be a little stale, but awkward if it is more than a day old. For data where freshness carries meaning, such as balances or inventory, tighten this to minutes or pair it with the "show how old this is" idea below. Note that gcTime is the in-memory lifetime and is a separate axis from the persisted maxAge. Extend one without the other and you get a blank flash the moment memory is evicted.

On the app side you just wrap with PersistQueryClientProvider. Because Rork regenerates app/_layout.tsx, I keep the wiring here down to a single line (the reasoning comes later).

// the minimal wiring point in app/_layout.tsx
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { queryClient, persister } from "../guarded/offline/queryClient";
 
export default function RootLayout() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
    >
      {/* the existing tree Rork generates */}
    </PersistQueryClientProvider>
  );
}

Never lose an offline tap: a write queue with optimistic updates

Persistence removed the blank screen on reads. Writes are next. When someone taps "add to favorites" while offline and nothing happens, they forget they ever tapped it. The quiet rating-killer was people noticing later, once back online, that it was never saved.

The design call here is to reflect the write on screen immediately and defer the send. TanStack Query's onMutate updates the UI optimistically and onError rolls back. On top of that, while offline I hold the send itself and push it onto a local queue.

// guarded/offline/mutationQueue.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
 
export type PendingMutation = {
  id: string;          // unique key used to reject duplicate sends
  kind: "favorite.add" | "favorite.remove";
  payload: Record<string, unknown>;
  createdAt: number;
};
 
const QUEUE_KEY = "RORK_PENDING_MUTATIONS_V1";
 
export async function enqueue(m: PendingMutation): Promise<void> {
  const raw = await AsyncStorage.getItem(QUEUE_KEY);
  const list: PendingMutation[] = raw ? JSON.parse(raw) : [];
  // if the same id is already queued, do not push again (double-tap / retry)
  if (list.some((x) => x.id === m.id)) return;
  list.push(m);
  await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(list));
}
 
export async function getQueue(): Promise<PendingMutation[]> {
  const raw = await AsyncStorage.getItem(QUEUE_KEY);
  return raw ? JSON.parse(raw) : [];
}
 
export async function removeFromQueue(id: string): Promise<void> {
  const list = await getQueue();
  await AsyncStorage.setItem(
    QUEUE_KEY,
    JSON.stringify(list.filter((x) => x.id !== id)),
  );
}

The "add favorite" useMutation looks like this. Even when the send fails (including offline), it enqueues and lets the UI move forward optimistically.

// guarded/offline/useFavoriteMutation.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { enqueue } from "./mutationQueue";
import { addFavoriteRequest } from "../../app/api/favorites"; // send fn in the generated code
 
export function useAddFavorite() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (itemId: string) => addFavoriteRequest(itemId),
    onMutate: async (itemId) => {
      await qc.cancelQueries({ queryKey: ["favorites"] });
      const prev = qc.getQueryData<string[]>(["favorites"]) ?? [];
      // update the screen immediately (optimistic update)
      qc.setQueryData<string[]>(["favorites"], [...prev, itemId]);
      return { prev };
    },
    onError: async (_err, itemId, ctx) => {
      // a failed send likely means offline. Do not revert the UI; stash to the queue
      await enqueue({
        id: `fav-add-${itemId}`,
        kind: "favorite.add",
        payload: { itemId },
        createdAt: Date.now(),
      });
      // The key is NOT rolling back to ctx.prev here. Rolling back means "I tapped it and it vanished."
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["favorites"] });
    },
  });
}

The part I agonized over was onError. The textbook version rolls back to ctx.prev. But offline-first, rolling back produces the worst experience: the item disappears the instant you tap it. I decided to treat a failed send as "send it later," keeping the UI advanced while stashing the action. To be stricter, branch on the error type so that a genuine server error (like a permission failure) does roll back.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
A persistQueryClient + AsyncStorage setup that paints last session's data on launch, with the reasoning behind a 24-hour maxAge
An optimistic-update mutation queue that never loses an offline tap, plus the full NetInfo code that flushes it once on reconnect
How to keep the retrofitted layer out of Rork's regeneration path with a guarded/ folder, and the duplicate-flush and stale-data bugs I hit in production
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
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 →

Related Articles

Dev Tools2026-06-14
Actually Delivering 'It Updates Without Opening' in Expo — A Realistic Background Task Design
Building 'content refreshes every morning' into a Rork-generated Expo app runs into iOS background execution being far less dutiful than you expect. Here is a minimal expo-background-task setup plus a design that doesn't break when the task never runs.
Dev Tools2026-06-14
When Your Rork App Gets ITMS-91053 — A Practical Guide to Privacy Manifests and Required Reason APIs
Submitting a Rork-generated Expo app to the App Store can trigger Privacy Manifest warnings even when you never wrote the offending code. Here is how to clear both Required Reason API and SDK manifest issues before you submit.
Dev Tools2026-05-26
Two Weeks Tightening Up iPad Support for a Rork-Generated Wallpaper App
Notes from spending two weeks tightening up iPad support for a wallpaper app I scaffolded with Rork. Coming from an iPhone-centric indie practice since 2014, I cover where Rork's defaults stopped, how the AdMob adaptive banner misbehaved on iPad, and what changed in retention afterwards.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →