Building an offline-first mobile BIM tool
Offline-first sounds simple. It isn't. After a year of building Planscape's mobile app for site teams in East Africa — places where the connection is the exception, not the rule — here's what we learned. This post is more technical than our usual writing; if you build mobile tools and you're tempted to say "we'll add offline later," please don't.
The brief
Our coordinators work on construction sites. Most of those sites have either no fibre at all, intermittent 4G in some areas and dead zones in others, or — in the worst case — a 24-hour total outage when the local mast goes down. They take photos, raise issues, scan QR tags on assets, navigate by GPS, and update issue statuses.
The brief was: every one of those actions must work when they're disconnected, must not lose data, and must sync seamlessly without manual intervention when connectivity returns. The user should never think about connectivity unless they choose to.
Lesson 1: offline-first means designing the data layer first
The mistake we made on the first prototype was building screens against the API directly, then adding a "cache layer" later. That works for read-heavy apps where stale data is acceptable. It doesn't work when the user expects to write while offline.
The right approach is to design the local database as the source of truth, and treat the server as an eventual-consistency replica. Every screen reads from local storage. Every action writes to local storage first and queues a sync job. The sync job hits the server when network state allows. Failed syncs retry with exponential backoff.
This inverts the usual mental model — and it forced us to rewrite about 40% of the original prototype. But it's the only model that gives the user the experience of "the app works regardless of connectivity".
Lesson 2: SQLite > Realm for our case
We started with Realm (now MongoDB Realm). Reactive, fast, lovely DX. But three things bit us:
- Schema migration friction. Every backend schema change required a matching Realm migration. With a fast-moving server, we were managing migration scripts in two places.
- iOS/Android divergence on Hermes. Some Realm features behaved subtly differently across platforms, especially under low memory.
- Audit-trail incompatibility. We needed to write hash-chained records locally and have them verifiably append-only. Realm's transaction model didn't give us a clean way to do this without working around the abstraction.
We migrated to plain SQLite with a hand-written schema, drizzle-style query builder, and a sync engine on top. More work to set up, less work to maintain in the long run. The audit-chain implementation became trivial — it's just an append-only table with a SHA-256 column.
For apps where the server is the source of truth and offline is "nice to have", Realm is great. For apps where the device is the source of truth for hours at a time and audit integrity matters, raw SQLite gave us cleaner control.
Lesson 3: the replay queue is harder than you think
Naive replay: collect pending writes, post them to the server in order, mark each as synced on success. This breaks in three ways:
Write dependencies
"Create issue, then add photo to issue, then add comment to issue" — three writes that have to land in order. If the first one fails (e.g. duplicate key from a previous partial sync), the second and third are orphaned.
We solved this by giving every local write a UUID generated client-side. The server treats UUIDs as idempotent — re-sending the same UUID is a no-op, not an error. This decouples "did the server receive this" from "did the server acknowledge this".
Conflict resolution
Two coordinators offline simultaneously, both change the same issue's status. When they come back online, whose write wins?
We adopted last-write-wins by timestamp — but with a twist: we surface the alternative in a conflict banner. The "loser" isn't silently overwritten. The user who got out-voted sees their change in a banner with "Override" and "Discard" options. In practice ~90% of conflicts are obvious (one user marked it Resolved before the other realised it was already fixed) and people pick the right option.
Stale references
You raise an issue offline, attached to element X. By the time you come back online, element X has been deleted in Revit. What happens?
We let the issue save (the photo and observation are still useful) but flag it as "orphaned — element no longer exists". The user can re-link to a current element or close the issue. We never silently drop user-generated data.
Lesson 4: photos are not data
Treat photos and voice notes as a different sync class to structured data. Specifically:
- Structured data is small and fast. 1 KB JSON for an issue. Sync immediately on any connection.
- Photos are large and slow. 4 MB JPEG. Sync only on WiFi, or on a fast cellular connection, or when the user explicitly requests.
We auto-detect connection quality (via a tiny ping on app startup) and queue photo uploads behind structured-data sync. If the user is on a slow connection, structured data lands first — the issue exists, just without its photo for the next hour. Coordinators value "the issue is on the server" much more than "the photo is uploaded right now".
The compression strategy matters too. We resize photos to 1080p before upload (originals stay on the device), and we generate a 400px thumbnail for the list view that uploads alongside the issue itself — so even before the original photo lands, teammates see the thumbnail.
Lesson 5: pre-sync is the killer feature
The single most useful UX intervention was making "pre-sync this project for offline use" a first-class one-button action. Users do it the night before a site visit, on WiFi at the office. The app downloads:
- The full issue list + photos at thumbnail resolution.
- All "Site reference" documents (PDF drawings, RFIs, design notes).
- The federated model's plan views at all visible levels.
- Mapbox tiles for a 5km radius around the project pin.
This takes 2–10 minutes on WiFi. Once done, the user can leave the office and work a full day offline without missing anything. We measured: before we had pre-sync as a button, ~30% of site visits had "I couldn't open X" friction. After: under 5%.
What we still get wrong
- Push notifications when offline. If your phone has no signal, you don't get the push notification when an issue is assigned. The notification arrives when you reconnect — but by then the assignment may be hours old. Not a fix we have yet.
- Voice-note transcription. Transcription is server-side (Whisper). When offline, you can record a voice note but it won't be transcribed until you're back online. We've been asked for on-device Whisper; the model size and battery cost have stopped us so far.
- Real-time collaboration. SignalR is online-only. When two coordinators are both offline on the same project, they don't see each other's edits live — they replay when both reconnect. We don't think this can be solved without P2P sync, which is a lot of complexity for the value.
The principles that survived contact with reality
- Local DB is source of truth. Server is eventual.
- Every write gets a client-generated UUID. Idempotent server.
- Replay queue is durable. Survives app restart, OS kill, low memory.
- Surface conflicts; don't auto-resolve. Users handle ambiguity better than algorithms.
- Different sync classes for different data types. Structured first, media second.
- Pre-sync is a feature, not a setting. Make it a button on the project home.
Tech stack, for the curious
- React Native + Expo (currently v52)
- SQLite via expo-sqlite, custom thin query layer
- TanStack Query for caching, with custom mutation middleware that writes to SQLite first
- Background tasks via expo-background-fetch for sync retry
- Mapbox GL Native for offline-capable maps
- SignalR client (when online) for real-time push from other team members
- Server: ASP.NET Core 8 + PostgreSQL + Redis + MinIO
If you're building something similar and want to compare notes, drop us a line — engineering@planscape.co. Always happy to talk shop.