Skip to main content

Conflict Resolution

Starfish uses optimistic concurrency control. When two clients push simultaneously, the server detects the conflict and the client resolves it automatically.

Prerequisites: SyncManager

How Conflicts Arise

Every push includes a baseHash — the hash of the last known server version. If the server's current hash doesn't match, it returns a 409 Conflict.

Device A pulls (hash: "abc123")
Device B pulls (hash: "abc123")
Device A pushes (baseHash: "abc123") → 200 OK, new hash: "def456"
Device B pushes (baseHash: "abc123") → 409 Conflict (server is now "def456")

Default Strategy: deepMerge

When no custom resolver is provided, SyncManager uses deepMerge:

  • Scalars: remote wins
  • Nested objects: recursively merged
  • Arrays: atomic — remote wins entirely (no element-level merge). Use createUnionMerge() as onConflict to get element-wise union on both push-conflict, pull, and revalidation (including the first pull after a cache seed — see Bootstrap window in the SyncManager docs).
const sync = new SyncManager({
client,
pullPath, pushPath,
// default: onConflict = deepMerge (remote wins)
})

Automatic Retry

On conflict, SyncManager automatically:

  1. Pulls the latest remote state
  2. Calls onConflict(localData, remoteData) to produce a merged result
  3. Waits with exponential backoff
  4. Retries the push with the merged data

Backoff formula:

delay = min(100ms * 2^attempt, 2000ms) + random(0–100ms)
AttemptBase delayWith jitter
0100ms100–200ms
1200ms200–300ms
2400ms400–500ms
3800ms800–900ms
41600ms1600–1700ms
5+2000ms2000–2100ms

If all retries fail, SyncManager throws a ConflictError.

Custom Conflict Resolvers

ConflictResolver type

type ConflictResolver = (
local: Record<string, unknown>,
remote: Record<string, unknown>
) => Record<string, unknown>

Remote wins (simple spread)

onConflict: (local, remote) => ({ ...local, ...remote })

Local wins

onConflict: (local, remote) => ({ ...remote, ...local })

ID-based union for arrays

Merge arrays by item id, keeping items from both sides. Local versions win for shared IDs:

onConflict: (local, remote) => {
const merged: Record<string, unknown> = { ...remote }

for (const [key, localVal] of Object.entries(local)) {
const remoteVal = remote[key]

if (Array.isArray(localVal) && Array.isArray(remoteVal)) {
// Build map from remote, overlay local
const byId = new Map<string, unknown>()
for (const item of remoteVal) {
if (item && typeof item === "object" && "id" in item) {
byId.set((item as { id: string }).id, item)
}
}
for (const item of localVal) {
if (item && typeof item === "object" && "id" in item) {
byId.set((item as { id: string }).id, item)
}
}
merged[key] = [...byId.values()]
}
}

return merged
}

Item-level merge with ID + updatedAt

The ID-based union above always picks the local version for shared items. A more precise strategy uses a per-item updatedAt timestamp so the most recent edit wins regardless of which device it came from:

interface SyncItem {
id: string
updatedAt: number // Date.now() at last modification
[key: string]: unknown
}

function mergeArraysByTimestamp(
localArr: SyncItem[],
remoteArr: SyncItem[],
): SyncItem[] {
const byId = new Map<string, SyncItem>()

// Seed with remote items
for (const item of remoteArr) {
byId.set(item.id, item)
}

// Overlay local items — but only if they are newer
for (const item of localArr) {
const existing = byId.get(item.id)
if (!existing || item.updatedAt > existing.updatedAt) {
byId.set(item.id, item)
}
}

return [...byId.values()]
}

Wire it into a full resolver:

onConflict: (local, remote) => {
const merged: Record<string, unknown> = { ...remote }

for (const [key, localVal] of Object.entries(local)) {
const remoteVal = remote[key]
if (Array.isArray(localVal) && Array.isArray(remoteVal)) {
merged[key] = mergeArraysByTimestamp(
localVal as SyncItem[],
remoteVal as SyncItem[],
)
}
}

// For top-level scalars, pick from the document with the latest timestamp
const localTs = (local._updatedAt as number) ?? 0
const remoteTs = (remote._updatedAt as number) ?? 0
if (localTs > remoteTs) {
for (const [key, val] of Object.entries(local)) {
if (!Array.isArray(val)) merged[key] = val
}
}

return merged
}

This requires your domain stores to set updatedAt: Date.now() on every item mutation. See Integration Patterns — Soft Delete for how this interacts with deletions.

Soft-delete-aware merge

When using soft deletes, the merge must respect tombstones. Without this, a union merge "resurrects" items that one device deleted while another still had them:

function mergeWithSoftDelete(
localArr: SyncItem[],
remoteArr: SyncItem[],
): SyncItem[] {
const byId = new Map<string, SyncItem>()

for (const item of remoteArr) byId.set(item.id, item)
for (const item of localArr) {
const existing = byId.get(item.id)
if (!existing || item.updatedAt > existing.updatedAt) {
byId.set(item.id, item)
}
}

return [...byId.values()]
// Items with _deletedAt are kept in the array (tombstones preserved).
// Your UI filters them out; see Integration Patterns for the full pattern.
}

The key insight: deleted items are not removed from the array — they carry a _deletedAt timestamp. The merge compares updatedAt as usual, so the most recent action (edit or delete) wins per item. See Soft Delete for the complete pattern.

Timestamp-based (document-level)

Pick the newer document for top-level scalars:

onConflict: (local, remote) => {
const localTs = (local._updatedAt as number) ?? 0
const remoteTs = (remote._updatedAt as number) ?? 0
return localTs > remoteTs ? local : remote
}

Union for sets

Merge array values as sets (deduplication):

onConflict: (local, remote) => {
const merged: Record<string, unknown> = { ...remote }
for (const [key, localVal] of Object.entries(local)) {
const remoteVal = remote[key]
if (Array.isArray(localVal) && Array.isArray(remoteVal)) {
merged[key] = [...new Set([...localVal, ...remoteVal])]
}
}
return merged
}

Handling Unresolvable Conflicts

import { ConflictError } from "@drakkar.software/starfish-client"

try {
await sync.push(data)
} catch (err) {
if (err instanceof ConflictError) {
// All retries exhausted — surface to user or queue for later
console.error("Sync conflict could not be resolved")
}
}

Tuning Retries

const sync = new SyncManager({
client,
pullPath, pushPath,
maxRetries: 5, // default is 3
onConflict: myResolver,
})

Set maxRetries: 0 to disable automatic retry — the first conflict will throw immediately.

Choosing a Strategy

StrategyBest forLimitation
deepMerge (default)Simple key-value documentsArrays are atomic
Remote/local winsSingle-writer scenariosDiscards one side entirely
ID-based unionMulti-device item creationDoesn't resolve same-item edits
ID + updatedAtPer-item conflict resolutionRequires timestamp discipline
Soft-delete-awareApps with item deletionTombstones grow the document
Union for setsTag lists, labelsNo ordering guarantees

For most apps with array-heavy documents, ID + updatedAt with soft-delete awareness provides the best balance of correctness and simplicity.

Conflict Metadata

Since v1.5.0, wrap any resolver with withConflictMeta() to get information about which fields conflicted and how the conflict was resolved:

import { createUnionMerge, withConflictMeta } from "@drakkar.software/starfish-client"

const merge = withConflictMeta(createUnionMerge())

const result = merge(
{ items: [{ id: 1, name: "A", updatedAt: 100 }], timestamp: 100 },
{ items: [{ id: 1, name: "B", updatedAt: 200 }], timestamp: 200 },
)

console.log(result.meta)
// {
// conflictedFields: ["items", "timestamp"],
// resolvedBy: "merged", // or "local" or "remote"
// timestamp: 1712345678000
// }

This is useful for showing users what changed during a conflict resolution, or for analytics.

Next Steps