Plan offline functionality and later synchronisation
Connect saved work, immutable transfer intentions and matching acknowledgement through one explicit data contract.

Offline support starts with an explicitly allowed task
Offline functionality lets a person do useful work without a current connection. Later synchronisation additionally needs to establish which saved revision is sent, how repetition behaves and what happens when the server has another version. A connectivity indicator answers none of those questions by itself. Plan an explicit contract between device draft, outgoing queue, server update and acknowledged result.
Our original fictional example is a maintenance report edited during travel and transferred later. Two separate file-backed SQLite databases play the device and server roles in the test model. JavaScript running in Node.js demonstrates the data decisions. This is neither an integrated Android application nor a real mobile connection. Its bounded purpose makes persistence and repeat processing reproducible without pretending to prove the whole deployment.
First decide what work is genuinely allowed offline. Preserving a personal report draft differs from allocating a scarce appointment or finally changing a shared inventory. This example permits drafting and later controlled transfer. It does not claim that every business approval can safely be made without current server state. That product decision needs to precede selection of a queue or background-work library.
Separate the saved draft from the transfer intention
The device record holds current text, its revision and the known server version. A separate outbox freezes one transfer intention with its own identifier and exact payload. Later draft edits must not silently mutate that operation. Otherwise the same identifier could represent different content on different attempts, undermining the very relationship that makes repeat processing understandable.
The first illustrative operation is op-one. It contains report revision one and expects server version zero. Calling the queue function again for the same still-open report returns the same operation rather than creating another intention because someone pressed a button twice. This is a business repetition rule, not merely a temporary visual disablement of a control. The stored identity survives beyond the current screen.
The person may continue editing while an operation is outstanding. This simple model defers another automatic operation for that report until the existing one is acknowledged or deliberately resolved. Afterwards, the new revision can receive a new operation with the now-known base version. The restriction keeps the example tractable. A system allowing several simultaneous changes needs a more elaborate explicit ordering and conflict model.
Prove persistence rather than assuming it
A value in working memory is not a durable outbox. The fixture writes draft and operation to real files, closes the device database and opens it again. The values remain available. That observation is more meaningful than a saved icon derived only from a successful in-memory function call. Persistence is a claim about recovery of the work, not simply about the current process still holding an object.
Queueing uses a database transaction, and a unique constraint prevents arbitrary duplicate entries for one report revision. The synchronous helper uses BEGIN IMMEDIATE and completes with commit or rollback. It is not nested and contains no waiting network operation. Other database environments need their own appropriate transaction and concurrency decisions. The example demonstrates one particular mechanism rather than a universal distributed storage recipe.
A real mobile integration also reviews storage failure, process termination and recovery. Isolated SQLite files do not automatically establish a particular Android repository’s behaviour. They establish the intended data decision. An app connects that model to its actual persistence and reports saved work only after the relevant operation really completes. The test boundary remains clear enough to support later integration instead of concealing it.
Distinguish a lost acknowledgement from a lost operation
The important failure occurs after a successful server update. The report is stored, but the device cannot process the acknowledgement and therefore has an unknown outcome. Creating a fresh operation identifier at that point could repeat downstream work. A retry must bring the same unchanged intention so the receiving application can recognise it rather than treating uncertainty as proof that nothing happened.
The server model stores a receipt containing operation identity, a fingerprint of the payload and the resulting version in the same transaction as the report update. A matching repetition receives that known result. Reusing the identity for changed content is rejected. The identifier is therefore not a blanket permission for arbitrary later changes, but a link to one precisely described successful operation.
HTTP method semantics and this application-level repetition rule are different matters. A generic POST is not made idempotent merely by its name. The fixture uses direct calls to demonstrate the rule independently of transport. A deployed endpoint still needs authentication, resource-specific authorisation, response matching and a complete error contract. The isolated model does not claim to implement those surrounding protections.
Compare current server state before updating
An operation expects a particular base version. The server compares that expectation with its current report and declines to overwrite when they differ. The result is a version conflict. In the example, the device retains its new observation while the server retains another edit. Both texts are useful evidence for the next business decision instead of one silently destroying the other.
A last-writer-wins policy may be acceptable for some low-risk information, but it is not a universal resolution strategy. Two maintenance observations can each contain important facts. Their timestamps alone do not establish which may be discarded. Decide whether to merge, select explicitly or refer to a responsible person. That decision follows the data’s meaning rather than the convenience of an update statement.
The helper deliberately does not resolve the conflict. It retains conflict state and blocks automatic replacement with another queue entry. An explicit resolution would need to produce a new permitted operation. That management path is outside this code. Keeping the limitation visible prevents a small transfer demonstration from being mistaken for a complete business conflict-management system.
Read the executable data helper
The code below creates the two small data models, freezes a revision and applies it to the separate server store. It was actually run with Node.js 24.22.1 and its available SQLite module. Version and scope matter: this is not a general mobile or browser background daemon and does not automatically become part of another runtime’s storage architecture. It is an executable way to inspect the contract.
Permission for the target resource must already be established before applyOperation. The helper validates a bounded input contract, then demonstrates fingerprint comparison, version checking and atomic storage. A real HTTP receiver additionally validates incoming data and response schemas. An arbitrary 200 response without a matching operation result must not be accepted as confirmation for some unrelated local revision.
import { DatabaseSync } from 'node:sqlite';
import { createHash } from 'node:crypto';
export function transaction(db, work) {
db.exec('BEGIN IMMEDIATE');
try { const result = work(); db.exec('COMMIT'); return result; }
catch (error) { db.exec('ROLLBACK'); throw error; }
}
export function openClient(path) {
const db = new DatabaseSync(path);
db.exec(`PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS drafts(
id TEXT PRIMARY KEY, revision INTEGER NOT NULL,
base_version INTEGER NOT NULL, synced_revision INTEGER,
body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS outbox(
op_id TEXT PRIMARY KEY, report_id TEXT NOT NULL REFERENCES drafts(id),
revision INTEGER NOT NULL, base_version INTEGER NOT NULL,
body TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
UNIQUE(report_id, revision));`);
return db;
}
export function openServer(path) {
const db = new DatabaseSync(path);
db.exec(`CREATE TABLE IF NOT EXISTS reports(
id TEXT PRIMARY KEY, version INTEGER NOT NULL, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS receipts(
op_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
result_version INTEGER NOT NULL);`);
return db;
}
export function queueCurrent(client, reportId, operationId) {
return transaction(client, () => {
const active = client.prepare("SELECT * FROM outbox WHERE report_id=? AND state IN ('pending','sending','conflict','rejected')").get(reportId);
if (active) return active;
const draft = client.prepare('SELECT * FROM drafts WHERE id=?').get(reportId);
if (!draft || !draft.body.trim()) throw new Error('Usable draft required');
const previous = client.prepare('SELECT * FROM outbox WHERE report_id=? AND revision=?').get(reportId, draft.revision);
if (previous) return previous;
client.prepare('INSERT INTO outbox(op_id,report_id,revision,base_version,body) VALUES(?,?,?,?,?)')
.run(operationId, reportId, draft.revision, draft.base_version, draft.body);
return client.prepare('SELECT * FROM outbox WHERE op_id=?').get(operationId);
});
}
export function applyOperation(server, operation) {
// Authentication and resource permission belong before this helper.
const { op_id, report_id, base_version, body } = operation;
if (typeof op_id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/u.test(op_id)
|| typeof report_id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/u.test(report_id) || !Number.isSafeInteger(base_version) || base_version < 0
|| typeof body !== 'string' || !body.trim() || body.length > 4000) {
return { status: 422, code: 'invalid_operation' };
}
const fingerprint = createHash('sha256')
.update(JSON.stringify([report_id, base_version, body])).digest('hex');
return transaction(server, () => {
const known = server.prepare('SELECT * FROM receipts WHERE op_id=?').get(op_id);
if (known) return known.fingerprint === fingerprint
? { status: 200, version: known.result_version, duplicate: true }
: { status: 409, code: 'operation_id_reused' };
const current = server.prepare('SELECT * FROM reports WHERE id=?').get(report_id);
if (!current) return { status: 404, code: 'not_found' };
if (current.version !== base_version) return { status: 409, code: 'version_conflict' };
const version = current.version + 1;
server.prepare('UPDATE reports SET body=?,version=? WHERE id=?')
.run(body, version, report_id);
server.prepare('INSERT INTO receipts VALUES(?,?,?)').run(op_id, fingerprint, version);
return { status: 200, version, duplicate: false };
});
}
export function recordResult(client, operation, result) {
return transaction(client, () => {
if (result.status === 200) {
client.prepare("UPDATE outbox SET state='acknowledged' WHERE op_id=?").run(operation.op_id);
client.prepare('UPDATE drafts SET base_version=?,synced_revision=? WHERE id=?')
.run(result.version, operation.revision, operation.report_id);
} else {
client.prepare('UPDATE outbox SET state=? WHERE op_id=?')
.run(result.status === 409 ? 'conflict' : 'rejected', operation.op_id);
}
});
}
export function attempt(client, server, operationId, loseAcknowledgement = false) {
const operation = client.prepare('SELECT * FROM outbox WHERE op_id=?').get(operationId);
if (!operation || operation.state !== 'pending') throw new Error('Pending operation required');
client.prepare("UPDATE outbox SET state='sending',attempts=attempts+1 WHERE op_id=?").run(operationId);
let result;
try { result = applyOperation(server, operation); }
catch (error) {
client.prepare("UPDATE outbox SET state='pending' WHERE op_id=?").run(operationId);
throw error;
}
if (loseAcknowledgement) {
// Controlled fault point AFTER server commit; preserve the operation ID.
client.prepare("UPDATE outbox SET state='pending' WHERE op_id=?").run(operationId);
return { status: 'acknowledgement_unknown' };
}
recordResult(client, operation, result);
return result;
}
// Run only after the one responsible sender is known to have stopped.
export function recoverInterrupted(client) {
return client.prepare("UPDATE outbox SET state='pending' WHERE state='sending'").run().changes;
}
Handle outcomes according to their meaning
An acknowledged operation records its confirmed revision and resulting version on the device. That statement concerns exactly the frozen revision. If the person has continued editing, the newer draft is preserved and remains unsynchronised. The checks explicitly establish this distinction. An old valid response must not accidentally make new input appear completed simply because both belong to the same report identifier.
A version conflict remains conflict, while another final rejection, such as a missing server resource, becomes rejected in the example. The payload remains available and automatic replacement stays blocked. This supports focused investigation instead of endlessly retrying an unchanged invalid prerequisite. A rejected business operation and an uncertain transient delivery outcome need different recovery decisions.
A known receipt also does not necessarily describe the latest complete server state. Another device may have changed the report after the acknowledged version. The next new operation must compare its known base against current state again. Replaying a receipt answers what happened to the old operation, not every question about later edits. A clear distinction avoids falsely treating successful repeat recognition as a full refresh of shared data.
Plan restart and interrupted-sender recovery
The simple model assumes exactly one responsible serial sender. An attempt marks its operation sending, while an unknown acknowledgement returns it to pending. An unexpected storage exception is surfaced and preserves retryability. These states describe bounded single-sender processing rather than a distributed multi-worker lock. The assumption belongs in the design and handover so it is not silently violated during expansion.
After the responsible sender is known to have stopped, a persisted sending state can be returned to pending. The recovery function must therefore not be invoked indiscriminately while another sender is still active. A larger system needs suitable ownership, leases or coordination and separate concurrency verification. The example does not claim those properties merely because it uses a transactional database.
The tests close and reopen the actual device file with an interrupted state. Recovery keeps the same operation identifier, and repeating recovery has no additional effect. The operation can then be processed. That establishes persistence and a defined restart decision, not automatic delivery under every mobile operating-system condition or successful scheduling by an unimplemented background service.
Coordinate retries with capacity and operations
Connections fluctuate and servers may be temporarily unavailable. Retry planning needs intervals, limits and understandable feedback. An immediate infinite loop consumes device and server resources without necessarily resolving the cause. The code counts attempts but does not implement a full scheduler. Keep that responsibility explicit in the real architecture instead of assuming the presence of an outbox automatically supplies a safe retry policy.
Android WorkManager provides facilities for persistent background work, constraints and backoff. The reviewed documentation describes connection requirements and retry strategies. An app still connects those facilities to its data contract. Scheduled work is not evidence that the server has already accepted the desired content; final presentation continues to follow an actual matched processing result. Scheduling and business acknowledgement remain distinct responsibilities.
Receipt retention also needs a policy. How long might a device return with an old uncertain outcome, and what happens if the corresponding receipt is gone? This model keeps its receipts. Production cleanup must not silently undermine the repetition contract. Plan retention with retry windows and a clarification route rather than deleting successful evidence solely because the visible queue currently looks empty.
Android Developers: Work requests
Design a usable conflict route
People do not need a raw database exception to understand a conflict. The interface can explain their stored version, another version and the outstanding decision. Which details may be shown depends on actual permissions. This fictional fixture displays two short original texts without generalising that all conflicting third-party content may be exposed to every person attempting an update.
Resolution needs an explicit action, such as retaining a version, reviewing a merge or referring to an owner. The resulting operation needs an appropriate new base and traceable content. Quietly resetting a version number merely hides the disagreement. A business choice must not be substituted by an accidental technical default. Define the route before claiming that a system can recover meaningfully from conflict.
Even without disagreement, distinguish saved on device, queued, in progress and acknowledged. The app may show editable new changes alongside an older confirmed revision. That distinction connects data and interface. The storage model supplies facts; the presentation turns them into a useful next task. Neither layer should invent the other’s outcome because a simpler visual state would be easier to display.
Interpret the 69 executed cases
The checks use separate real SQLite files. They establish storage and reopening, unchanged queue identity, a controlled missing acknowledgement after server commit and a matching repetition. The server stays at version one without a second update. Changed payload with the same operation identity is rejected, and the successful receipt survives reopening the server store. These are concrete observations rather than inferred reliability claims.
Further cases cover changed server state, rollback after a receipt collision, invalid values, an absent server resource and an interrupted sender. An older acknowledgement preserves a newer device draft, which can subsequently be sent against the updated known base. There are 69 named checks. Their count describes the exercised decisions, not a delivery percentage or a production reliability metric.
The missing acknowledgement is introduced at a controlled fault point between server processing and client result recording. No real radio link is severed and no concurrent-worker load is generated. Those boundaries matter. The example checks the data contract and repeat consequences, while a deployed app adds network, operating-system, permission and concurrency testing appropriate to its actual implementation.

Accept through disruption as well as success
Start with an ordinary report, save without connectivity and recover it after restart. Process the frozen operation, then introduce an outcome the device cannot confirm after the server has committed. Repetition must reuse the operation. This sequence reveals whether the application handles uncertainty or only supports one uninterrupted happy path. A successful first attempt alone cannot establish the recovery behaviour.
Another case lets a different actor change server state before the queued version arrives. The expected conflict must preserve both local text and the other revision. Review the intended resolution with the responsible business person. A red error surface without an action might avoid immediate data loss while still leaving the work blocked indefinitely. Preservation and usable resolution both belong to the product design.
Also review storage failure, expired authentication and exact response matching. An unrelated or outdated result must not mark the wrong draft confirmed. The isolated checks provide a clear core without replacing these outer paths. Good acceptance records expected behaviour together with the layer actually examined, allowing later teams to add evidence without overinterpreting what the original fixture proved.
Record scope and ownership
Offline work involves product decisions, data and operations. Business owners decide which actions are acceptable without current server state. Development establishes storage, identity and version checks. Design explains state and next action. Operations plans retry boundaries and failure investigation. A connection icon cannot take over all of those responsibilities, and no single library makes their agreement unnecessary.
Handover records the stores, operation type, identity lifecycle and conflict path. This small design permits one outstanding operation per report and one serial sender. Preserve those assumptions. A later expansion across more devices or parallel operations should deliberately extend the contract rather than merely add more loops around a small example that was never designed for that coordination problem.
The result is a traceable agreement about saved, queued and acknowledged work. People can continue while uncertain outcomes and other edits are handled deliberately. The real SQLite fixture supplies a tested core for that arrangement. A finished application connects it to actual access paths, devices and feedback, then demonstrates those connections with integration evidence appropriate to the deployment.
Key data decisions
| Situation | Example rule |
|---|---|
| No connection | Preserve durable draft and queue entry |
| Acknowledgement missing after commit | Repeat unchanged operation with same identity |
| Same identity, different content | Reject |
| Different server version | Preserve conflict; do not silently overwrite |
| New text after older acknowledgement | Confirm only the older revision |
| One stopped sender | Recover interrupted sending entries deliberately |
Synchronisation checklist
- Define offline-permitted work and business boundaries.
- Separate drafts from immutable transfer intentions.
- Retain identity when the outcome is unknown.
- Store server update and corresponding receipt atomically.
- Handle version conflicts without silent data loss.
- Distinguish older acknowledgements from new drafts.
- Define sender ownership, recovery, backoff and receipt retention.
- Verify the data contract separately from real transport and device integration.
Sources and verification scope
Primary sources reviewed on 9 November 2026. Original fictional report with 69 executed Node.js 24.22.1/SQLite cases using actual files. A controlled post-commit acknowledgement fault is introduced. No real mobile-network, Android-runtime or concurrent-worker load evidence is claimed.








