Skip to content

Durable Generation Workflow Recipes

A React Native guide for coding agents. Also covers ai generation workflow, long running jobs, generation progress, retry and resume, sqlite persistence, zustand persistence.

Long-running image, story, audio, and video generation should survive screen changes and app restarts. Persist the job identity and stage, keep state transitions explicit, and let the server remain the source of truth for completion.

Use a Discriminated Job State

Avoid many booleans such as isLoading, isWriting, and hasError; impossible combinations appear quickly.

import { z } from "zod";

export const generationJobSchema = z.discriminatedUnion("status", [
  z.object({ status: z.literal("draft"), localId: z.string() }),
  z.object({ status: z.literal("submitting"), localId: z.string() }),
  z.object({
    status: z.literal("running"),
    localId: z.string(),
    remoteId: z.string(),
    stage: z.enum(["planning", "writing", "images", "audio", "finalizing"]),
    progress: z.number().min(0).max(1),
  }),
  z.object({
    status: z.literal("complete"),
    localId: z.string(),
    remoteId: z.string(),
    resultUri: z.string(),
  }),
  z.object({
    status: z.literal("failed"),
    localId: z.string(),
    remoteId: z.string().optional(),
    message: z.string(),
    retryable: z.boolean(),
  }),
]);

export type GenerationJob = z.infer<typeof generationJobSchema>;

Persist the stage the server reports; do not fake progress with a timer. If exact percentage is unavailable, show the current named stage.

Persist and Validate on Rehydration

SQLite-backed key-value storage is sufficient for a small active-job queue. Use a proper table when you need queries, history, or many jobs.

import Storage from "expo-sqlite/kv-store";

const ACTIVE_JOBS_KEY = "generation.active-jobs.v1";

export async function saveJobs(jobs: GenerationJob[]) {
  await Storage.setItem(ACTIVE_JOBS_KEY, JSON.stringify(jobs));
}

export async function loadJobs(): Promise<GenerationJob[]> {
  const raw = await Storage.getItem(ACTIVE_JOBS_KEY);
  if (!raw) return [];

  try {
    const parsed = z.array(generationJobSchema).safeParse(JSON.parse(raw));
    return parsed.success ? parsed.data : [];
  } catch {
    return [];
  }
}

export async function upsertPersistedJob(job: GenerationJob) {
  const jobs = await loadJobs();
  const withoutCurrent = jobs.filter((item) => item.localId !== job.localId);
  await saveJobs([...withoutCurrent, job]);
}

Schema validation prevents an older or corrupted payload from becoming trusted TypeScript state. Serialize writes in the repository layer so concurrent jobs cannot overwrite each other. Version the key or add migrations when the persisted shape changes.

Submit with an Idempotency Key

Create the local id before the network call and reuse it for retries.

import * as Crypto from "expo-crypto";

export async function startGeneration(prompt: string, existingLocalId?: string) {
  const localId = existingLocalId ?? Crypto.randomUUID();
  const initial: GenerationJob = { status: "submitting", localId };
  await upsertPersistedJob(initial);

  const response = await authenticatedFetch("/generations", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": localId,
    },
    body: JSON.stringify({ prompt }),
  });

  if (!response.ok) throw new Error("Generation could not be started");
  const serverJob = generationJobSchema.parse(await response.json());
  await upsertPersistedJob(serverJob);
  return serverJob;
}

Pass the failed job's localId back to startGeneration for a retry. The server must echo that localId in its validated response and enforce uniqueness for (userId, idempotencyKey).

Reconcile Instead of Restarting

When the app returns to the foreground:

  1. Load persisted jobs.
  2. Fetch each remoteId still marked running.
  3. Replace local state with the validated server state.
  4. Resume polling only for active jobs.
  5. Stop polling when the app backgrounds or the job reaches a terminal state.
export async function reconcileJob(job: GenerationJob): Promise<GenerationJob> {
  if (job.status !== "running" && job.status !== "submitting") return job;

  const path = job.status === "running"
    ? `/generations/${job.remoteId}`
    : `/generations/by-idempotency-key/${encodeURIComponent(job.localId)}`;

  try {
    const response = await authenticatedFetch(path);
    if (!response.ok) return job;

    const parsed = generationJobSchema.safeParse(await response.json());
    if (!parsed.success) return job;

    await upsertPersistedJob(parsed.data);
    return parsed.data;
  } catch {
    return job;
  }
}

Polling should back off and pause offline. Push notifications can invite a refresh, but the notification payload should not be treated as the final job record.

Keep State Updates Pure

Reducers decide the next state; persistence and network effects run outside them.

type JobAction =
  | { type: "upsert"; job: GenerationJob }
  | { type: "remove"; localId: string };

export function jobsReducer(state: GenerationJob[], action: JobAction) {
  if (action.type === "remove") {
    return state.filter((job) => job.localId !== action.localId);
  }

  const next = state.filter((job) => job.localId !== action.job.localId);
  return [...next, action.job];
}

After dispatch, persist the new store through a store subscription, effect, or repository layer. Do not write storage from inside a reducer.

Retry, Cancel, and Failure UX

  • Retry reads and idempotent job creation with bounded backoff.
  • Do not create a second job when a status request times out.
  • Treat cancellation as a server operation, then reconcile its result.
  • Keep completed partial assets if they are useful and the product promises them.
  • Use one clear failure message plus Retry when retryable.
  • Let users leave the screen; the job continues independently.

Sources

Persistence APIs are checked against Expo’s SQLite documentation, including the SQLite-backed key-value store. The job schema, reconciliation flow, and pure reducer are original patterns inspired by staged media-generation applications.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "durable-generation-recipes" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems