---
name: offline-sync
description: >
  Handle offline sync failures. Use PendingQueue for failed operations, configure
  RetryPolicy with exponential backoff, call getPendingCount and syncPending to
  manage retry. Understand which errors are retryable (429, 5xx) vs non-retryable (405).
  Use onSyncFailed and onSyncSuccess callbacks.
type: core
library: '@omnistreamai/data-sync'
library_version: '0.4.1'
sources:
  - 'omni-stream-ai/data-sync:packages/core/src/types.ts'
  - 'omni-stream-ai/data-sync:packages/core/src/pending-queue.ts'
  - 'omni-stream-ai/data-sync:packages/core/src/store.ts'
---

# Offline Sync

Handle network failures and sync retries when operations fail.

## Setup

```typescript
import { createSyncManager } from '@omnistreamai/data-core';
import { DEFAULT_RETRY_POLICY } from '@omnistreamai/data-core';

const syncManager = createSyncManager({
  localAdapter: new IndexedDBAdapter({ dbName: 'app', version: 1 }),
  remoteAdapter: new WebDAVAdapter(),
  notThrowLocalError: true, // Required for sync callbacks to work
});

const store = syncManager.createStore<Todo>('todos', {
  idKey: 'id',
  syncConfig: {
    defaultPolicy: {
      maxRetries: 3,
      baseDelayMs: 5000,
      backoff: 'exponential',
      jitter: true,
      retryableStatuses: [408, 429, 500, 502, 503, 504],
    },
    autoSync: false, // Manual sync only
  },
  syncCallbacks: {
    onSyncFailed: ({ item, error, attempt, isMaxRetriesExceeded, nextRetryDelayMs }) => {
      console.error(`Sync failed for ${item.recordId}: ${error.message}`, {
        attempt,
        isMaxRetriesExceeded,
        nextRetryDelayMs,
      });
    },
    onSyncSuccess: (item) => {
      console.log(`Synced ${item.recordId} (${item.operation})`);
    },
  },
});
```

## Core Patterns

### Check pending sync count

```typescript
const pendingCount = await store.getPendingCount();
console.log(`${pendingCount} operations waiting to sync`);
```

### Manually sync pending items

```typescript
// Call after coming back online
const result = await store.syncPending();
console.log(`Synced: ${result.success}, Failed: ${result.failed}`);

// syncPending returns items that are past their retry delay
// Retries follow exponential backoff: attempt 1 = immediate, attempt 2+ = baseDelay * 2^(n-1)
```

### Understand retryable vs non-retryable errors

```typescript
// These errors ARE queued for retry:
// - 408 Request Timeout
// - 429 Too Many Requests
// - 500, 502, 503, 504 Server errors
// - ECONNREFUSED, ENOTFOUND, ETIMEDOUT, 'timeout', 'network', 'fetch failed'

// This error is NEVER queued:
// - 405 Method Not Allowed — operation not supported by server, retrying won't help
```

### Configure retry policy

```typescript
const store = syncManager.createStore<Todo>('todos', {
  idKey: 'id',
  syncConfig: {
    defaultPolicy: {
      maxRetries: 5,
      baseDelayMs: 10000,     // 10 seconds base
      maxDelayMs: 300000,     // 5 minutes max
      backoff: 'exponential',  // 10s → 20s → 40s → 80s → 160s
      jitter: true,           // Random 0-50% added to prevent thundering herd
      retryableStatuses: [429, 500, 502, 503, 504],
      retryableErrors: ['ECONNREFUSED', 'ETIMEDOUT', 'network'],
    },
  },
});
```

### Retry flow behavior

```typescript
// When add/update/delete fails with retryable error:
// 1. Item added to pending queue with attempts=1
// 2. onSyncFailed called with isMaxRetriesExceeded=false
// 3. When syncPending() is called and delay has passed:
//    - If attempts < maxRetries: retry operation, attempts++
//    - If attempts >= maxRetries: mark failed, onSyncFailed with isMaxRetriesExceeded=true
```

## Common Mistakes

### HIGH Assume all errors are retried

Wrong:

```typescript
// Assuming every failed operation will be retried automatically
await store.add({ id: '1', title: 'Test' });
// If this fails with 405, it might be lost — 405 is NOT queued
```

Correct:

```typescript
// 405 Method Not Allowed is NOT queued — understand which errors retry
// 405 means the server doesn't support this operation — no point retrying

// Only these are queued:
// - 429 Too Many Requests — wait and retry
// - 500/502/503/504 — server issue, retry may succeed

// Always monitor with onSyncFailed callback:
syncCallbacks: {
  onSyncFailed: ({ item, isMaxRetriesExceeded }) => {
    if (isMaxRetriesExceeded) {
      // Item permanently failed — notify user or persist to dead-letter queue
      console.error(`Permanent sync failure for ${item.recordId}: ${item.lastError}`);
    }
  },
},
```

Source: packages/core/src/pending-queue.ts:44-58

---

### HIGH Not using notThrowLocalError with syncCallbacks

Wrong:

```typescript
const syncManager = createSyncManager({
  localAdapter: new IndexedDBAdapter(),
  remoteAdapter: new WebDAVAdapter(),
  // notThrowLocalError defaults to false
  // When a local write succeeds but remote fails, error propagates
  // syncCallbacks.onSyncFailed will NOT fire
});
```

Correct:

```typescript
const syncManager = createSyncManager({
  localAdapter: new IndexedDBAdapter(),
  remoteAdapter: new WebDAVAdapter(),
  notThrowLocalError: true, // ✅ Required for sync callbacks to work
});
```

`notThrowLocalError` must be `true` for `onSyncFailed` and `onSyncSuccess` callbacks to fire. When `false`, local write errors propagate and sync callbacks are skipped.

Source: packages/core/src/store.ts:76-83

See also: data-sync/initialize-sync-system
