The app keeps a person's notes and lists on a server, and the server is the only source of truth. The phone holds a read-only mirror, refreshed on every successful fetch and served only when the network fails. That rule is easy to keep for reads. It gets uncomfortable the first time someone ticks an item on a list in a shop with no signal: the screen has to obey now, the server hears about it later, and in between the phone is showing a state nobody has accepted.
This is the write side of that design: a durable queue of edits made without a connection, replayed by two triggers, able to undo itself when the server says no. React Native 0.81, one Kotlin module. Every block below is copied from the repository at the commit named above it.
An entry that remembers what it replaced
The queue is a JSON array in AsyncStorage, and the shape of one entry is the whole design.
frontend/src/services/pendingEditGuards.ts at e4fbb70:
export interface PendingEdit {
id: string;
neuronId: string;
/** The fields to send, exactly as the PATCH body. Empty for a deletion. */
patch: Record<string, unknown>;
/** Those same fields as they were BEFORE the edit. Without this a rollback
* is impossible: we would know to undo without knowing what to restore.
* For a deletion this holds the WHOLE fiche, since that is what a rollback
* has to put back. */
previous: Record<string, unknown>;
patch is the request body, verbatim. previous is the same keys as they stood in the local mirror before the edit, an absent field recorded as null so that rolling back clears it. A deletion carries an empty patch and the entire cached row as previous.
An optional kind field marks deletions and favourites. Optional on purpose: the first shipped version only knew patches, and entries already sitting on phones had to stay valid across the update. An unknown kind is dropped at parse time rather than replayed as the wrong request.
Send first, queue on transport failure
Every writer goes through one function. It tries the network, and only a retryable failure sends the edit to the queue.
frontend/src/services/MemoryService.ts at cae57e0:
try {
await sendNeuronPatch(id, patch);
void neuronCache.patch(id, patch).catch(() => {});
return;
} catch (err) {
// An explicit refusal is not an offline case: it must reach the user now.
if (!isRetryable(err)) throw err;
const cached = await neuronCache.get(id).catch(() => null);
await pendingEdits.enqueue({ neuronId: id, patch, previous: previousOf(cached, patch) });
void neuronCache.patch(id, patch).catch(() => {});
}
isRetryable is a regex over the error message: a 400, 403, 404 or 409 is definitive, everything else is worth another try. A 409 in particular means the server's own merge-and-retry already gave up. If the server says no while the network is fine, the user needs to see that now, not at the next reconnect.
Status changes ("done", "archived") ride the same path. Deletions joined it later the same day, the last gesture of the card menu still doing a bare DELETE and throwing with no signal.
The rule that had to go
The first version of the queue refused shared cards. The reasoning was in the guard itself.
frontend/src/services/pendingEditGuards.ts at 7d688f9:
export function isSharedFiche(neuron: Record<string, unknown> | null): boolean {
if (!neuron) return true;
if (neuron.visibility === 'shared') return true;
const meta = neuron.metadata as { shared_ref?: unknown } | null | undefined;
return Boolean(meta?.shared_ref);
}
A card with more than one author, the argument went, could have a friend's tick erased by a stale edit replayed hours later. An unknown card counted as shared, because refusing one edit was cheaper than overwriting someone. It was cautious, it was tested, and it was wrong twice over.
First, the product: a shared grocery list ticked in a shop with no signal is the single most common offline moment the app has. Refusing it broke exactly what offline mode exists for. Second, the server already provided the protection.
backend/src/routes/anonymous.ts at cae57e0:
if (attemptNo > 0) {
const { data: fresh } = await serviceClient
.from('neurons')
.select('metadata,updated_at')
.eq('id', req.params.id)
.eq(column, value)
.maybeSingle<{ metadata: Record<string, unknown> | null; updated_at: string | null }>();
if (!fresh) return { committed: true, value: { refusal: 'not_found' } };
expectedUpdatedAt = fresh.updated_at;
if (body.metadata !== undefined) {
const merged = buildOwnerMetadata(
body.metadata as Record<string, unknown>,
(fresh.metadata ?? {}) as Record<string, unknown>,
userId ?? deviceId ?? 'owner',
new Date().toISOString(),
body.removed_section_ids,
);
if (!merged.ok) return { committed: true, value: { refusal: merged.error } };
effectivePatch = { ...patch, metadata: merged.metadata };
}
}
The PATCH route writes under an updated_at guard: the update carries .eq('updated_at', expectedUpdatedAt) and commits nothing if someone wrote in between. On that miss it re-reads the fresh row, merges the client's metadata onto it, and tries again. What the client imposes is reapplied; what the client never saw survives. Two people ticking different items both keep their tick. What the merge cannot save is a scalar rewritten on both sides, the card's text for instance: there, last writer wins and the card history keeps the trace.
So the guard was deleted at 3bc82c6, not left unused, because it encoded a rule that had just proved wrong. The same commit fixed something the refusal had hidden: no route returned visibility, so offline the "shared" badge vanished on a card a friend was editing.
Replay: order, refusal, rollback
Two triggers replay the queue: in the foreground the WebSocket connected frame, when the tube is known good; with the app closed, a native worker, described below. Both call the same flush(), which is why it holds a lock.
frontend/src/services/PendingEditStore.ts at e4fbb70:
async flush(): Promise<void> {
if (this.flushing) return;
this.flushing = true;
try {
await this.replayOnce();
} finally {
this.flushing = false;
}
}
The lock arrived at bac42a8, after a test reproduced the failure: two overlapping passes read the same queue and sent the same edit twice. Harmless while a reconnecting socket was the only trigger; not once a background worker could start a pass while the user was opening the app. A plain flag is enough because both triggers share the JS context. The finally has a test of its own: a latching lock would drain the queue once and never again.
The pass walks the queue in arrival order. A retryable failure stops it and leaves the rest queued: two edits on the same card must land in the order they were made. A definitive refusal is where previous earns its place.
Same file, same commit:
if (edit.kind === 'delete' && isDeleteAlreadySettled(err)) {
await this.remove(edit.id);
continue;
}
await this.remove(edit.id);
if (Object.keys(edit.previous).length > 0) {
await neuronCache.patch(edit.neuronId, edit.previous).catch(() => {});
}
const cached = await neuronCache.get(edit.neuronId).catch(() => null);
const content = cached?.content;
const title = typeof content === 'string' ? content.split('\n')[0].slice(0, 60) : '';
this.onRejected?.({ neuronId: edit.neuronId, title });
The refused entry is dropped, the mirror is patched back to the pre-edit fields, and a handler set by the app names the card in a message to the user.
The first three lines are the one exception. A 404 counts as definitive, but a queued deletion answered 404 has already got what it wanted: the card is gone. Rolling that back would restore previous, the whole card, and resurrect something the user deleted. So a deletion treats 404 as success and drops the entry.
The second trigger, without the app
A card edited with no signal should reach the server without the app being reopened. On Android the queue reports its size to a small native module after every change; the module holds a single one-shot work item under a network constraint while the count is above zero and cancels it when the queue drains. ExistingWorkPolicy.KEEP means fifty offline edits arm one wake-up rather than fifty, and someone who never edits offline never asks the system for a single one.
frontend/android/app/src/main/java/com/xneuronal/OfflineReplayWorker.kt at 24cbb26:
override fun doWork(): Result {
val pending = OfflineReplayModule.pendingCount(applicationContext)
if (pending <= 0) {
Log.i(TAG, "nothing queued, not starting the replay")
return Result.success()
}
return try {
Log.i(TAG, "$pending queued, starting headless replay")
applicationContext.startService(
Intent(applicationContext, OfflineReplayService::class.java)
)
Result.success()
} catch (err: Exception) {
// Android refuses background service starts in some states. Retrying is
// free and the constraint keeps it network-bound.
Log.w(TAG, "could not start the replay service: ${err.message}")
Result.retry()
}
}
Native learns how many entries wait, never which. The counter lets the worker skip booting a JavaScript runtime when the system fires the work after the app was reopened and the queue already drained. Otherwise it starts a headless task service, which runs the same flush() the foreground runs. The background replay inherits order, refusals, rollback and the 404-means-done rule by construction, not by discipline. A replay reimplemented in Kotlin would have been a twin that drifts, and drifting twins produced this project's worst defects.
frontend/index.js at 7a0a6ba:
AppRegistry.registerHeadlessTask('OfflineReplay', () => async () => {
// Creations before edits: an edit aimed at a draft the server has never seen
// takes a 404, and the edit queue reads a 404 as a definitive refusal - it
// would roll back and destroy the fiche.
const { pendingCreations } = require('./src/services/PendingCreationStore');
const { pendingEdits } = require('./src/services/PendingEditStore');
await pendingCreations.flush();
await pendingEdits.flush();
});
The ordering comment describes a bug found by reading, not by a user. A card created offline has an id the server has never seen; an edit to it, replayed first, takes a 404, and 404 is definitive. Creations go first, on both triggers. The require() calls are deliberate too: the module graph must not be pulled in until the task runs, or a wake-up meant to flush a queue would boot half the app first.
The worker returns success without waiting for the JavaScript. If the replay fails, the queue keeps its entries and the next queue change arms a fresh wake-up. The queue is the only authority on what still has to be sent; the schedule is a hint to the OS, and its worst case is a wake-up that never fires, which is exactly where the app stood before the worker existed.
What the code does not prove
The unit tests cover the store on its real path: arrival order, a retryable failure leaving the rest queued, a rollback restoring the exact previous state, the lock refusing a second concurrent pass and accepting a later one, entries surviving a restart, a queued deletion answered 404 being dropped rather than resurrected. They run against mocked storage and mocked send functions. Nothing tests the Kotlin side: the counter, the KEEP policy, the network constraint, the headless service start. That the native worker and a foreground launch actually collide on a phone is inferred from the platform's documented behaviour, not from a captured trace.
The merge argument that justified deleting the shared-card guard rests on the server merging metadata, which is where checklist items live. A scalar field rewritten by two people offline is last-writer-wins by design, and no test pins what the second writer sees.
And the cap. The comment above MAX_ENTRIES says the oldest entries survive, because dropping the newest would discard what the user just did while they still believe it landed. The line beneath it is slice(-MAX_ENTRIES), which keeps the newest two hundred and drops the oldest. One of the two is wrong, no test pins which end goes, and nobody has hit the cap on a phone. I found that by copying the block for this article.
The lesson I kept: a client-side guard that refuses a write "to be safe" has to name the exact failure it prevents, and then someone has to check whether the server already prevents it. Ours did not survive that check, and keeping it had broken the product's most common offline moment.
The queue described here ships in the Android app at xneuronal.com.