Decide what can wait
Offline support starts by classifying actions. Some can be queued safely, some can be completed locally, and some must wait for authoritative server state.
That classification should be visible in the interface. A saved draft, a queued command, and a confirmed operation are different states and should not share one success message.
Conflict is a product decision
When two users change the same record, the system needs more than a merge algorithm. It needs to know which fields are authoritative, which edits can coexist, and when a person must resolve the conflict.
Offline capability is successful when it preserves momentum without pretending that synchronization is free.
Separate local intent from server truth
An offline client can record that a user requested an action, but it cannot claim that the server accepted it. I model local records with an operation ID, a queue state, and the server version they were based on. That gives the UI enough information to say “queued,” “synced,” or “needs review.”
stateDiagram-v2
[*] --> LocalDraft
LocalDraft --> Queued: user saves offline
Queued --> Syncing: connection returns
Syncing --> Synced: server accepts
Syncing --> Conflict: server version changed
Syncing --> Queued: temporary failure
Conflict --> Synced: user resolves
Conflict strategy
Last-write-wins is simple but can silently erase work. Field-level merging is safer for independent fields, while business-critical conflicts should stop and ask for a decision. The server must still re-check authorization and invariants when the queued operation arrives.
const operation = {
id: crypto.randomUUID(),
baseVersion: record.version,
type: "update-status",
payload: { status: "ready" },
state: "queued" as const,
};
await localQueue.append(operation);
Offline support is a consistency contract. The most important design work is deciding what the user is allowed to believe at each step.
