---
title: 'Synchronization Strategies'
source: 'https://academia.sh/en/courses/cross-platform-production/synchronization-strategies'
course: 'Cross-Platform Production Practice'
language: en
updated: '2026-10-21T05:02:50+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains how a change queue postpones an offline write'
    - 'Explains why idempotent submission depends on a client-generated identifier'
    - 'Compares the results of last writer wins and field-level merging'
    - 'Measures that offline note editing comes out deferred, and disabled when storage is full'
---

# Synchronization Strategies

Synchronization strategies queue a change made offline, send it to the server when the network returns, and resolve conflicts. The lesson measures that offline note editing stays deferred and shuts down when storage is full.

The previous lesson covered only reading while there is no network. Local data was shown, but the changes the user makes were never discussed. In a real app the user writes while offline too: edits a note, completes a task, fills in a form. That write must not be lost, and it must reach the server when the network returns. This lesson splits that journey into three parts: putting the change in a queue, not applying the same change twice while sending the queue, and resolving the conflict when both sides have changed the same record.

The course question stays the same: how well does this feature work under this condition? The nine records in the opening lesson came out as five **full**, one **degraded**, two **deferred**, and one **disabled**. This lesson does not change that count; it measures only its own slice.

## The Change Queue

A write made offline cannot go straight to the server. The app writes it to the local store, into a separate **change queue**. Each entry in the queue carries one change: which field of which record moved to which value, and the identifier of the change. The screen updates the local data immediately; the user sees their own change. The server does not know yet.

The queue must be durable. A queue kept in memory disappears when the app closes, and the work the user did offline is gone. That is why the queue is a table in the local database. The Android snippet below shows adding an entry to the queue. The snippet is not measured.

```kotlin
// Platform code: Android change queue
class ChangeQueue(private val db: SQLiteDatabase) {
    fun enqueue(change: Change) {
        val row = ContentValues().apply {
            put("change_id", change.id)
            put("note_id", change.noteId)
            put("field", change.field)
            put("value", change.value)
        }
        db.insertOrThrow("pending_changes", null, row)
    }
}
```

`change.id` is generated on the client and stays fixed until the change is sent. Why this identifier matters will be clear shortly. On iOS the same table and the same identifier rule are built in Swift; the queue carries the same contract on both platforms.

## Measuring Notes

Measuring needs the course core. The core is familiar from the first lesson and is given again in the same form in every lesson; this lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

The lesson's helper file builds three features. `EditNote` is note editing and needs `storage` and `network`; when the network is missing the edit is queued, so the fallback for `network` is `deferred`. `ReadNote` reads a note and needs only `storage`. `ShareNote` shares a note and needs only `network`; it has no fallback. `stateOf(link)` builds a condition from a connection state.

```javascript
// notes.mjs — builds the note features and the connection states
import { feature, condition } from "./operability.mjs";

export const editNote = feature("EditNote", ["storage", "network"], { network: "deferred" });
export const readNote = feature("ReadNote", ["storage"]);
export const shareNote = feature("ShareNote", ["network"]);

export function stateOf(link) {
  const has = [];
  if (link.network) has.push("network");
  if (link.storage !== false) has.push("storage");
  return condition(link.name, has);
}
```

The model of the queue lives in a second helper file. `submit` looks at a record's operability and reports what happens to the change: if it is full, the change is sent; if it is deferred, it enters the queue; if it is disabled, it is rejected. The queue is an array, and its length is the unit of work.

```javascript
// sync.mjs — builds the change queue and the submission
export function submit(record, queue, change) {
  if (record.level === "deferred") {
    queue.push(change);
    return "queued";
  }
  return record.level === "full" ? "sent" : "rejected";
}

export function newServer(mode) {
  return { mode, applied: new Map(), log: [] };
}

export function flush(queue, server) {
  for (const change of queue) {
    if (server.mode === "keyed") server.applied.set(change.id, change.value);
    else server.log.push(change);
  }
  return queue.length;
}

export function stored(server) {
  return server.mode === "keyed" ? server.applied.size : server.log.length;
}
```

The first measurement makes three edits offline and one edit while connected.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { editNote, readNote, stateOf } from "./notes.mjs";
import { submit } from "./sync.mjs";

const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
];
const records = assess([editNote, readNote], links.map(stateOf));
printLevels(records);
const edits = records.filter((r) => r.feature === "EditNote");
const queue = [];
console.log(`c0: ${submit(edits[0], queue, { id: "c0", value: "v-c0" })}`);
for (const id of ["c1", "c2", "c3"]) {
  console.log(`${id}: ${submit(edits[1], queue, { id, value: `v-${id}` })}`);
}
console.log(`queue: ${queue.length} changes`);
```

```text
full: EditNote @ online missing=-
full: ReadNote @ online missing=-
deferred: EditNote @ offline missing=network
full: ReadNote @ offline missing=-
c0: sent
c1: queued
c2: queued
c3: queued
queue: 3 changes
```

While connected, the edit is sent directly. Without a connection, `EditNote` is deferred: three edits enter the queue, and the queue carries three changes. Reading is **full** in both states, because it depends only on the local store.

**OS2.** The model builds the queue as an in-memory array. A real queue lives in the local database, survives the app closing and opening, and has to preserve its order. How many changes the queue carries is the unit of work in the model; how much space each change takes, how long it waits, and when it will be sent are not in the model. The model builds this operability's rule; the model is not the framework itself.

## Idempotent Submission

When the network returns, the queue is sent to the server. The difficulty in this step is that knowing whether a submission succeeded is hard. The client sends a change, the server applies it, but the response is lost on the way. The client does not know whether the change arrived and sends the same change again. If the server applies the second copy as if it were seeing it for the first time, the change is processed twice.

The solution is to make the change **idempotent**. Every change arrives with an identifier generated on the client. If the server has already applied the identifier, it ignores the second copy. Generating the identifier on the client is essential: if the server generated it, a submission whose response was lost would be repeated with a new identifier, and the server would see two separate changes.

The measurement below sends the same queue to two servers twice. The `unkeyed` server appends every incoming change to a list. The `keyed` server stores a change under its identifier.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { editNote, readNote, stateOf } from "./notes.mjs";
import { submit, newServer, flush, stored } from "./sync.mjs";

const links = [
  { name: "offline", network: false },
  { name: "online", network: true },
];
const records = assess([editNote, readNote], links.map(stateOf));
const edits = records.filter((r) => r.feature === "EditNote");
printLevels(edits);
const queue = [];
for (const id of ["c1", "c2", "c3"]) submit(edits[0], queue, { id, value: `v-${id}` });
for (const mode of ["unkeyed", "keyed"]) {
  const server = newServer(mode);
  const sent = flush(queue, server) + flush(queue, server);
  console.log(`${mode}: ${sent} sends, ${stored(server)} records on server`);
}
```

```text
deferred: EditNote @ offline missing=network
full: EditNote @ online missing=-
unkeyed: 6 sends, 6 records on server
keyed: 6 sends, 3 records on server
```

Six sends were made to both servers: three changes, twice. The `unkeyed` server holds six records; every change was applied twice. The `keyed` server holds three records; the second send finds the same identifiers and does not change the result. Idempotence makes it safe to repeat a submission, and this is the basis of retrying in the next lessons.

## Conflict Resolution

While the user is offline, the record on the server may have changed too; for example, the same note was edited from another device. When the network returns, two versions are in hand, and the server has to decide which one to accept. This decision is called **conflict resolution**, and there are two common strategies.

The **last writer wins** strategy compares the whole record. The version with the larger timestamp wins and the other's change is dropped. The strategy is simple, but it loses one of two edits that changed different fields, entirely. **Field-level merging** compares fields one by one. If only one side changed a field, that change is kept; if both sides changed the same field, the one with the larger timestamp wins. The measurement below changes a note's title on the server and its body on the client, and puts the two strategies side by side.

```javascript
// merge.mjs — merges two conflicting versions with two strategies
export function lastWriterWins(local, remote) {
  return local.stamp > remote.stamp ? local.fields : remote.fields;
}

export function mergeFields(base, local, remote) {
  const merged = {};
  for (const key of Object.keys(base).sort()) {
    const localChanged = local.fields[key] !== base[key];
    const remoteChanged = remote.fields[key] !== base[key];
    if (localChanged && remoteChanged) {
      merged[key] = local.stamp > remote.stamp ? local.fields[key] : remote.fields[key];
    } else {
      merged[key] = localChanged ? local.fields[key] : remote.fields[key];
    }
  }
  return merged;
}

export function joinFields(fields) {
  return Object.keys(fields).sort().map((key) => `${key}=${fields[key]}`).join(" ");
}
```

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { editNote, readNote, stateOf } from "./notes.mjs";
import { lastWriterWins, mergeFields, joinFields } from "./merge.mjs";

const links = [
  { name: "offline", network: false },
  { name: "online", network: true },
];
const records = assess([editNote, readNote], links.map(stateOf));
printLevels(records.filter((r) => r.feature === "EditNote"));
const base = { title: "List", body: "Buy milk" };
const local = { stamp: 5, fields: { title: "List", body: "Buy milk and tea" } };
const remote = { stamp: 7, fields: { title: "Shopping", body: "Buy milk" } };
console.log(`last writer wins: ${joinFields(lastWriterWins(local, remote))}`);
console.log(`field level: ${joinFields(mergeFields(base, local, remote))}`);
```

```text
deferred: EditNote @ offline missing=network
full: EditNote @ online missing=-
last writer wins: body=Buy milk title=Shopping
field level: body=Buy milk and tea title=Shopping
```

With last writer wins, the server's version wins and the client's body edit is lost. With field-level merging, both changes are kept. The operability records are the same under both strategies: `EditNote @ offline` is deferred and `EditNote @ online` is full. How the conflict is resolved does not change the record, because the rule sees the capability and the fallback, not the content of the two versions. Choosing a strategy is a data-loss decision, and it comes from what the product is willing to lose, not from the operability table.

## The Bounding Measurement

This lesson's focus record is `EditNote @ offline`. The bounding measurement runs three features under three states. The third state is `storage-full`: the network exists, storage does not.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { editNote, readNote, shareNote, stateOf } from "./notes.mjs";

const features = [editNote, readNote, shareNote];
const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
  { name: "storage-full", network: true, storage: false },
];
const records = assess(features, links.map(stateOf));
printLevels(records.filter((r) => r.feature === "EditNote"));
countLevels(records);

const withServer = (mode) =>
  assess(features, [{ name: "offline", network: false, server: mode }].map(stateOf));
const same = JSON.stringify(withServer("keyed")) === JSON.stringify(withServer("unkeyed"));
console.log(`duplication is not recorded: ${same ? "yes" : "no"}`);
```

```text
full: EditNote @ online missing=-
deferred: EditNote @ offline missing=network
disabled: EditNote @ storage-full missing=storage
full: 5
degraded: 0
deferred: 1
disabled: 3
affected: 4
duplication is not recorded: yes
```

The first three lines carry the lesson's operability. The same feature, the same code: **full** with a network, **deferred** without one. The edit is not lost; it waits in the queue and is sent when the network returns. Under `storage-full` it is **disabled**, because the queue itself is also written to the local store. If there is no store to hold the deferral, there is no deferral. Of the nine records, five are **full**, one is **deferred**, and three are **disabled**; four are affected.

The last line shows how this differs from the question of M30/K05. That lesson asked whether an operation's result changes if it is repeated or reordered. Idempotent and non-idempotent submission produce the same operability record; but this is not an observation, it is the definition of the model: the `stateOf` function that builds the condition never reads the `server` field, so the two calls see the same input from the start. Operability does not classify the durability of submission; it classifies that the write was postponed. A non-idempotent server applies records twice, and that is a data error, but `EditNote @ offline` stays **deferred**.

## Summary

- This lesson's operability: **deferred**. `EditNote @ offline` came out deferred because `network` is missing and a `deferred` fallback is defined; under the same condition `ReadNote` stayed **full** and `ShareNote` was **disabled**.
- An offline write enters a durable change queue; three edits became three queue entries.
- An identifier generated on the client makes submission idempotent: in six sends, the keyed server held three records and the unkeyed server held six.
- Last writer wins loses an edit in a conflicting record, field-level merging keeps both; the strategy does not change the operability record.
- Under `storage-full` the queue cannot be written either and `EditNote` shuts down; four of nine records are affected.

## Next Step

In this lesson, sending the queue began when the network returned; who starts it was not asked. While the app is closed or in the background, the operating system may not run this job. The next lesson, `Background Processing Limits`, covers the background task mechanisms of the two platforms, the limits the operating system imposes, and low power mode. It then measures how well media backup works when background capability is restricted.
