# How to Add Device Sync to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

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.

## 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** (data): SQLite 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).
- **Sync engine** (backend): Supabase 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.
- **Conflict resolution logic** (backend): For 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.
- **Connectivity monitor** (ui): Flutter'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.
- **Auth-scoped sync** (backend): Supabase 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.
- **Sync status UI** (ui): App-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.

## 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.

```sql
-- Template: apply this pattern to each table that needs to sync
create table public.sync_items (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  -- your domain columns here, e.g.:
  content text,
  title text,
  -- required sync columns:
  updated_at timestamptz not null default now(),
  _deleted bool not null default false,
  created_at timestamptz not null default now()
);

alter table public.sync_items enable row level security;

create policy "Users can manage their own sync items"
  on public.sync_items for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

create index sync_items_user_updated_idx
  on public.sync_items (user_id, updated_at desc);

-- Trigger to auto-update updated_at on every row change
create or replace function public.set_updated_at()
returns trigger language plpgsql as $$
begin
  new.updated_at = now();
  return new;
end;
$$;

create trigger sync_items_updated_at
  before update on public.sync_items
  for each row execute function public.set_updated_at();

-- Enable Realtime for this table (run once per table)
alter publication supabase_realtime add table public.sync_items;

-- Local outbox table (lives in device SQLite, not Supabase)
-- Create this as a drift table in your Flutter app:
-- sync_outbox(id, table_name, row_id, operation TEXT, payload TEXT, created_at INTEGER, retry_count INTEGER)
```

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 paths

### Flutterflow — fit 3/10, 8–16 hours

FlutterFlow with Supabase Realtime backend queries can sync data across devices for the same user when both are online — but lacks true offline-first SQLite persistence, so data disappears when connectivity is lost.

1. Connect Supabase in FlutterFlow Settings → Supabase and configure your sync_items table with the updated_at trigger applied
2. Add a Backend Query on your data list page using Supabase Realtime query type — select your table, filter by user_id = currentUser.uid, and enable the 'Real Time' toggle in the query settings
3. Add Page State variables to hold the sync status (synced, syncing, offline) and update them from the Realtime subscription response and the connectivity_plus Custom Action
4. Create a Custom Action CheckConnectivity that uses the connectivity_plus package to return the current network status, and call it in the page's onInit Action
5. Test by opening the app on two devices logged in as the same user, make a change on one, and observe it appear on the other within 5 seconds

Limitations:

- No built-in local SQLite persistence for offline — FlutterFlow App State is in-memory and lost when the app is closed; offline edits will be lost
- True offline-first with an outbox pattern requires Custom Action Dart code writing to a drift SQLite database — well beyond FlutterFlow's visual tools
- FlutterFlow App State does not survive app restart — any unsynced data queued in memory is gone if the user closes the app while offline

### Lovable — fit 2/10, 8–16 hours

Lovable can implement Supabase Realtime subscriptions for live sync on web — usable for web apps where offline is not a hard requirement, but not recommended for mobile sync.

1. Create a Lovable project with Supabase connected; add the sync tables with the updated_at trigger using the SQL editor in the Cloud tab
2. Paste the prompt below and ask Lovable to implement a Supabase Realtime subscription that subscribes to your sync_items channel filtered by user_id
3. Add a React Context or Zustand store to hold global sync state (online/offline/syncing) updated by the subscription status and navigator.onLine events
4. Publish and test by opening two browser tabs logged in as the same user — changes in one tab should appear in the other within 5 seconds
5. Acknowledge the offline limitation: browser localStorage is not a reliable sync store; document that offline edits will be lost when the tab closes

Starter prompt:

```
Build a device sync feature using Supabase Realtime for a web app. Create a React Context SyncContext that exposes: status ('synced' | 'syncing' | 'offline' | 'error'), lastSyncedAt, and a syncNow() function. In SyncContext: subscribe to the Supabase Realtime channel 'sync_items' filtered by user_id=eq.{auth.uid()} for INSERT, UPDATE, and DELETE events. On each event: update the local React state with the new row data using last-write-wins (compare server updated_at timestamps). On channel subscription error: set status to 'error' and attempt re-subscribe after 5 seconds with exponential backoff (up to 60s max delay). On channel SUBSCRIBED event: set status to 'synced'. Add window event listeners for 'online' and 'offline' browser events — set status accordingly. Include a sync status chip component showing the current status with a coloured dot (green=synced, amber=syncing, red=offline, warning=error). On reconnect from offline: call supabase.from('sync_items').select().eq('user_id', uid).order('updated_at', {ascending: false}) to fetch any remote changes made while offline and merge them into local state by updated_at. Handle the Supabase Realtime channel self-event loop: filter out events where the commit_timestamp matches a write made by this client within the last 2 seconds to prevent double-applying local writes. Show sync status in the app header. Add a manual Sync Now button that triggers a full re-fetch. Use soft deletes: when deleting a row, set _deleted=true rather than calling .delete(); filter out _deleted=true rows from all list queries.
```

Limitations:

- No offline persistence on web — browser localStorage is not a reliable sync store and the data is lost when the tab closes
- Supabase Realtime requires active configuration (enabling the table in the supabase_realtime publication) which must be done manually in the SQL editor
- Not recommended for mobile sync use cases — native mobile apps need drift SQLite for offline persistence, which Lovable cannot produce

### Custom — fit 5/10, 2–4 weeks

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.

1. Design 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
2. Flutter: add drift for local SQLite, define a DAO layer that wraps all writes to both local DB and the sync_outbox table when offline
3. Implement 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
4. Set 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
5. Implement 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
6. Write 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

Limitations:

- 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

## Gotchas

- **Supabase Realtime disconnects silently and never reconnects** — 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** — 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** — 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** — 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** — 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

## 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.

---

Source: https://www.rapidevelopers.com/app-features/device-sync
© RapidDev — https://www.rapidevelopers.com/app-features/device-sync
