RORK LABJP
PRODUCT — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageNATIVE — Rork Max unlocks AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core MLCLASSIC — The original Rork uses React Native (Expo), turning plain-English prompts into shippable iOS/Android appsFUNDING — Rork raised $2.8M from a16z (plus $15M more), reaching 743,000 monthly visits at 85% growthPRICING — Rork is free to start, with paid plans from $25/month; Rork Max is $200/monthCHOICE — Pick cross-platform Rork or Rork Max for deep Apple-native capabilities, depending on your goalPRODUCT — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageNATIVE — Rork Max unlocks AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core MLCLASSIC — The original Rork uses React Native (Expo), turning plain-English prompts into shippable iOS/Android appsFUNDING — Rork raised $2.8M from a16z (plus $15M more), reaching 743,000 monthly visits at 85% growthPRICING — Rork is free to start, with paid plans from $25/month; Rork Max is $200/monthCHOICE — Pick cross-platform Rork or Rork Max for deep Apple-native capabilities, depending on your goal
Articles/Dev Tools
Dev Tools/2026-06-23Advanced

The Post You Wrote Offline Shows Up Twice — Designing a Send Outbox That Survives Retries

Persisting a queue and replaying it isn't enough — a lost response turns into a duplicate write. Here's a send outbox with idempotency keys, temp-to-server ID remapping, and poison-message quarantine, in working TypeScript.

offline-first3react-native11expo10architecture9idempotency3

Premium Article

A reader was writing a long entry in one of my journaling apps while standing on a subway platform. They hit save in the dead zone, walked out past the gates, and a few seconds after the signal came back the same entry sat in their timeline twice. That review stuck with me.

I run a few apps with write paths as an indie developer, and this "extra one that appears the moment you reconnect" is a trap you're more likely to hit the further you've taken your offline support. Queue the write, replay it on reconnect — most articles stop there, and so did I for a while. But that alone does not remove the duplicate.

This article isn't about optimistic updates themselves. It's about what comes after them: the safety of the retry. Concretely, stopping duplicate writes with an idempotency key, remapping the temporary IDs you minted offline to real server IDs, and isolating a mutation that will never succeed. This is the load-bearing part of a write path you intend to operate for years.

The moment "just retry" falls apart

Most send queues are written as "if it failed, send it again." The hard question is what counts as a failure.

A network can drop after the request reaches the server, after the server finishes processing, but before the response makes it back to the client. From the client's side that's a timeout — a failure. From the server's side the post already exists. Retry naively and the server creates a second one.

This isn't a bug so much as a property of writing across a network. Because responses can be lost, a client retry is inherently at-least-once. If you want to avoid duplicates, the receiving side has to refuse to count the same write twice. That's the idempotency key.

Decide the key once, at creation time

The crux is to mint the key exactly once, the instant you enqueue, and never change it on retry. Generate a fresh key on every send and, as far as the server is concerned, each attempt is a different write — useless.

Start with a type for a single outbox entry.

// outbox/types.ts
export type OutboxStatus = 'pending' | 'inflight' | 'failed' | 'dead';
 
export interface OutboxItem {
  localId: string;        // primary key on device (UUID)
  idempotencyKey: string; // key sent to the server (minted once)
  endpoint: string;       // e.g. 'POST /posts'
  payload: unknown;       // request body
  status: OutboxStatus;
  attempts: number;
  nextAttemptAt: number;  // exponential backoff time (epoch ms)
  dependsOn?: string;     // localId of another item (see below)
  createdAt: number;
}

When enqueuing, fix idempotencyKey here and only here.

// outbox/enqueue.ts
import { randomUUID } from 'expo-crypto';
 
export function buildOutboxItem(
  endpoint: string,
  payload: unknown,
  dependsOn?: string,
): OutboxItem {
  const now = Date.now();
  return {
    localId: randomUUID(),
    idempotencyKey: randomUUID(), // decided here, never changed again
    endpoint,
    payload,
    status: 'pending',
    attempts: 0,
    nextAttemptAt: now,
    dependsOn,
    createdAt: now,
  };
}

Send it in a header. The server's contract is: on the second arrival of the same key, don't create anything new — return the first result.

// outbox/send.ts
async function send(item: OutboxItem): Promise<Response> {
  return fetch(`https://api.example.com${item.endpoint.split(' ')[1]}`, {
    method: item.endpoint.split(' ')[0],
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': item.idempotencyKey, // identical on every retry
    },
    body: JSON.stringify(item.payload),
  });
}

If you own the server, the receiver only needs to write one row keyed by the idempotency key. INSERT ... ON CONFLICT DO NOTHING rejects the second arrival and returns the existing result. For an external API like Stripe, most accept an Idempotency-Key header officially, so you align with that.

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
Why a retry creates a duplicate write, and the idempotency key that stops it
Remapping a temporary local ID to the server ID so dependent offline writes resolve
Quarantining a mutation that can never succeed, so it doesn't block the whole queue
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-05-23
Rork-Specific 'expo start --offline forbidden': Four Causes in Rork's Template Config
When expo start --offline returns 'forbidden' specifically on a Rork-generated project, the cause is usually Rork template config: tsconfigPaths, an un-generated expo-router cache, native prebuild, or a lockfile mismatch. Four Rork-specific fixes; the generic Expo proxy and dependency-validation guide is covered separately.
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. Crashlytics shows exception code 0x8badf00d. Here is the watchdog termination story and the exact steps an indie developer running React Native apps for 50M downloads uses to make it stop.
Dev Tools2026-03-29
AI Agent × Mobile App Design — Separation of Concerns Architecture for Smarter Apps
When integrating AI agents into mobile apps, not everything should be handled by AI. Learn the Separation of Concerns design pattern for Rork apps to optimize quality, cost, and performance.
📚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 →