A mobile client can lose the network at the worst moment: after the server stores a change but before the response reaches the phone. From the client’s point of view, “the request never arrived” and “the server committed it” can look identical. Retrying is necessary, but a retry must not become a second write.

FieldCore explores that boundary with a Room-backed event queue and an ASP.NET Core sync API. The useful engineering question is narrow: what should the server do when the same event returns, and how can the client tell a safe retry from a changed request?

SYNC TRACE / FIELDCORE PROTOTYPE / API VERIFIED

01 CLIENT TO SERVER

  1. ROOM / LOCAL TRANSACTIONSave the change and eventThe task update and UUID queue row commit together.
  2. WORKMANAGERWait for a safe windowThe job requires network connectivity and battery headroom.
  3. BOUNDED REQUESTKeep each batch within limitsUp to 100 events · 900,000 body bytes · 65,536 characters per payload.
  4. POST /API/SYNC/PUSHCheck identity and contentThe API stores each new event with a server version.
FIRST DELIVERYACCEPTED

Record the event once and return its server version.

SAME ID + SAME OWNER + SAME CONTENTDUPLICATE_ACK

Return the original version. Do not create a second event.

SAME ID, DIFFERENT OWNER OR CONTENT409 CONFLICT

Reject the reuse instead of silently treating it as a retry.

Source-based diagram of FieldCore’s mobile and API code. The API smoke run uses PostgreSQL in Compose; device-level offline recovery remains a separate, unverified demonstration.

Keep the local change and its event together

In the task-start path, the Android app updates the task and enqueues a sync event inside one Room transaction. The event gets a UUID and contains the entity, operation, timestamp and serialized payload. If that transaction fails, neither half is committed. That is the part of the local-first design I can point to directly in the source.

WorkManager schedules synchronization only when Android reports a connected network and the battery is not low. The sync engine reads pending events in ordered pages rather than loading an unbounded queue into memory. Its batch planner measures the serialized request as UTF-8 bytes, which matters because a character count is not always a byte count. It enforces three limits: at most 100 events, no payload above 65,536 characters, and no request body above 900,000 bytes.

Those bounds make a large backlog several requests instead of one oversized upload. If a request fails, the worker can ask WorkManager to retry the job. The event ID remains the same because the queued row is the same event.

Give an exact retry the original answer

The push endpoint validates required IDs, fields, payload size and JSON before it enters the write path. For a new event ID, it stores the event and assigns a server version. If it sees an existing ID, it compares the claimed device and user plus the event type, entity, operation and payload.

An exact match returns DUPLICATE_ACK with the event’s original server version. Reusing the ID from a different claimed device or user, or with different event content, returns HTTP 409 Conflict. The API does not turn every repeated ID into a success; it accepts only the same event identity and contents.

On the Android side, the sync engine checks that the response contains exactly one result for every event in the submitted batch before it records acknowledgements. The later pull phase uses a server cursor to fetch deltas in pages. This gives the prototype a specific retry contract that can be tested without pretending that every offline recovery case has been exercised on a phone.

What the evidence covers

The repository’s CI starts the API with PostgreSQL through Docker Compose and runs a smoke scenario. It accepts two events, resends the same request and checks for DUPLICATE_ACK with the original versions. It also checks that a reused ID with a different identity or payload gets 409, reads the log in cursor pages, and sends concurrent requests to check that the single API process assigns distinct versions.

The latest recorded CI run for the reviewed source revision passed Android unit tests and debug APK assembly, .NET API and console builds, and the PostgreSQL-backed API smoke test. That verifies the build and the tested API contract. It is not a benchmark or proof of an end-to-end Android device surviving a network outage.

Where the prototype stops

The Android app, Room queue, planner and WorkManager worker are implemented in source, but the repository does not claim a recorded emulator/device run where a user edits while disconnected, reconnects and watches that queue drain. I would verify that next, including an app or device restart between the edit and the retry.

The API is also a prototype. Its version-allocation gate is a process-local semaphore, so it does not coordinate multiple server replicas. A production service needs durable, database-backed version allocation and idempotency constraints, authenticated identity, authorization and tenant isolation. The current event log does not project every accepted event back into the task read model. Evidence images stay on the device; upload endpoints return 501 Not Implemented.

These limits shape how I describe the result: FieldCore contains a tested local API retry contract and a source-level Android sync path. I have not presented it as a production offline platform or a device-verified recovery flow.

For related examples, read how I designed quarantine and replay for synthetic telemetry in Aurora and how a transactional outbox coordinates PostgreSQL and Kafka.

← All notes