Conflict Resolution
When the server returns 409 Conflict or 412 Precondition Failed for an outbox operation, Shiny.Data.Sync consults the endpoint’s DefaultConflictPolicy to decide what to do.
Endpoint policies
Section titled “Endpoint policies”opts.RegisterEndpoint<Project>(url, ep =>{ ep.DefaultConflictPolicy = ConflictPolicy.AskDelegate; // default});| Policy | Behavior |
|---|---|
AskDelegate (default) |
Calls IDataSyncDelegate.OnConflict(op, remotePayload) on each registered delegate until one decides. See More than one delegate. |
ServerWins |
Drops the local op, dispatches the remote payload through OnReceived as an Update. |
ClientWins |
Re-queues the local op as-is. The server should ultimately accept it (e.g. drop the ETag check). |
Delegate-driven resolution
Section titled “Delegate-driven resolution”OnConflict receives the local SyncOperation and the current remote payload as the server returned it. Decide what to apply by returning a ConflictResolution:
public Task<ConflictResolution> OnConflict(SyncOperation op, string remotePayload){ // Discard the local op and accept the remote state. Same as ConflictPolicy.ServerWins. if (alwaysTrustServer) return Task.FromResult(ConflictResolution.AcceptRemote);
// Force the local op through. Same as ConflictPolicy.ClientWins. if (op.Verb == SyncVerb.Delete) return Task.FromResult(ConflictResolution.KeepLocal);
// Merge: parse both sides, build a combined payload, retry with that. var local = JsonSerializer.Deserialize<Project>(op.Payload!, AppJsonContext.Default.Project)!; var remote = JsonSerializer.Deserialize<Project>(remotePayload, AppJsonContext.Default.Project)!;
var merged = local with { Title = remote.Title, // server-wins for title Tags = local.Tags.Union(remote.Tags).ToArray(), // union of tags UpdatedAt = DateTimeOffset.UtcNow };
var mergedJson = JsonSerializer.Serialize(merged, AppJsonContext.Default.Project); return Task.FromResult(ConflictResolution.UseMerged(mergedJson));}ConflictResolution factory methods:
| Factory | Result |
|---|---|
ConflictResolution.AcceptRemote |
Same as ServerWins — the remote payload is dispatched as an Update and the local op is removed. |
ConflictResolution.KeepLocal |
Re-queues the local op as-is. |
ConflictResolution.UseMerged(string mergedPayload) |
Replaces the op’s Payload with mergedPayload and re-queues. |
ConflictResolution.Abstain |
No opinion. The next registered delegate is asked. If every delegate abstains, the server’s copy wins, as with AcceptRemote. |
More than one delegate
Section titled “More than one delegate”You can register more than one IDataSyncDelegate. For a conflict, they are asked in registration order, and the first real answer wins: once a delegate returns AcceptRemote, KeepLocal or UseMerged, that resolution is applied and later delegates are not called.
A delegate that has no opinion returns ConflictResolution.Abstain, and the next delegate is asked. Returning null counts as abstaining too. (Before 6.5.3 a null result threw inside the engine and the op went down the error and retry path.) A delegate that throws is logged and skipped the same way. If every delegate abstains, the server’s copy wins.
// A logging delegate that never decides. It only sees the conflict if it is// registered before the delegate that does decide.public Task<ConflictResolution> OnConflict(SyncOperation op, string remotePayload){ logger.LogInformation("Conflict on {Entity}", op.EntityIdentifier); return Task.FromResult(ConflictResolution.Abstain);}Because the first real answer stops the loop, an abstaining delegate registered after the deciding one is never called. To watch conflicts without depending on registration order, use the Activity events below.
Observing conflicts
Section titled “Observing conflicts”To see conflicts without deciding them (for logging, telemetry or a status bar), subscribe to the Activity stream. Two events cover a conflict:
| Event | When | Carries |
|---|---|---|
OutboxConflict |
The server answered 409 / 412. |
Operation, StatusCode, RemotePayload (the server’s copy sent with the conflict). |
OutboxConflictResolved |
The endpoint’s ConflictPolicy or a delegate has decided, before the op’s next event. |
The same, plus Resolution (the ConflictResolution that was applied). |
sync.Activity += (s, evt) =>{ switch (evt.Type) { case SyncEventType.OutboxConflict: Log.Warn($"{evt.EndpointKey} conflict for {evt.Operation?.EntityIdentifier} (HTTP {evt.StatusCode}). Server has: {evt.RemotePayload}"); break;
case SyncEventType.OutboxConflictResolved: Log.Info($"{evt.Operation?.EntityIdentifier} resolved with {evt.Resolution?.Action}"); break; }};OutboxConflictResolved fires under every policy, so a ServerWins endpoint reports AcceptRemote and a ClientWins endpoint reports KeepLocal. This works however many delegates you register and in whatever order.
The typed UpdateReceived event carries the same information on SyncOperationResult.RemotePayload and SyncOperationResult.Resolution. The update that applies the resolution has both: Completed for AcceptRemote, Pending when the local change is sent again. On the HttpClient engine (Android, Windows, Linux, macOS, Blazor) the operation also fires as SyncOperationState.ConflictPending, with RemotePayload set, so a status bar can flag it.
Choosing a policy
Section titled “Choosing a policy”| Situation | Recommended policy |
|---|---|
| Reference data the user can’t edit on the client | ServerWins — the server is the source of truth. |
| Mobile-first workflow where the user expects their edit to win | ClientWins (or KeepLocal from the delegate). |
| Real conflicts (two editors, both with local changes) | AskDelegate with a merge implementation. |
| You can’t merge but want to log and skip | AskDelegate returning AcceptRemote. |
| A library or diagnostics delegate that should never decide | Return Abstain, or better, listen to the OutboxConflict / OutboxConflictResolved events. |
ETags and precondition headers
Section titled “ETags and precondition headers”Shiny.Data.Sync does not attach If-Match headers automatically. If your server uses optimistic concurrency, attach the ETag either:
- in the per-endpoint
OnBeforeSendhook (read it off the op’s payload or your local store), or - in a global
ISyncInterceptor.BeforePushfor cross-cutting precondition handling.


