Core Concepts

Conflict resolution

What happens when an offline edit and a server-side change land on the same record. This page covers the two-pass handshake that detects it, the priority chain that decides what to do about it, and the one thing conflict resolution is never allowed to touch.

Prerequisites
  • Familiarity with core-concepts/offline-queue — conflicts only ever apply to queued mutations
  • For wiring a resolution panel to this mechanism, see offline/conflict-ui
1

Why a conflict is even possible

Exactly one shape of event produces a conflict: an offline edit and a server-side change landing on the same record during the same disconnected window.
typescript
1// A conflict is only possible in one specific shape of event: a
2// mutation was queued while offline, targeting a record that ALSO
3// changed on the server during that same window — someone else
4// edited it, a background job updated it, a second device the same
5// user was using got there first.
6//
7// user opens dashboard, edits a field, goes offline
8// -> mutation queued to IDB (Section 4.3)
9//
10// ...meanwhile, server-side, the same record changes...
11//
12// user reconnects
13// -> queue wants to replay the edit
14// -> but the record it's editing isn't what it was when queued
15//
16// Fire-and-forget writes (waitlist signups, contact forms, log
17// entries) can never hit this — there's no existing server record
18// to have moved out from under them. Conflicts are exclusively a
19// dashboard-edit / settings-update / profile-change problem: writes
20// that overwrite something that already exists.
2

Two strategies, one fork in the road

Set once, per store, at registration. The cost of guessing wrong is asymmetric — worth defaulting to handshake when unsure.
typescript
1// ReplayStrategy is the fork in the road, set per queueConfig:
2
3type ReplayStrategy = 'fire_and_forget' | 'handshake'
4
5// fire_and_forget (default) — just send it. No server-state check.
6// Correct when there's nothing to conflict with:
7queueConfig: {
8 storeName: 'waitlist-signups',
9 replayEndpoint: '/api/waitlist',
10 // replayStrategy omitted -> fire_and_forget
11}
12
13// handshake — check first, then send. Correct for anything that
14// OVERWRITES an existing record:
15queueConfig: {
16 storeName: 'dashboard-edits',
17 replayEndpoint: '/api/dashboard/batch-update',
18 replayStrategy: 'handshake',
19 revalidateFn: async (entries, manifest) => {
20 const res = await fetch('/api/dashboard/revalidate', {
21 method: 'POST',
22 body: JSON.stringify({ manifest }),
23 })
24 return res.json() // { conflictedIds, serverStates }
25 },
26}
27
28// Picking the wrong one has an asymmetric cost: fire_and_forget on
29// an overwritable record risks silently clobbering someone else's
30// change. handshake on an append-only record just wastes one
31// redundant network round trip. When unsure, handshake is the safer
32// default.
3

The two-pass mechanics

The entire handshake, end to end. The key property: this costs exactly 2 network requests regardless of queue size — 2 for one entry, 2 for a thousand.
typescript
1// The two-pass model — this is the entire mechanism, and it costs
2// EXACTLY 2 requests regardless of how many entries are queued.
3
4// PASS 1 — bulk probe. One request, all entries in this endpoint
5// group, at once:
6const manifest = entries.map(e => ({
7 id: e.dedupeKey ?? e.id,
8 timestamp: e.timestamp + clockSkewMs, // clock-skew corrected —
9 // see Step 4
10}))
11
12const { conflictedIds, serverStates } = await revalidateFn(entries, manifest)
13
14// The server's job here is simple: for each id in the manifest,
15// "is your current row newer than this timestamp?" — return the
16// ids where the answer is yes, plus their current state.
17
18// In-memory filter — ZERO additional network cost. Every entry not
19// in conflictedIds is immediately safe:
20const safe = entries.filter(e => !conflictedIds.includes(e.dedupeKey ?? e.id))
21const conflicted = entries.filter(e => conflictedIds.includes(e.dedupeKey ?? e.id))
22
23// Conflicted entries run through the priority chain — Step 3.
24// Whatever comes out safe (proceed / apply_merged decisions, plus
25// everything that was never conflicted) all goes in...
26
27// PASS 2 — single execution. One more request, everything safe,
28// batched:
29await fetch(replayEndpoint, {
30 method: 'POST',
31 headers: { 'X-Flux-Batch': '1', 'X-Flux-Count': String(safe.length) },
32 body: JSON.stringify(safe.map(e => e.payload)),
33})
34
35// 1,000 queued entries across one endpoint group -> still 2 requests
36// total. Not 2 per entry.
4

The conflict priority chain

Every conflicted entry runs through this in order and stops at the first stage that handles it — worth knowing exactly where a given conflict will land before wiring anything up.
typescript
1// Every conflicted entry runs through this chain, in order, and
2// stops at the first stage that handles it:
3
4// STAGE 1 — per-channel onConflict, if set on that store's
5// queueConfig. Runs FIRST, before anything else sees the conflict.
6queueConfig: {
7 onConflict: (entry, serverState) => {
8 if (onlyTouchesUnrelatedFields(entry, serverState)) {
9 return { action: 'proceed' } // no real conflict, send it anyway
10 }
11 return { action: 'drop' } // pass it downstream — see stage 2
12 },
13}
14// 'proceed' and 'apply_merged' decisions here join the safe Pass 2
15// batch IMMEDIATELY — they never reach stage 2 or 3 at all.
16// Only a 'drop' result continues down the chain.
17
18// STAGE 2 — the engine's onConflictUnified, if configured. The
19// entry routes to the universal register (Section 4.14) — this is
20// what feeds a conflict resolution panel. CRITICALLY: the entry is
21// NOT removed from IDB here. It stays frozen in the queue until
22// resolveUnifiedConflict() is explicitly called — no automatic
23// timeout, no automatic fallback while it's sitting there.
24const flux = createFlux({
25 adapter: createSupabaseAdapter(supabaseClient),
26 onConflictUnified: (conflicts) => {
27 // drives ONE resolution UI for every store's conflicts at once
28 // — see offline/conflict-ui
29 },
30})
31
32// STAGE 3 — conflictStrategy, only reached if NEITHER stage 1 nor
33// stage 2 is configured. Applied automatically, no human involved:
34queueConfig: {
35 conflictStrategy: 'reject', // 'reject' | 'replace' | 'merge'
36}
A frozen entry has no automatic timeout

Once an entry reaches the universal register (Stage 2), it stays frozen in IDB until resolveUnifiedConflict() is explicitly called — there's no fallback that fires on its own after some delay.

5

conflictStrategy — the automatic fallback

Only reached if neither of the first two stages is configured. 'merge' in particular is a deliberate dead end, not a shortcut — worth reading closely.
typescript
1type ConflictStrategy = 'reject' | 'replace' | 'merge'
2
3// reject (default) — drop the local entry, server wins. The safest
4// automatic choice: never overwrites someone else's change without
5// a human or explicit logic deciding to.
6
7// replace — drop the local entry, same outcome as reject in
8// practice. Named separately because the INTENT differs: "the
9// server already has the newer version" as a stated assumption,
10// versus reject's "when in doubt, don't overwrite."
11
12// merge — cannot auto-merge without a merge function. Warns to the
13// console and drops. This is a deliberate dead end, not a
14// half-implemented feature: Flux will never guess how to combine
15// two versions of a record on your behalf. If you want 'merge'
16// behavior, it has to come from stage 1 or stage 2 of the priority
17// chain above, with real logic behind it — conflictStrategy: 'merge'
18// alone does nothing but warn.
6

Clock skew correction

Every timestamp in the manifest is corrected before it's sent — without this, a device with a drifting clock generates false conflicts out of nothing.
typescript
1// Every timestamp in the manifest (Pass 1) is corrected before it's
2// ever sent — this is what keeps a device with a wrong system clock
3// from generating false-positive conflicts.
4
5// Computed once, during flux.bootstrap() (Section 4.13):
6const serverTime = new Date(response.headers.get('Date')).getTime()
7const latency = (Date.now() - requestStartTime) / 2
8const clockSkewMs = serverTime - (requestStartTime + latency)
9
10// Stored on the engine, applied to every queued entry's timestamp
11// before it becomes part of a manifest:
12const correctedTimestamp = entry.timestamp + clockSkewMs
13
14// Without this, a laptop with its clock set 10 minutes fast would
15// have every queued mutation look like it happened in the future
16// relative to the server — and a server comparing "is my row newer
17// than this timestamp" would misclassify genuinely-safe writes as
18// conflicts, or the reverse, purely from clock drift with nothing
19// having actually changed.
7

The atomic lock — why multi-tab doesn't race

A subtlety that only shows up with more than one tab open. Without this, two tabs can both believe they're clear to write at the same time.
typescript
1// A subtlety that only shows up with multiple tabs open: the
2// revalidation fetch, the timeline comparison, the conflict routing,
3// AND the final Pass 2 execution all run inside the SAME Web Lock
4// (Section 4.4) for a handshake-strategy store.
5
6// Without this, a two-tab race is possible:
7//
8// Tab 1: Pass 1 runs, sees record as safe, starts Pass 2
9// Tab 2: Pass 1 runs BEFORE Tab 1's Pass 2 lands on the server,
10// also sees the record as safe (Tab 1's write hasn't been
11// committed yet), also starts its own Pass 2
12// -> both tabs believe they're clear to overwrite. One of them
13// silently clobbers the other, and neither ever sees a conflict
14// at all — the exact failure mode handshake exists to prevent.
15
16// The atomic lock closes this: Tab 2's entire handshake — probe,
17// compare, route, execute — waits for Tab 1's to fully complete
18// first. By the time Tab 2's Pass 1 runs, Tab 1's write is already
19// reflected on the server, so a genuine conflict is correctly
20// detected instead of two tabs racing past each other.
8

When the check itself fails

A failed revalidation and a missing one are treated very differently — worth knowing which failure mode you're looking at when debugging.
typescript
1// What happens when revalidateFn itself fails, versus when it's
2// simply missing — these are handled differently on purpose:
3
4// revalidateFn THROWS (a real network/server failure during Pass 1):
5// -> every entry stays in the queue, untouched, for retry on the
6// next trigger. Flux does NOT fall back to fire_and_forget here
7// — that would risk silently overwriting a record that might
8// genuinely have conflicted, just because the check itself
9// failed. A failed check is treated as "unknown," never as
10// "assume safe."
11
12// revalidateFn is MISSING entirely, with replayStrategy: 'handshake'
13// set (a configuration error, not a network failure):
14// -> Flux warns to the console and falls back to fire_and_forget
15// for that group. This case is different because it's a static
16// mistake, not a transient failure — the developer needs a
17// signal to fix their config, and freezing the queue forever
18// over a typo would be worse than the fallback.
9

What this never covers

Worth stating plainly, since this mechanism can look like it solves a problem it deliberately doesn't.
typescript
1// Worth restating explicitly here, since conflict resolution can
2// look like it solves problems it doesn't (Section 4.5's fintech
3// boundary):
4//
5// Financial ledger writes — wire transfers, direct debits — must
6// NEVER go through a queue at all, handshake or otherwise. Handshake
7// replay detects conflicts on RECORDS. It has no concept of
8// idempotency keys, double-spend prevention, or transactional
9// integrity at the ledger level. Use strategy: 'drop' with
10// optimistic.rollbackOnError: false for payment endpoints — send it
11// immediately online, or don't send it, never queue and replay it
12// later.
13//
14// Handshake replay is for state that can be safely compared and
15// overwritten: dashboard edits, settings, KYC field updates,
16// preference changes. Not for anything where "which one wrote last"
17// is the wrong question to even be asking.

For wiring a resolution UI to the universal register described in Step 4, see offline/conflict-ui. For the offline queue mechanics conflicts build on top of, see core-concepts/offline-queue. For the full API signature of resolveUnifiedConflict(), see api/resolve-unified-conflict.