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 is 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 is 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 does not race

A subtlety that only shows up with more than one tab open. Without this, two tabs can both believe they are 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 are 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 does not.
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.

Powered by Flux