FieldLog is an offline-first mobile capture tool for working in environments with poor or zero connectivity. Users create inspection reports, attach photos, add notes, and search their local dataset without any network dependency. When connectivity returns, a sync engine reconciles local state with the server.
The system is built around a shared Rust sync engine that handles all the hard distributed systems logic (operation queuing, conflict resolution, retry with backoff) and exposes it to a React Native client through a JSI bridge. The server is C#/.NET.
I wanted to build something that demonstrates real offline-first architecture, not just "cache some API responses." FieldLog treats the local database as the source of truth. Every write goes to SQLite first, gets queued as an operation, and syncs later. The user never waits for a network call.
The interesting problems here are conflict resolution (what happens when two devices edit the same report offline?), operation ordering (a note can't sync before its parent report), and failure recovery (what happens when the server is down mid-sync, or the app gets killed, or the network drops after three of five operations succeed?).
React Native (Expo)
│
│ JSI (synchronous C++ calls, no bridge serialization)
│
Rust Sync Engine
├── Operation Queue (SQLite-backed, survives app kill)
├── Sync Worker (push batches, pull changes, background loop)
├── Conflict Resolver (field-level merge with LWW tiebreak)
└── Event Bus (pushes state changes to JS reactively)
│
│ HTTPS / REST
│
C# / ASP.NET Core Server
├── Push/Pull sync endpoints
├── Version-based conflict detection
└── Attachment storage
The hard logic lives once in Rust. The React Native client is a thin UI layer over Zustand stores that call into the engine. The server is a sync coordinator that persists state and detects conflicts.
Every local write (create report, update note, delete attachment) produces an operation that enters a SQLite-backed queue. Nothing hits the network at write time.
The sync worker runs in the background and drains the queue in batches. Each batch goes to the server's push endpoint. The server checks each operation's version against its own copy. If versions match, the write applies. If they diverge, the server returns the conflict with its current state.
On conflict the client runs field-level merge, if only the client changed a field, the client value wins. If only the server changed a field, the server value wins. If both changed the same field, last-write-wins by timestamp decides. The merged result gets applied locally.
After pushing, the worker pulls server changes since the last cursor. Pulled changes go through the same version logic in reverse. The cursor is persisted so restarts don't re-pull everything.
Failed operations retry with exponential backoff and jitter (to prevent thundering herd when many devices come back online simultaneously). After max retries, operations are abandoned and shown in the sync dashboard with their error messages.
fieldlog/
├── core/ Rust sync engine
│ ├── src/domain/ Entities, value objects, error types
│ ├── src/features/ Queue, sync, conflict resolution, events
│ ├── src/infrastructure/ SQLite repos, network monitor, file storage
│ ├── src/engine/ Public API that the FFI layer calls
│ ├── src/ffi/ C ABI exports
│ ├── tests/ Integration and chaos tests
│ └── examples/ End-to-end smoke test against a real server
├── clients/react-native/ Expo app
│ ├── app/ File-based routes (Expo Router)
│ ├── components/ Extracted UI components
│ ├── stores/ Zustand (reports, sync status)
│ ├── hooks/ Engine init, network monitor, storage info
│ ├── services/ JSI bridge wrapper
│ └── modules/ C++ JSI bridge, Kotlin/ObjC++ installers
├── server/csharp/ ASP.NET Core sync server
│ ├── src/ API, application layer, domain, infrastructure
│ └── tests/ xUnit integration tests
└── scripts/ Rust cross-compilation for iOS and Android
Start the server:
cd server/csharp
dotnet run --project src/FieldLog.Api --urls http://localhost:5050
Start the React Native app:
cd clients/react-native
pnpm install
EXPO_PUBLIC_API_URL=http://localhost:5050 pnpm dev
Run the end-to-end smoke test (needs the server running):
cd core
cargo run --example smoke_test -- http://localhost:5050
Run all tests:
cd core && cargo test
cd server/csharp && dotnet test
cd clients/react-native && pnpm lint && npx tsc --noEmit
cd core && cargo clippy --all-targets
React Native with Expo Router, TypeScript in strict mode, React Native Paper for UI, Zustand for state. The native bridge is JSI over a hand written ABI (not the legacy RN bridge, not UniFFI). The Rust engine uses SQLite in WAL mode for concurrent reads during sync, tokio for the async runtime, reqwest for HTTP, and serde for serialization. The server is ASP.NET Core 8 with Entity Framework Core.
I evaluated UniFFI for generating platform bindings but dropped it. The primary client is React Native, which UniFFI can't target and maintaining a UDL alongside C header would have been two contracts for the same boundary. The C ABI is 27 functions.
I evaluated CRDTs for conflict resolution and chose version vectors with field level merge instead. FieldLog records are structured documents edited by one user at a time. The CRDT state vector overhead doesn't pay for itself when the conflict rate is low and the resolution policy is simple.
The JSI bridge calls into Rust on the JS thread. This sounds crazy but the Rust engine's tokio runtime runs on its own thread pool. The JS thread blocks for the duration of the call which is cheaper than the async bridge would be.
90 Rust tests covering the operation queue lifecycle, conflict resolution (clean write, field merge, LWW), sync worker behavior (success, 5xx retry, offline skip, conflict handling, dependency ordering, queue persistence across restarts), and chaos scenarios (intermittent 500s, high latency, malformed responses, rate limiting, network toggling). 13 C# xUnit tests covering push (create, update, delete, idempotency), conflict detection (stale version), pull (cursor filtering, deleted entities), and batch processing.