---
title: Sync Configuration
description: "Configure Kora.js sync: server URL, WebSocket and HTTP transports, auth, sync scopes, batching, and reconnection behavior."
---

# Sync Configuration

Kora sync is opt-in. Your app works fully offline without sync. When enabled, sync handles connection management, delta exchange, conflict resolution, and reconnection automatically.

## Enable Sync

Add `sync.url`, then connect after `app.ready`:

```typescript
import { createApp } from 'korajs'
import schema from './schema'

const app = createApp({
  schema,
  sync: {
    url: 'wss://my-server.com/kora',
  },
})

await app.ready
await app.sync?.connect()
```

Kora then handles the handshake, delta exchange, retries, and conflict resolution.

## Sync Options Reference

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `url` | `string` | -- | WebSocket URL of your sync server (required) |
| `auth` | `() => Promise<{ token: string }>` | -- | Async function that returns an auth token |
| `authClient` | `AuthSyncBinding` | -- | Pre-built binding from `createKoraAuthSync()` in `@korajs/auth` |
| `scope` | `Record<string, unknown>` | -- | Flat scope values combined with schema scope declarations |
| `batchSize` | `number` | `100` | Max operations per sync batch |
| `schemaVersion` | `number` | `1` | Schema version sent in handshake |
| `autoConnect` | `boolean` | `false` | Connect automatically after `app.ready` |
| `autoReconnect` | `boolean` | `true` | Automatically reconnect on disconnect |
| `reconnectInterval` | `number` | `1000` | Initial reconnect delay in ms |
| `maxReconnectInterval` | `number` | `30000` | Maximum reconnect delay in ms |

### Full Configuration Example

```typescript
const app = createApp({
  schema,
  sync: {
    url: 'wss://my-server.com/kora',
    auth: async () => ({ token: await getAuthToken() }),
    batchSize: 100,
    schemaVersion: 1,
    autoReconnect: true,
    reconnectInterval: 1000,
    maxReconnectInterval: 30000,
  },
})
```

## Authentication

### Recommended: `createKoraAuthSync`

When using `@korajs/auth`, pass an auth sync binding instead of wiring tokens manually:

```typescript
import { createApp } from 'korajs'
import { createKoraAuth, createKoraAuthSync } from '@korajs/auth'

const authClient = createKoraAuth({ serverUrl: 'http://localhost:3001' })

const app = createApp({
  schema,
  sync: {
    url: 'wss://my-server.com/kora',
    authClient: createKoraAuthSync({ authClient, schema }),
  },
})
```

The binding handles token refresh, JWT → scope map resolution, device-bound sync node ids (`dev` claim), and reconnect-on-auth-change.

### Manual `auth` function

Provide an async `auth` function that returns a token. The token is sent during the WebSocket handshake:

```typescript
sync: {
  url: 'wss://my-server.com/kora',
  auth: async () => ({
    token: await refreshAccessToken(),
  }),
}
```

The `auth` function is called on every connection attempt, including reconnections. This allows you to refresh expired tokens automatically.

### Server-side auth bridge

On the server side, bridge auth to the sync server with `toSyncAuthProvider()`:

```typescript
import { BuiltInAuthRoutes, TokenManager, InMemoryUserStore } from '@korajs/auth/server'
import { createKoraServer } from '@korajs/server'

const authRoutes = new BuiltInAuthRoutes({ userStore, tokenManager })

const server = createKoraServer({
  store: serverStore,
  port: 3001,
  auth: authRoutes.toSyncAuthProvider(),
})
```

See the [Authentication Guide](/guide/authentication) for the full setup.

### Client-side scope from JWT

When `createKoraAuthSync({ schema })` is used, scope filters are built automatically from JWT claims:

```typescript
// Schema declares scope fields on collections
const schema = defineSchema({
  collections: {
    todos: {
      fields: { title: t.string(), userId: t.string() },
      scope: ['userId'],
    },
  },
})

// Access token: { sub: 'user-1', dev: 'device-abc', ... }
// Handshake scope map: { todos: { userId: 'user-1' } }
```

The store sync **node id** comes from the token's `dev` claim (device identity), not `sub` (user id). Each physical device gets its own operation log node while sharing user-scoped data.

For static scope values not present in the token, use `sync.scope`:

```typescript
sync: {
  url: 'wss://my-server.com/kora',
  scope: { orgId: 'org-123' },
}
```

### Declarative sync rules in schema

Prefer schema-level sync rules for partial sync. Only listed collections sync:

```typescript
const schema = defineSchema({
  version: 1,
  collections: {
    todos: {
      fields: {
        title: t.string(),
        userId: t.string(),
      },
    },
  },
  sync: {
    todos: { where: { userId: true } },
  },
})
```

Combined with `createKoraAuthSync({ authClient, schema })`, scope maps are built automatically from JWT claims (`sub` → `userId`). See the [Core API `buildScopeMap()` reference](/api/core#buildscopemap) for details.

### Anonymous Sync (Mixed Auth)

For apps where some users are authenticated and others are anonymous (e.g., public form respondents), use `MixedAuthProvider`:

```typescript
import { MixedAuthProvider } from '@korajs/server'

const auth = new MixedAuthProvider({
  primary: authRoutes.toSyncAuthProvider(),
  anonymousScopes: {
    responses: {},  // anonymous users can only sync this collection
  },
})

const server = createKoraServer({ store, port: 3001, auth })
```

On the client, return an empty token for unauthenticated users (or use `createKoraAuthSync`, which does this automatically):

```typescript
sync: {
  url: 'wss://my-server.com/kora',
  authClient: createKoraAuthSync({ authClient, schema }),
}
```

Anonymous connections get full offline-first capabilities (data saves locally and syncs when connected) but are restricted to the collections listed in `anonymousScopes`.

See the [Common Patterns guide](/guide/common-patterns#anonymous-public-data-access) for a complete walkthrough.

## Connection Lifecycle

### Initial Sync

When a client connects for the first time (or after being offline):

1. App opens local storage and loads the local version vector.
2. You call `app.sync?.connect()`.
3. Client authenticates (if configured).
4. **Handshake**: Client sends its version vector to the server.
5. **Server response**: Server sends its version vector back.
6. **Delta exchange**: Both sides compute which operations the other is missing and send them. Operations are sent in causal order (dependencies before dependents).
7. **Streaming**: After the initial exchange, the connection enters real-time bidirectional streaming mode. New operations are sent as they happen.

Large initial syncs are paginated into batches of `batchSize` (default 100). Each batch includes `batchIndex`, `totalBatches`, `isFinal`, and a `cursor` so the client can resume if the connection drops mid-sync. The cursor is persisted in `_kora_meta` and sent on the next handshake as `deltaCursor`.

### Query-specific sync subsets

When you subscribe to a reactive query, Kora automatically registers a sync subset for equality filters in the query's `where` clause:

```typescript
// Only incomplete todos for this user are synced (within auth/schema scope)
app.todos.where({ completed: false }).subscribe((todos) => {
  renderList(todos)
})
```

Subsets are sent in the handshake as `syncQueries`. The server filters delta exchange and relay to match. Operator-based filters (`$gt`, `$in`, etc.) are not registered as sync subsets. Use schema sync rules or auth scopes for those.

Changing active subscriptions triggers a debounced reconnect so the server receives updated subsets.

### How Delta Sync Works

Version vectors track the highest sequence number seen from each node. During sync:

```
Client version vector: { nodeA: 42, nodeB: 17 }
Server version vector: { nodeA: 42, nodeB: 20, nodeC: 5 }

Client needs: nodeB ops 18-20, all nodeC ops (1-5)
Server needs: nothing (client has nothing server doesn't)
```

Only the missing operations are transferred. This makes incremental sync very efficient -- typically under 200ms for a single new operation.

### Delivery guarantees (server to client)

You do not configure this and you do not need to think about it, but it is worth knowing what the framework promises. Once an operation reaches the server, it will reach every client whose sync scope includes it, and it will never be silently skipped. This holds across dropped messages, reconnects, client restarts, and scoped sync.

The server drives each client from a durable delivery watermark: a single position in the server's operation stream up to which the client has applied every in-scope operation with no gap. The watermark advances only when a batch is fully applied, so a message that is dropped or that fails to apply leaves the watermark in place, and the next exchange re-sends everything above it. The watermark is persisted on the client, so a client that restarts resumes exactly where it left off rather than re-syncing from zero. The watermark is tracked per view (a stable signature of the active scope plus query subscriptions), so a client that switches views and later returns resumes each view from its own position instead of re-scanning it.

Two consequences are useful in practice:

- A transient apply failure (for example a temporary error while writing to local storage) is retried automatically on the next exchange rather than skipping that operation.
- A client that reconnects after a long streaming session receives only what it actually missed, not the whole session, because its watermark tracked the server frontier while it was connected.

This behavior is automatic and requires no client or server configuration. It is also backward compatible: an older client and a newer server (or the reverse) fall back to version-vector delta sync and still converge.

Two things to expect in practice. First, changing the sync scope (for example when a user joins a project) or subscribing to a new reactive query back-fills that view once: the widened scope can include historical data the client did not have before, so the client scans it from that view's watermark, which starts at zero the first time the view is seen. Each view is back-filled at most once, and returning to a view you have already synced resumes from where you left off rather than re-scanning it. Any back-fill is deduplicated, so operations already applied under another view are re-received but not re-applied. The number of remembered views is bounded (the least-recently-used are dropped, never the default or current view), so an app that churns through many transient views does not accumulate state without limit; a dropped view simply back-fills once when you return to it. Second, if an operation genuinely cannot be applied on a client (most often a sign that the sync scope includes a child record but excludes its parent), sync will visibly stall on that operation and surface an apply-failure event rather than skipping it. That is intentional: it makes a scope misconfiguration a loud, diagnosable condition instead of silent data loss. The fix in that case is to make the scope consistent (include the parent).

### Offline Handling

1. Network drops (or device goes offline).
2. Kora emits `sync:disconnected`.
3. Local writes continue normally and are added to a persistent outbound queue.
4. Reconnect retries run with exponential backoff (see below).
5. On reconnect, the full handshake runs and the queue is flushed.

The outbound queue is persisted to the local database. Operations survive page refreshes, browser restarts, and device reboots.

## Reconnection Behavior

When the connection drops, Kora retries with exponential backoff:

| Attempt | Delay |
|---------|-------|
| 1 | Immediate |
| 2 | `reconnectInterval` (default: 1s) |
| 3 | 2x previous (2s) |
| 4 | 2x previous (4s) |
| 5+ | Capped at `maxReconnectInterval` (default: 30s) |

The backoff resets after a successful connection. Set `autoReconnect: false` to disable automatic reconnection:

```typescript
sync: {
  url: 'wss://my-server.com/kora',
  autoReconnect: false,
}
```

## Transports

### WebSocket (Default)

The `createApp` runtime uses WebSocket transport. This provides:

- Low-latency bidirectional streaming
- Real-time operation delivery
- Efficient for frequent small updates

### HTTP Long-Polling

The `@korajs/sync` package also supports HTTP long-polling transport, useful when WebSocket connections are blocked by firewalls or proxies. To use it, work with the sync engine directly:

```typescript
import { SyncEngine, HttpLongPollingTransport } from '@korajs/sync'

const transport = new HttpLongPollingTransport({
  url: 'https://my-server.com/kora/sync',
  pollInterval: 5000,
})

const sync = new SyncEngine({ transport, mergeEngine, operationLog })
sync.start()
```

### Wire Format

Kora uses format negotiation. The client and server negotiate between:

- **JSON** -- human-readable, good for debugging
- **Protocol Buffers** -- compact binary encoding, 60-80% smaller payloads

The negotiation happens automatically during the handshake. In development, JSON is preferred for debuggability. In production, Protobuf is preferred for bandwidth efficiency.

## Sync Status in UI

Use `useSyncStatus()` from `@korajs/react` to show user-facing state:

```tsx
import { useSyncStatus } from '@korajs/react'

function SyncIndicator() {
  const status = useSyncStatus()

  switch (status.status) {
    case 'synced':
      return <span>All changes saved</span>
    case 'syncing':
      return <span>Syncing...</span>
    case 'offline':
      return <span>Working offline</span>
    case 'clock-error':
      return <span>Clock error - check your device time</span>
    case 'error':
      return <span>Sync error - retrying</span>
    case 'schema-mismatch':
      return <span>Update required</span>
    case 'connected':
      return <span>Connected</span>
  }
}
```

### Status Properties

| Property | Type | Description |
|----------|------|-------------|
| `status` | `'connected' \| 'syncing' \| 'synced' \| 'offline' \| 'clock-error' \| 'error' \| 'schema-mismatch'` | Current sync status |
| `pendingOperations` | `number` | Operations queued but not yet sent |
| `lastSyncedAt` | `number \| null` | Timestamp of last successful sync |

## Server-Side Scoping

Restrict which data each user can sync by returning scopes from your auth provider:

```typescript
const auth = new TokenAuthProvider({
  validate: async (token) => {
    const user = await verifyToken(token)
    return {
      userId: user.id,
      scopes: {
        // User only syncs their own todos
        todos: { userId: user.id },
        // User syncs all projects in their org
        projects: { orgId: user.orgId },
      },
    }
  },
})
```

The server filters operations based on scopes before sending them to the client. This ensures users only receive data they are authorized to see.

### With Organizations (RBAC)

If you use `@korajs/auth` organizations, the `OrgScopeResolver` generates scope filters automatically based on org membership:

```typescript
import { OrgScopeResolver, RbacEngine } from '@korajs/auth/server'

const rbac = new RbacEngine(orgStore)
const scopeResolver = new OrgScopeResolver(orgStore, rbac)

// In your auth provider:
const scopes = await scopeResolver.resolve(userId, orgId, ['todos', 'projects'])
```

See the [Authentication Guide](/guide/authentication) for the full RBAC setup.

## End-to-End Encryption

Kora supports encrypting operation data before it leaves the device. When enabled, the server only sees encrypted payloads -- it cannot read your users' data.

```typescript
const app = createApp({
  schema,
  sync: {
    url: 'wss://my-server.com/kora',
    encryption: {
      enabled: true,
      key: userPassphrase,  // or a key provider function
    },
  },
})
```

- Uses AES-256-GCM with PBKDF2 key derivation (600,000 iterations)
- Operation metadata (timestamps, IDs) remains unencrypted for sync protocol
- Operation data (field values) is encrypted end-to-end
- Supports key rotation with versioned keys

See the [Sync Encryption guide](/guide/sync-encryption) for setup details.

## Sync Diagnostics

The sync engine exposes real-time diagnostics for monitoring connection health:

```typescript
const diagnostics = app.sync?.exportDiagnostics()
// {
//   state: 'streaming',
//   status: { status: 'synced', pendingOperations: 0, ... },
//   pendingOperations: 0,
//   lastSyncedAt: 1715097600000,
//   lastSuccessfulPush: 1715097600000,
//   lastSuccessfulPull: 1715097600000,
//   conflicts: 0,
//   reconnecting: false,
// }
```

For lower-level observability, listen to diagnostics events:

```typescript
app.events.on('sync:diagnostics', (event) => {
  console.log(event.diagnostics.quality)
  console.log(event.diagnostics.rttP95Ms)
  console.log(event.diagnostics.effectiveBandwidth)
})
```

Diagnostics events are also visible in [DevTools](/guide/devtools).

## Manual Disconnect and Reconnect

```typescript
// Disconnect (e.g., when user logs out)
await app.sync?.disconnect()

// Reconnect (e.g., when user logs back in)
await app.sync?.connect()
```

Operations created while disconnected remain in the local queue and are sent on the next successful connection.

## Sync Events

Listen to sync events programmatically for logging or custom behavior:

```typescript
app.events.on('sync:connected', (event) => {
  console.log('Connected to sync server')
})

app.events.on('sync:disconnected', (event) => {
  console.log('Disconnected:', event.reason)
})

app.events.on('sync:sent', (event) => {
  console.log('Sent', event.operations.length, 'operations')
})

app.events.on('sync:received', (event) => {
  console.log('Received', event.operations.length, 'operations')
})

app.events.on('sync:auth-failed', () => {
  // Token rejected by the server (expired, revoked, or database reset).
  // Sign out the user so they can re-authenticate.
  console.warn('Auth token rejected - signing out')
  authClient.signOut()
})
```

These events are also visible in the [DevTools](/guide/devtools) sync timeline.

## Troubleshooting

### Sync not connecting

- Verify the `sync.url` uses `wss://` (not `ws://`) for HTTPS sites.
- Check that the sync server is running and the port is accessible.
- If using auth, verify the token is valid and not expired.
- Check browser console for WebSocket connection errors.

### Operations not syncing

- Check `useSyncStatus().pendingOperations` -- if greater than 0, operations are queued.
- Verify the connection state is `'connected'` or `'synced'`.
- Check the server logs for authentication or scope rejections.

### Slow initial sync

- Reduce the amount of data with server-side scoping.
- Check the total operation count -- large datasets take longer on first sync.
- Consider operation compaction on the server to reduce historical operations.

### Duplicate data appearing

- This should not happen. Kora operations are content-addressed (same content = same ID), and duplicates are automatically deduplicated.
- If you see duplicates, check that your schema's `id` field is not being generated client-side with non-deterministic IDs.

## Related guides

- [Server-side Validation](/guide/server-side-validation): adjudicate untrusted client operations before they become authoritative (public forms, multi-tenant boundaries), and surface rejections back to the submitter.
- [Production Server](/guide/production-server): set `maxOperationBytes` and `maxOpsPerMinute` once at server config, plus background-job data access and blob GC.
# Query views and settlement

Reactive query subsets remain the compatibility default. Applications with route-heavy UIs
can choose a stable manifest or keep all queries local:

```ts
sync: { querySubsets: { mode: 'static' } }
await app.sync?.setQuerySubsets([
  { collection: 'courses', where: { orgId } },
])
```

Use `mode: 'disabled'` to send no client query subsets. Subsets are canonicalized: object key
order and duplicates do not matter, and a broader equality predicate removes contained narrower
predicates. Static replacement is atomic.

`useSyncStatus()` and `app.sync.getStatus()` expose `phase`, active-view completeness, upload
in-flight state, the delivery watermark/frontier, initial-sync progress, and any active blocking
apply failure. For workflows, prefer the race-safe settlement primitive:

```ts
const result = await app.sync?.waitForSettled({
  upload: true,
  download: 'active-view',
  timeoutMs: 30_000,
  signal,
})
```

The structured result distinguishes settled, offline, suspended, blocked, timed out, and aborted
states; waiting never discards local operations or changes sync state.

# Multi-partition authorization

Server-authoritative scopes accept bounded `$in` predicates such as
`{ courses: { offeringId: { $in: ['a', 'b'] } } }`. Values are deduplicated and sorted before
the scope signature is computed. An empty `$in` is an explicit deny. The default maximum is 100
values per predicate; excessive or malformed predicates fail the handshake rather than widening
access. The same matcher is used for downlink filtering, relay, backfill, and uplink validation.
