Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
description: "How to implement custom conflict resolution strategies in PowerSync to handle concurrent updates from multiple clients."
---

The default behavior is essentially "last write wins" because the server processes operations in order received, with later updates overwriting earlier ones. For many apps, this works fine. But some scenarios demand more business logic to resolve conflicts.
The default behavior is "**last write wins per field**". Updates to different fields on the same record don't conflict with each other. The server processes operations in the order received, so if two users modify the *same* field, the last update to reach the server wins.

For most apps, this works fine. But some scenarios demand more complex conflict resolution strategies.

## When You Might Need Custom Conflict Resolution

Expand Down Expand Up @@ -37,7 +39,7 @@
3. **Clients download updates** - Based on their sync rules
4. **Local SQLite updates** - Changes merge into the client's database

**Conflicts arise when**: Multiple clients modify the same row before syncing, or when a client's changes conflict with server-side rules.
**Conflicts arise when**: Multiple clients modify the same row (or fields) before syncing, or when a client's changes conflict with server-side rules.

---

Expand Down Expand Up @@ -101,10 +103,10 @@
"op": "PATCH",
"table": "todos",
"id": "44f21466-d031-11f0-94bd-62f5a66ac26c",
"opData": {

Check warning on line 106 in usage/lifecycle-maintenance/handling-update-conflicts/custom-conflict-resolution.mdx

View check run for this annotation

Mintlify / Mintlify Validation (powersync) - vale-spellcheck

usage/lifecycle-maintenance/handling-update-conflicts/custom-conflict-resolution.mdx#L106

Did you really mean 'opData'?
"completed": 1,
"completed_at": "2025-12-03T10:20:04.658Z",

Check warning on line 108 in usage/lifecycle-maintenance/handling-update-conflicts/custom-conflict-resolution.mdx

View check run for this annotation

Mintlify / Mintlify Validation (powersync) - vale-spellcheck

usage/lifecycle-maintenance/handling-update-conflicts/custom-conflict-resolution.mdx#L108

Did you really mean 'completed_at'?
"completed_by": "c7b8cc68-41dd-4643-b559-66664ab6c7c5"

Check warning on line 109 in usage/lifecycle-maintenance/handling-update-conflicts/custom-conflict-resolution.mdx

View check run for this annotation

Mintlify / Mintlify Validation (powersync) - vale-spellcheck

usage/lifecycle-maintenance/handling-update-conflicts/custom-conflict-resolution.mdx#L109

Did you really mean 'completed_by'?
}
}
]
Expand Down Expand Up @@ -479,7 +481,7 @@

## Strategy 5: Server-Side Conflict Recording

Sometimes you can’t automatically fix a conflict. Both versions might be valid, and you need a human to choose. In those cases you record the conflict instead of picking a winner. You save both versions in a write_conflicts table and sync that back to the client so the user can decide.

Check warning on line 484 in usage/lifecycle-maintenance/handling-update-conflicts/custom-conflict-resolution.mdx

View check run for this annotation

Mintlify / Mintlify Validation (powersync) - vale-spellcheck

usage/lifecycle-maintenance/handling-update-conflicts/custom-conflict-resolution.mdx#L484

Did you really mean 'write_conflicts'?

The flow is simple: detect the conflict, store the client and server versions, surface it in the UI, and let the user choose or merge. After they resolve it, you mark the conflict as handled.

Expand Down Expand Up @@ -672,7 +674,9 @@

Your backend then processes these changes asynchronously. Each one gets a status like `pending`, `applied`, or `failed`. If a change fails validation, you mark it as `failed` and surface the error in the UI. The user can see exactly which fields succeeded and which didn’t, and retry the failed ones without resubmitting everything.

This gives you excellent visibility. You get a clear history of every change, who made it, and when it happened. The cost is extra writes, since every field update creates an additional log entry. But for compliance-heavy systems or any app that needs detailed auditing, the tradeoff is worth it.
This gives you excellent visibility. You get a clear history of every change, who made it, and when it happened. The cost is extra writes, since every field update creates an additional log entry. But for compliance-heavy systems or any app that needs detailed auditing, the tradeoff could be worth it.

The implementation below shows the full version with complete status tracking. If you don't need all that complexity, see the simpler variations at the end of this section.

### Step 1: Create Change Log Table

Expand Down Expand Up @@ -837,6 +841,86 @@
}
```

### Other Variations
The implementation above syncs the `field_changes` table bidirectionally, giving you full visibility into change status on the client. But there are two simpler approaches that reduce overhead when you don't need complete status tracking:

#### Insert-Only (Fire and Forget)

For scenarios where you just need to record changes without tracking their status. For example, logging analytics events or recording simple increment/decrement operations.
How it works:

- Mark the table as `insertOnly: true` in your client schema
- Don't include the `field_changes` table in your sync rules
- Changes are uploaded to the server but never downloaded back to clients

**Client schema:**

```typescript
const fieldChanges = new Table(
{
table_name: column.text,
row_id: column.text,
field_name: column.text,
new_value: column.text,
user_id: column.text
},
{
insertOnly: true // Only allows INSERT operations
}
);
```

**When to use:** Analytics logging, audit trails that don't need client visibility, simple increment/decrement where conflicts are rare.

**Tradeoff:** No status visibility on the client. You can't show pending/failed states or implement retry logic.

#### Pending-Only (Temporary Tracking)

For scenarios where you want to show sync status temporarily but don't need a permanent history on the client.
How it works:

- Use a normal table on the client (not `insertOnly`)
- Don't include the `field_changes` table in your sync rules
- Pending changes stay on the client until they're uploaded and the server processes them
- Once the server processes a change and PowerSync syncs the next checkpoint, the change automatically disappears from the client

**Client schema:**

```typescript
const pendingChanges = new Table({
table_name: column.text,
row_id: column.text,
field_name: column.text,
new_value: column.text,
status: column.text,
user_id: column.text
});
```

**Show pending indicator:**

```typescript
function SyncIndicator({ taskId }: { taskId: string }) {
const { data: pending } = useQuery(
`SELECT COUNT(*) as count FROM pending_changes
WHERE row_id = ? AND status = 'pending'`,
[taskId]
);

if (!pending?.[0]?.count) return null;

return (
<div className="sync-badge">
⏳ {pending[0].count} change{pending[0].count > 1 ? 's' : ''} syncing...
</div>
);
}
```

**When to use:** Showing "syncing..." indicators, temporary status tracking without long-term storage overhead, cases where you want automatic cleanup after sync.

**Tradeoff:** Can't show detailed server-side error messages (unless the server writes to a separate errors table that *is* in sync rules). No long-term history on the client.

## Strategy 7: Cumulative Operations (Inventory)

For scenarios like inventory management, simply replacing values causes data loss. When two clerks simultaneously sell the same item while offline, both sales must be honored. The solution is to treat certain fields as **deltas** rather than absolute values, you subtract incoming quantities from the current stock rather than replacing the count.
Expand Down