---
id: mobile-offline-first
title: Offline-first — Local-first / Sync / Conflict
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [mobile, offline, sync, vibe-coding]
tech_stack: { language: "TS / Swift / Kotlin", applicable_to: ["iOS", "Android", "React Native"] }
applied_in: []
aliases: [offline-first, local-first, sync, optimistic UI, queue, retry]
---

# Offline-first

> Network = unreliable. **Local DB 가 truth → background sync → optimistic UI**. 사용자가 즉시 반응 + sync 가 invisible. Notion / Linear / Figma 의 UX.

## 📖 핵심 개념
- Local DB: 모든 read/write.
- Sync queue: pending changes.
- Optimistic UI: 즉시 반영.
- Conflict resolution.

## 💻 코드 패턴

### Architecture
```
[UI]
  ↕ (read/write)
[Local DB]  ←(sync)→  [Server]
  ↕
[Sync Queue]
```

### Local DB 선택
```
SQLite (RN, native): better-sqlite3 / op-sqlite
WatermelonDB (RN): reactive, scalable
Realm: cross-platform
RxDB: client-side
PouchDB: CouchDB sync
SwiftData / Core Data (iOS)
Room (Android)
```

### WatermelonDB (RN, 가장 인기)
```ts
import { Database, Model } from '@nozbe/watermelondb';
import { schemaMigrations, addColumns } from '@nozbe/watermelondb/Schema/migrations';

const schema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'tasks',
      columns: [
        { name: 'title', type: 'string' },
        { name: 'completed', type: 'boolean' },
        { name: 'created_at', type: 'number' },
      ],
    }),
  ],
});

class Task extends Model {
  static table = 'tasks';
  
  @field('title') title!: string;
  @field('completed') completed!: boolean;
  @date('created_at') createdAt!: Date;
}

const database = new Database({
  adapter: new SQLiteAdapter({ schema }),
  modelClasses: [Task],
});
```

### Reactive query
```tsx
import { withObservables } from '@nozbe/watermelondb/react';

const TaskList = withObservables(['database'], ({ database }) => ({
  tasks: database.collections.get('tasks').query().observe(),
}))(({ tasks }) => (
  <FlatList data={tasks} renderItem={({ item }) => <Task task={item} />} />
));
```

→ DB 변경 시 자동 re-render.

### Write (optimistic)
```ts
async function addTask(title: string) {
  // 즉시 local DB
  await database.write(async () => {
    await database.collections.get('tasks').create((t) => {
      t.title = title;
      t.completed = false;
      t.createdAt = new Date();
    });
  });
  
  // Background sync (다음 trigger 시)
  syncQueue.enqueue('tasks/create', { title });
}
```

→ UI 즉시 반응 + server async sync.

### Sync queue
```ts
class SyncQueue {
  private queue: PendingOp[] = [];
  
  enqueue(op: PendingOp) {
    this.queue.push({ ...op, id: uuid(), timestamp: Date.now() });
    this.persistQueue();
    this.tryRun();
  }
  
  async tryRun() {
    if (!isOnline()) return;
    
    while (this.queue.length > 0) {
      const op = this.queue[0];
      try {
        await this.executeOp(op);
        this.queue.shift();
        this.persistQueue();
      } catch (e) {
        if (isRetryable(e)) break;  // 재시도 — 나중
        else this.markFailed(op);
        break;
      }
    }
  }
  
  private persistQueue() {
    // localStorage / DB
  }
}
```

### Network state
```ts
import NetInfo from '@react-native-community/netinfo';

NetInfo.addEventListener((state) => {
  if (state.isConnected) {
    syncQueue.tryRun();
  }
});
```

### Sync trigger
```
1. App resume (foreground)
2. Network reconnect
3. User pull-to-refresh
4. Background fetch (15 min)
5. After mutation
6. Push notification
```

### Pull (server → client)
```ts
async function pull() {
  const lastSync = await db.getLastSync();
  const response = await api.sync({ since: lastSync });
  
  await database.write(async () => {
    // Apply changes
    for (const item of response.items) {
      await upsertTask(item);
    }
    for (const id of response.deleted) {
      await deleteTask(id);
    }
  });
  
  await db.setLastSync(response.cursor);
}
```

### Push (client → server)
```ts
async function push() {
  const pending = syncQueue.all();
  if (pending.length === 0) return;
  
  const response = await api.sync({
    operations: pending,
  });
  
  // Server 가 결과 반환
  for (const result of response.results) {
    if (result.ok) {
      syncQueue.remove(result.opId);
    } else if (result.conflict) {
      await handleConflict(result);
    }
  }
}
```

### Conflict resolution
```ts
// 1. Last-write-wins (간단)
async function resolve(local: Item, server: Item) {
  return server.updatedAt > local.updatedAt ? server : local;
}

// 2. Field-level merge
async function merge(local: Item, server: Item) {
  return {
    ...server,
    customField: local.customField,  // 일부 keep
    updatedAt: Math.max(local.updatedAt, server.updatedAt),
  };
}

// 3. CRDT (자동 merge)
// Yjs / Automerge

// 4. 사용자 결정 (최후)
showConflictDialog(local, server, (chosen) => apply(chosen));
```

### CRDT 통합 (Yjs)
```ts
import * as Y from 'yjs';

const doc = new Y.Doc();
const tasks = doc.getArray('tasks');

tasks.push([{ id: '1', title: 'Buy milk', completed: false }]);

// Sync (any 2 docs merge — same result)
const update = Y.encodeStateAsUpdate(doc);
// Send to server / peer

const otherDoc = new Y.Doc();
Y.applyUpdate(otherDoc, update);
```

→ 자동 conflict-free merge.

### Tombstone (delete)
```ts
// 삭제도 sync 필요 — server 에 알림
{
  id: '...',
  deleted: true,
  deletedAt: Date.now(),
}

// Server 가 propagate.
// 일정 시간 후 hard delete (GC).
```

### Optimistic UI feedback
```tsx
function Task({ task }: { task: Task }) {
  const isPending = task.syncStatus === 'pending';
  
  return (
    <View style={{ opacity: isPending ? 0.5 : 1 }}>
      <Text>{task.title}</Text>
      {isPending && <ActivityIndicator size="small" />}
    </View>
  );
}
```

→ 사용자에 sync state 표시.

### Failed sync
```tsx
if (task.syncStatus === 'failed') {
  return (
    <View>
      <Text>{task.title}</Text>
      <Button title="Retry" onPress={() => retry(task.id)} />
    </View>
  );
}
```

### Backend (sync endpoint)
```ts
// POST /sync
{
  cursor: '...',  // last sync
  operations: [
    { type: 'create', table: 'tasks', data: {...} },
    { type: 'update', table: 'tasks', id: '...', changes: {...} },
    { type: 'delete', table: 'tasks', id: '...' },
  ],
}

// Response
{
  results: [
    { opId: '...', ok: true, applied: {...} },
    { opId: '...', conflict: true, server: {...}, client: {...} },
  ],
  changes: [...newServerSide],
  cursor: 'new-cursor',
}
```

### Server-side
```sql
-- Sync 위 schema
ALTER TABLE tasks ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN sync_version BIGINT DEFAULT 0;

CREATE INDEX tasks_user_updated ON tasks(user_id, updated_at) WHERE deleted_at IS NULL;
```

```ts
// Pull
async function pullChanges(userId: string, since: Date) {
  return db.tasks.findMany({
    where: { userId, updatedAt: { gt: since } },
  });
}
```

### Replicache (open-source library)
```ts
import { Replicache } from 'replicache';

const rep = new Replicache({
  name: 'my-app',
  pushURL: '/api/replicache/push',
  pullURL: '/api/replicache/pull',
  mutators: {
    addTask: async (tx, args) => {
      await tx.set(`task/${uuid()}`, args);
    },
  },
});

// UI
const tasks = useSubscribe(rep, async (tx) => {
  const all = await tx.scan({ prefix: 'task/' }).entries().toArray();
  return all.map(([_, t]) => t);
});

// Mutation
await rep.mutate.addTask({ title: 'Buy milk' });
```

→ Sync engine + conflict resolution + reactivity built-in.

### Tinybase (alternative)
```ts
import { createStore } from 'tinybase';
const store = createStore();

store.setRow('tasks', 'task1', { title: 'Buy milk', completed: false });

// Sync
import { createCustomPersister } from 'tinybase/persisters';
const persister = createCustomPersister(store, ...);
```

### Linear / Figma 패턴
```
Linear: Replicache (custom).
Figma: 자체 OT (operational transform).
Notion: 자체 sync engine.
Obsidian: file-based — sync = files.

→ 큰 회사 가 자체 build.
```

### Local-first vs Online-first
```
Online-first (legacy):
- Server 가 truth
- Offline = read-only (cached)
- Reconnect = simple refresh

Local-first (modern):
- Local 가 truth
- Offline write OK
- Sync 가 background
- 더 좋은 UX
```

### Use cases
```
✅ Notes (Notion)
✅ Tasks (Linear)
✅ Email (offline read)
✅ Maps (cached)
✅ Photos (cached + delete sync)

❌ Banking (transaction = strong consistency)
❌ Booking (resource race)
❌ Real-time game (server authoritative)
```

### CouchDB / PouchDB (sync OG)
```ts
import PouchDB from 'pouchdb';

const local = new PouchDB('local-db');
const remote = 'https://couchdb.example.com/db';

local.sync(remote, { live: true, retry: true })
  .on('change', (info) => console.log(info))
  .on('error', (err) => console.error(err));
```

→ Built-in sync. 옛 but 안정.

### Mobile-specific
```
React Native:
- WatermelonDB / Realm / op-sqlite
- React Query + offline persister

Native iOS:
- SwiftData + CloudKit (자동 sync)
- Core Data + CloudKit

Native Android:
- Room + 자체 sync
- Firebase Firestore (offline 자동)

Cross:
- Firebase Firestore (cross-platform offline)
```

### Test (offline)
```ts
// React Native
import NetInfo from '@react-native-community/netinfo';
jest.mock('@react-native-community/netinfo', () => ({
  fetch: jest.fn(() => Promise.resolve({ isConnected: false })),
  addEventListener: jest.fn(),
}));

// Test offline mutation queued
test('addTask queues when offline', async () => {
  await addTask('Buy milk');
  expect(syncQueue.size).toBe(1);
});
```

## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| Notes / tasks app | Local-first (WatermelonDB / Replicache) |
| Photo / video | Local cache + upload queue |
| Real-time collab | CRDT (Yjs / Automerge) |
| Strong consistency | Online-first |
| Cross-platform | Firestore / Replicache |
| Native iOS | SwiftData + CloudKit |

## ❌ 안티패턴
- **Sync 가정 + offline test 없음**: production fail.
- **Conflict resolution 없음**: data loss.
- **Optimistic UI + rollback 없음**: 잘못된 state.
- **Sync 무 throttle**: server 부담.
- **Tombstone 없음**: 삭제 안 sync.
- **모든 데이터 sync**: bandwidth. delta only.
- **사용자 sync state 모름**: 신뢰 X.

## 🤖 LLM 활용 힌트
- Local DB + sync queue + optimistic UI.
- CRDT (Yjs) 가 conflict 자동.
- Replicache / Linear-style = modern.
- Sync state UI 명시.

## 🔗 관련 문서
- [[Mobile_Background_Sync]]
- [[CS_CRDT_Patterns]]
- [[CS_Eventual_Consistency]]
