Feature spec
AdvancedCategory
scanning-devices
Build with AI
8–16 hours with Lovable + significant manual configuration
Custom build
2–4 weeks custom dev
Running cost
$0–25/mo at 100 users · $25–200/mo at 10K users depending on Realtime connection volume
Works on
Everything it takes to ship Device Sync — parts, prompts, and real costs.
Device sync requires three layers working together: a local persistence store (SQLite via drift on Flutter) that serves as the source of truth while offline, a Supabase Realtime WebSocket subscription that broadcasts row-level changes to all devices, and an outbox queue that holds writes made during offline periods and drains them on reconnection. With AI tools you can build basic Supabase Realtime sync in 8–16 hours, but true offline-first sync with conflict resolution is Advanced-level work that typically needs custom development at 2–4 weeks.
What Device Sync Actually Is — and Why It Is Hard
Device sync keeps the same user's data consistent across multiple devices — phone, tablet, secondary phone — automatically and without manual refresh. The online case is relatively straightforward: Supabase Realtime broadcasts database changes over WebSockets and each device applies the updates to its local state. The hard case is offline: the user makes changes on device A with no connectivity, then changes on device B with connectivity, then reconnects device A. Without an outbox pattern and conflict resolution logic, you will silently lose one set of changes. This is why device sync is the most complex feature in the scanning-devices category. AI tools can deliver the happy-path online sync in hours; offline-first with safe conflict resolution takes weeks of careful engineering.
What users consider table stakes in 2026
- Changes made on one device appear on another within 5 seconds when both are online — users can observe this with a second phone
- Offline edits queue locally and sync automatically when connectivity returns — no manual Sync button required
- Conflict resolution that never silently loses data — last-write-wins is acceptable for most apps as long as the timestamp comparison is reliable
- Sync status indicator visible at all times: synced, syncing, offline, or conflict — displayed as a chip in the top bar or app header
- Data loads instantly on app open from the local cache — no loading spinner waiting for a network response on first render
- Soft deletes that propagate correctly — deleting an item on one device removes it from all others rather than reappearing on next sync
Anatomy of the Feature
Six components. The sync engine and conflict resolution logic are where production builds consistently diverge from AI-generated prototypes.
Local persistence layer
DataSQLite database on the device, accessed via the drift package on Flutter or @op-engineering/op-sqlite on React Native. Stores all app data locally first — this is the single source of truth while offline. Indexed for fast reads so the UI never waits for a network response. The local DB schema mirrors the Supabase remote schema with additional columns: _synced (bool, false until confirmed by server), _deleted (bool, soft delete flag), and _local_updated_at (client-side timestamp for conflict comparison).
Note: Web apps do not have a reliable SQLite equivalent — IndexedDB in the browser is fragile and not suitable as a sync store. True offline-first sync is a native mobile concern.
Sync engine
BackendSupabase Realtime powered by PostgreSQL logical replication broadcasts row-level INSERT, UPDATE, and DELETE events to all subscribed clients via WebSocket. Each client applies incoming changes to the local SQLite database via a merge function that compares server updated_at with local _local_updated_at. Alternatively, Electric SQL provides full CRDT-based sync that handles concurrent offline edits without custom merge logic.
Note: Supabase Realtime WebSocket connections disconnect after 60 seconds of inactivity and do not always auto-reconnect. Add explicit reconnection logic with exponential backoff — AI-generated subscription code rarely includes this.
Conflict resolution logic
BackendFor simple data (settings, profile fields, lists): last-write-wins using the server-side updated_at timestamp. The server timestamp wins over the client timestamp to avoid clock skew between devices. For collaborative documents or concurrent list edits: vector clocks via Electric SQL or Automerge CRDTs guarantee that both devices' changes are merged rather than one overwriting the other.
Note: Last-write-wins is sufficient for most consumer apps. CRDTs are necessary only when two users can simultaneously edit the same document — a different complexity tier.
Connectivity monitor
UIFlutter's connectivity_plus package (or React Native NetInfo) listens for network state transitions. When the device goes offline, all writes are redirected to the local SQLite sync_outbox table rather than directly to Supabase. When connectivity returns, the outbox drainer processes queued operations in order, retrying failed items with exponential backoff before marking them as confirmed.
Note: Test offline behaviour explicitly by enabling Airplane Mode mid-session. AI-generated sync code is rarely tested in the offline path and often fails silently.
Auth-scoped sync
BackendSupabase RLS ensures each user's Realtime subscription only receives their own rows. The channel filter is set to user_id=eq.{auth.uid()} so a device never receives another user's data. All synced tables have a user_id UUID foreign key referencing auth.users. The service role key is only used in Edge Functions — never on the client.
Note: A common RLS mistake: creating the subscription before the user session is fully established, causing the first sync to return empty because the JWT is not yet valid.
Sync status UI
UIApp-level sync state derived from two signals: the length of the sync_outbox queue (0 = synced, >0 = pending) and the Supabase Realtime channel connection status (SUBSCRIBED, CLOSED, CHANNEL_ERROR). Displayed as a small status chip in the navigation bar or top header: green circle for synced, spinning indicator for syncing, cloud-with-slash for offline, warning triangle for conflict.
Note: Update the sync status indicator in a global state provider (Riverpod, Bloc, or React Context) so it reflects accurately across all screens.
The data model
All synced tables share a required column set. Run this template in the Supabase SQL editor for each table you want to sync — replace 'sync_items' with your actual table name. Also creates the outbox table pattern.
1-- Template: apply this pattern to each table that needs to sync2create table public.sync_items (3 id uuid primary key default gen_random_uuid(),4 user_id uuid references auth.users(id) on delete cascade not null,5 -- your domain columns here, e.g.:6 content text,7 title text,8 -- required sync columns:9 updated_at timestamptz not null default now(),10 _deleted bool not null default false,11 created_at timestamptz not null default now()12);1314alter table public.sync_items enable row level security;1516create policy "Users can manage their own sync items"17 on public.sync_items for all18 using (auth.uid() = user_id)19 with check (auth.uid() = user_id);2021create index sync_items_user_updated_idx22 on public.sync_items (user_id, updated_at desc);2324-- Trigger to auto-update updated_at on every row change25create or replace function public.set_updated_at()26returns trigger language plpgsql as $$27begin28 new.updated_at = now();29 return new;30end;31$$;3233create trigger sync_items_updated_at34 before update on public.sync_items35 for each row execute function public.set_updated_at();3637-- Enable Realtime for this table (run once per table)38alter publication supabase_realtime add table public.sync_items;3940-- Local outbox table (lives in device SQLite, not Supabase)41-- Create this as a drift table in your Flutter app:42-- sync_outbox(id, table_name, row_id, operation TEXT, payload TEXT, created_at INTEGER, retry_count INTEGER)Heads up: The updated_at trigger is essential for last-write-wins conflict resolution — it ensures the server timestamp is always set by the database, not the client, preventing clock skew from causing data loss. Add the supabase_realtime publication line for each table you want Realtime to broadcast.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
Production device sync requires native code: Electric SQL (Postgres to SQLite via CRDT sync) or drift + Supabase Realtime + custom outbox for Flutter. Full offline-first with conflict resolution and background sync.
Step by step
- 1Design the schema with all required sync columns on every synced table: user_id, updated_at (server-set), _deleted, _synced; apply updated_at triggers in Supabase
- 2Flutter: add drift for local SQLite, define a DAO layer that wraps all writes to both local DB and the sync_outbox table when offline
- 3Implement the outbox drainer: a background Isolate that watches the sync_outbox table, processes operations in order when connectivity is detected, and marks rows as _synced on confirmation
- 4Set up Supabase Realtime subscription with reconnection logic: subscribe on app launch, implement channel.onError handler with exponential backoff re-subscription, filter self-events by client UUID
- 5Implement last-write-wins merge in the sync apply function: when a remote event arrives, compare server updated_at with local _local_updated_at; apply the remote change only if server timestamp is newer
- 6Write integration tests covering: offline edit → reconnect → confirms sync; simultaneous edit on two devices → one wins; delete on device A while device B is offline → propagates correctly
Where this path bites
- Most complex feature in this category — requires careful schema design, extensive testing of edge cases, and deep Flutter knowledge
- Background sync on iOS is limited by OS battery policies — background fetch has a maximum 30-second window per invocation
- Electric SQL is still maturing; evaluate against your sync requirements before committing
Third-party services you'll need
The core sync infrastructure is free or open source. Costs scale with the number of concurrent Realtime connections:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase Realtime | WebSocket-based row-level change broadcasting powered by PostgreSQL logical replication — included in all Supabase tiers | 200 concurrent connections on free tier | Included in Pro $25/mo (500 concurrent); higher tiers for scale |
| Electric SQL | CRDT-based Postgres-to-SQLite sync — open source self-hostable alternative to Supabase Realtime for full offline-first support | Open source, free to self-host | Electric Cloud pricing TBC (approx) |
| drift (Flutter SQLite) | Type-safe SQLite library for Flutter — provides the local persistence layer for offline-first sync | Open source, free | Free |
| Automerge | Open source CRDT library for collaborative document sync — use when last-write-wins is insufficient | Open source, free | Free |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier supports 200 concurrent Realtime connections — 100 users with multiple devices may approach this limit. Upgrade to Pro at $25/mo for 500 concurrent connections and higher DB limits.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Supabase Realtime disconnects silently and never reconnects
Symptom: The Supabase Realtime WebSocket connection drops after 60 seconds of inactivity and does not always auto-reconnect. AI-generated subscription code calls channel.subscribe() once and never handles the CLOSED or CHANNEL_ERROR states — the app appears to be syncing (status shows 'synced') but is actually receiving no updates.
Fix: Add a channel.on('system', ...) listener that fires on CLOSED and CHANNEL_ERROR events. In the handler, call channel.unsubscribe() then re-subscribe after a delay using exponential backoff starting at 2 seconds and capping at 60 seconds. Test by disabling and re-enabling WiFi mid-session to trigger the disconnect.
Sync loop: local write triggers Realtime event back to same client
Symptom: When a client writes a row to Supabase, the Realtime subscription delivers the INSERT event back to the same client. If the subscription handler updates local state on every event without filtering, the same data is applied twice — causing visible flicker, duplicate list items, or double-increment of counters.
Fix: Tag each write with a client-generated UUID stored in a metadata column or a request header. In the Realtime event handler, compare the incoming event's commit_timestamp or payload.new.client_id with the IDs of writes made locally in the last 2 seconds. Skip events that originated from this client.
Deleted records reappear on sync after hard DELETE
Symptom: Using a hard DELETE removes the row from Supabase permanently. Another device's local SQLite cache still holds the record. On next sync, if the local write path re-inserts from cache, the deleted item reappears. This is the tombstone problem and it is a design flaw, not a bug.
Fix: Use soft deletes everywhere: set _deleted = true rather than calling .delete(). Sync the _deleted flag to all devices. Each device filters out _deleted=true rows from all display queries. Schedule a physical purge job only after all devices have confirmed receipt of the tombstone — typically 30+ days after deletion.
FlutterFlow App State offline edits lost on app restart
Symptom: FlutterFlow App State is held in memory at the widget tree root. If the user closes the app while offline with unsynced writes queued in App State, all pending edits are gone. There is no built-in persistence for App State across app restarts.
Fix: For any serious offline requirement, write to SharedPreferences (via a Custom Action) or a drift SQLite database as the primary write target — not App State. App State becomes a UI read cache derived from the persistent layer, not the source of truth. This requires Custom Action Dart code beyond FlutterFlow's visual tools.
RLS blocks cross-device row access with fresh session JWT
Symptom: When a user logs in on a second device for the first time, the new session's JWT may not immediately satisfy RLS policies that compare auth.uid() against the stored user_id column — especially if the user_id was stored as a text string or there is a type mismatch in the RLS expression. The device receives empty sync results silently.
Fix: Store user_id as UUID matching the auth.users(id) type, never as text. Write RLS policies using auth.uid() consistently without type casting. Test multi-device login explicitly: log in on a second device and verify the Realtime subscription immediately delivers existing rows.
Best practices
Apply the updated_at server-side trigger to every synced table — never let the client set this timestamp, as clock skew between devices causes incorrect conflict resolution
Use soft deletes (_deleted flag) on every synced table from day one — retrofitting tombstones after launch requires a data migration and time for all devices to process the change
Test offline behaviour explicitly before launch: enable Airplane Mode on each test device, make edits, re-enable connectivity, and verify every change reaches all other devices without duplicates
Filter self-originated Realtime events using a client UUID to prevent the same write from being applied twice to local state
Add exponential backoff reconnection logic to every Supabase Realtime subscription — silent disconnects without reconnection make your app appear to sync but actually accumulate stale data
Keep the sync_outbox table schema simple: table_name, row_id, operation (INSERT/UPDATE/DELETE), payload (JSON), retry_count — process in created_at order to preserve causality
Document and communicate the sync behaviour to users: show a 'Changes will sync when you reconnect' banner when offline rather than letting them wonder whether their edits are saved
When You Need Custom Development
AI tools can deliver basic Supabase Realtime sync for the online-only case. Device sync almost always needs custom development when any of these apply:
- True offline-first is required — users make edits in areas with intermittent or no connectivity (field workers, retail stores, remote locations) and expect zero data loss on reconnect
- Collaborative editing — two users editing the same document or list simultaneously requires CRDTs (Automerge or Electric SQL), not last-write-wins
- Background sync that continues when the app is closed — iOS background fetch and Android WorkManager integration require native platform code beyond visual builders
- Sync across more than two platforms simultaneously (iOS + Android + web) with consistent conflict resolution behaviour on all three
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
What is the difference between Supabase Realtime sync and offline-first sync?
Supabase Realtime sync keeps data consistent across devices when both are online — it uses a WebSocket subscription to broadcast database changes in real time. Offline-first sync additionally persists all data to a local SQLite database so the app works with no connectivity, and queues writes in an outbox table to be drained when connectivity returns. Realtime alone loses any data written while offline.
How do I prevent data loss when a user edits data on two devices simultaneously?
Use last-write-wins with server-side timestamps: the updated_at column is set by a Postgres trigger (not the client), so the server always determines which write is newer. When both devices come online, the row with the more recent server timestamp overwrites the other. For collaborative editing where both edits should be preserved, use CRDTs via Electric SQL or Automerge instead of last-write-wins.
Can I build device sync in FlutterFlow without writing any code?
For basic online-only sync, yes — FlutterFlow's Supabase Realtime query type broadcasts live data to all subscribed devices. For offline-first sync with an outbox pattern, no — you need Custom Action Dart code writing to a drift SQLite database. FlutterFlow App State is in-memory and lost on app close, making it unsuitable as an offline write store.
How do I handle soft deletes in a synced Supabase database?
Add a _deleted boolean column (default false) to every synced table. When a user deletes a record, UPDATE _deleted=true instead of calling DELETE. Sync the _deleted flag to all devices via Realtime. Each device filters out _deleted=true rows in all display queries. Never hard DELETE synced rows until you are certain all devices have received and processed the tombstone.
What is Electric SQL and when should I use it instead of Supabase Realtime?
Electric SQL is an open-source sync layer that replicates a Postgres database to client-side SQLite using CRDTs. Unlike Supabase Realtime (which sends raw change events that clients must apply manually), Electric SQL handles the merge logic automatically and supports true offline-first operation. Use Electric SQL when you need collaborative editing or when two users can simultaneously modify the same record and both changes must be preserved.
How many concurrent Realtime connections does Supabase allow on the free tier?
200 concurrent WebSocket connections on the Supabase free tier. Each device with an active Realtime subscription counts as one connection. If your app targets 100 users with an average of 2 active devices each, you are already at 200 connections at peak. Supabase Pro ($25/mo) raises this to 500 concurrent connections.
How do I show a 'syncing' indicator when data is being uploaded?
Derive the sync status from two signals: the length of the local sync_outbox queue (greater than 0 means pending uploads) and the Supabase Realtime channel state (SUBSCRIBED, CLOSED, or CHANNEL_ERROR). When outbox has items and the channel is SUBSCRIBED, show 'Syncing'. When outbox is empty and channel is SUBSCRIBED, show 'Synced'. When the channel state is CLOSED or network is offline, show 'Offline'.
Why do deleted records reappear after sync?
Hard DELETEs remove the row from Supabase permanently. Another device's local SQLite cache still has the row and can re-insert it on next write if the delete was not properly propagated. The fix is soft deletes: set _deleted=true and sync that flag to every device. Each device then filters out deleted rows locally. The physical record stays in the database until all devices have acknowledged the deletion.
Need this feature production-ready?
RapidDev builds device sync into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.