To build offline-capable OutSystems mobile apps, create Local Storage entities that mirror your server entities and implement sync logic using Client Actions triggered by connectivity events. This tutorial covers full and incremental sync patterns, uploading local changes with IsNew/IsModified/IsDeleted flags, last-write-wins conflict resolution, and automatic sync triggers on network restore and app resume.
How OutSystems Mobile Offline Sync Works
OutSystems mobile apps run in a native container (built by MABS — Mobile Application Build Service) that includes a local SQLite database. You model local data using Local Storage entities, which are structurally identical to server entities but stored on-device. Sync is an application-logic concern — OutSystems provides the building blocks (Local entities, Server Actions, Timers, connectivity events) but not a single automatic sync service. This tutorial implements two production-ready sync patterns: a full sync for reference/master data and an incremental sync for transactional data, with a last-write-wins conflict resolution strategy.
Prerequisites
- An OutSystems Mobile app module open in Service Studio (not Reactive Web — offline sync is mobile-specific)
- At least one server-side Entity with the data you want to sync to the device
- Understanding of Client Actions vs Server Actions in OutSystems mobile context
- The app published at least once via MABS so you have a real device or emulator to test on
Step-by-step guide
Create Local Storage Entities That Mirror Server Entities
Create Local Storage Entities That Mirror Server Entities
In Service Studio, go to **Data tab** in the left panel. Expand the **Local Storage** section (this section only appears in Mobile app modules — not Reactive Web). Right-click **Local Storage** → **Add Local Entity**. Name it to match your server entity (e.g., `LocalProduct`). Add the same attributes as your server entity (Name, Price, Category, etc.) plus two additional sync-tracking attributes: - `IsNew` (Boolean, default: False) — marks locally created records - `IsModified` (Boolean, default: False) — marks locally updated records - `IsDeleted` (Boolean, default: False) — marks locally deleted records (soft delete) - `LastSynced` (DateTime, default: #1900-01-01 00:00:00#) — timestamp of last server sync Local entities have auto-generated CRUD actions: `CreateLocalProduct`, `UpdateLocalProduct`, `DeleteLocalProduct`, `GetLocalProduct`.
Expected result: Data tab → Local Storage shows your LocalProduct entity. Service Studio auto-generates CreateLocalProduct, UpdateLocalProduct, DeleteLocalProduct, GetLocalProduct, and corresponding Aggregates targeting the local SQLite database.
Build the Full Sync Pattern (Download from Server)
Build the Full Sync Pattern (Download from Server)
Create a Server Action named `SyncDownload_Products`. This runs on the server and returns all products: **Server Action: SyncDownload_Products** - Output: `Products` (List of Product record) - Action flow: Start → GetAllProducts (Aggregate, no filters, no Max Records) → Assign Products = GetAllProducts.List → End Create a **Client Action** named `SyncProducts_Full`: Action flow: ``` Start → SyncDownload_Products (Server call) → For Each Product in SyncDownload_Products.Products → GetLocalProduct (find existing by server Id) → If GetLocalProduct.Success (local record exists) [True] → UpdateLocalProduct with new server values [False] → CreateLocalProduct from server data → [After loop] DeleteOldLocalRecords /* Aggregate: filter LocalProduct.Id NOT IN received server Ids */ /* Delete each record not in the server response */ → End ``` Call `SyncProducts_Full` from the App → OnApplicationReady lifecycle event.
1/* Client Action: SyncProducts_Full */2/* Runs on device, calls server, populates LocalStorage */34Start5→ SyncDownload_Products6 /* Server Action: returns all Product records */7→ For Each CurrentProduct in SyncDownload_Products.Products8 /* CurrentProduct is a Product record */9 → GetLocalProductById10 /* Aggregate: LocalProduct.ServerId = CurrentProduct.Id */11 → If GetLocalProductById.List.Empty12 [True] (new record)13 → CreateLocalProduct14 LocalProduct.ServerId = CurrentProduct.Id15 LocalProduct.Name = CurrentProduct.Name16 LocalProduct.Price = CurrentProduct.Price17 LocalProduct.LastSynced = CurrDateTime()18 LocalProduct.IsNew = False19 [False] (existing record — update)20 → UpdateLocalProduct21 LocalProduct.Id = GetLocalProductById.List.Current.LocalProduct.Id22 LocalProduct.Name = CurrentProduct.Name23 LocalProduct.Price = CurrentProduct.Price24 LocalProduct.LastSynced = CurrDateTime()25 LocalProduct.IsModified = False26→ EndExpected result: After SyncProducts_Full runs, Data tab → Local Storage → LocalProduct shows records populated from the server. The app can now display product data even in airplane mode.
Implement Incremental Sync Using Timestamps
Implement Incremental Sync Using Timestamps
For tables with many records that change frequently, only download records modified since the last sync. **Add a `ModifiedOn` attribute** to your server-side Product entity (DateTime, auto-set on every update via entity auto-timestamp or a Before Create/Update handler). **Create Client Variable**: Data tab → Client Variables → Add → name it `LastProductSync` (DateTime, default: NullDate()). **Modify SyncDownload_Products Server Action** to accept `SinceDateTime` (DateTime) input: - Add filter to Aggregate: `Product.ModifiedOn >= SinceDateTime` - If SinceDateTime = NullDate(), fetch all records (first sync) **Incremental Client Action: SyncProducts_Incremental**: ``` Start → SyncDownload_Products SinceDateTime = LastProductSync → [Process each returned record — upsert into LocalStorage] → Assign LastProductSync = CurrDateTime() → End ```
1/* Server Action: SyncDownload_Products (incremental version) */2/* Input: SinceDateTime (DateTime) */3/* Output: Products (List of Product) */45Start6→ GetModifiedProducts7 /* Aggregate filter: */8 /* Product.ModifiedOn >= SinceDateTime */9 /* OR SinceDateTime = NullDate() ← first sync, get everything */10 /* Filter expression: */11 /* Product.ModifiedOn >= SinceDateTime Or SinceDateTime = NullDate() */12→ Assign Products = GetModifiedProducts.List13→ End141516/* Client Variable tracking last sync time */17/* Data tab → Client Variables → LastProductSync (DateTime) */18/* Default: NullDate() */1920/* In SyncProducts_Incremental Client Action */21Start22→ SyncDownload_Products23 SinceDateTime = LastProductSync24→ For Each in SyncDownload_Products.Products25 → [upsert into LocalProduct as in full sync]26→ Assign: LastProductSync = CurrDateTime()27→ EndExpected result: Second and subsequent syncs only download records modified since the last sync timestamp, reducing data transfer and sync time significantly. The LastProductSync Client Variable is updated after each successful sync.
Upload Local Changes to the Server (Sync Up)
Upload Local Changes to the Server (Sync Up)
When users create or modify records offline, sync those changes back to the server when connectivity is restored. **Create Server Action: SyncUpload_Products** - Input: `ModifiedProducts` (List of LocalProduct) - For each record: create or update the server entity based on `IsNew`/`IsModified` flags - Output: `Success` (Boolean), `FailedIds` (List of Integer) **Client Action: SyncUp_Products**: ``` Start → GetModifiedLocalProducts /* Aggregate: LocalProduct.IsNew = True OR LocalProduct.IsModified = True */ /* Exclude IsDeleted records — handle separately */ → If NOT GetModifiedLocalProducts.List.Empty [True] → SyncUpload_Products (Server Action) ModifiedProducts = GetModifiedLocalProducts.List → [On success] Reset IsNew = False, IsModified = False for each uploaded record → GetDeletedLocalProducts /* Aggregate: LocalProduct.IsDeleted = True AND LocalProduct.ServerId > 0 */ → [Call Server Action to delete on server] → DeleteFromLocal (remove soft-deleted local records) → End ```
1/* Aggregate: GetModifiedLocalProducts */2/* Filter: LocalProduct.IsNew = True Or LocalProduct.IsModified = True */34/* Server Action: SyncUpload_Products */5/* Input: ModifiedProducts (List of LocalProduct) */6/* Output: Success (Boolean) */78Start9→ For Each CurrentRecord in ModifiedProducts10 → If CurrentRecord.IsNew11 [True]12 → CreateProduct13 Product.Name = CurrentRecord.Name14 Product.Price = CurrentRecord.Price15 /* ... other attributes ... */16 → Assign CurrentRecord.ServerId = CreateProduct.Id17 [False] (IsModified)18 → UpdateProduct19 Product.Id = CurrentRecord.ServerId20 Product.Name = CurrentRecord.Name21 Product.Price = CurrentRecord.Price22→ Assign Success = True23→ End2425/* After successful upload — reset flags in Client Action */26UpdateLocalProduct27 LocalProduct.Id = UploadedRecord.Id28 LocalProduct.IsNew = False29 LocalProduct.IsModified = False30 LocalProduct.ServerId = ServerIdFromUploadExpected result: After SyncUp_Products, all locally created and modified records appear on the server. Local IsNew and IsModified flags are reset to False. Deleted records are removed from both the server and local storage.
Implement Conflict Resolution (Last Write Wins)
Implement Conflict Resolution (Last Write Wins)
A conflict occurs when the same record is modified both locally and on the server since the last sync. **Last Write Wins** (simplest strategy): Compare `ModifiedOn` timestamps — the more recent change wins. In `SyncUpload_Products`, before updating a server record: ``` → GetServerProduct (Get server entity by ServerId) → If GetServerProduct.ModifiedOn > CurrentRecord.LastSynced [True — server modified after our last sync — CONFLICT] → Apply conflict policy: Option A: Server wins → discard local change, refresh local from server Option B: Client wins → proceed with update, overwrite server Option C: Merge → merge specific fields (complex, app-specific) [False — no conflict, safe to update] → UpdateProduct ``` For most enterprise apps, server wins is safest and prevents data loss.
1/* Conflict detection in SyncUpload_Products Server Action */23For Each LocalRecord in ModifiedProducts4→ GetProductFromServer /* Get<Entity> by ServerId */5→ If GetProductFromServer.Success6 → If GetProductFromServer.Product.ModifiedOn > LocalRecord.LastSynced7 [True — CONFLICT: server changed after our last sync]8 /* Option A: Server wins — return conflict info to client */9 → Append to ConflictsList: LocalRecord.Id10 [False — no conflict, safe to proceed]11 → UpdateProduct (server entity)12 [False — record doesn't exist on server anymore]13 → If LocalRecord.IsNew14 [True] → CreateProduct (was new local record, never existed on server)15 [False] → Skip update (deleted on server — log and discard)1617/* On client: refresh conflicted records from server */18For Each ConflictedId in SyncUploadResult.ConflictsList19→ GetServerRecord (Server Action)20→ UpdateLocalProduct with server data21→ Assign IsModified = False, LastSynced = CurrDateTime()Expected result: When a sync-up encounters a server-modified record, the conflict is detected and resolved according to policy. No silent data loss occurs. Conflicts are logged for audit purposes.
Trigger Sync on Connectivity Restore and App Resume
Trigger Sync on Connectivity Restore and App Resume
OutSystems mobile apps expose lifecycle and connectivity events. Wire your sync logic to these: **On app resume** (user reopens app): - Interface tab → UI Flows → Common → **OnApplicationReady** → add call to `SyncProducts_Full` (or incremental if LastSync is recent) **On connectivity restore**: - Interface tab → UI Flows → Common → **OnNetworkStatusChanged** event → check if status = Online → trigger `SyncProducts_Incremental` followed by `SyncUp_Products` **Manual sync button**: - Add a Sync button on relevant screens → OnClick Client Action calls the sync sequence with a loading spinner **Offline indicator**: - Use `IsNetworkConnected()` built-in Client function in a Container's Visible expression or a status bar widget. Action flow for OnNetworkStatusChanged: ``` Start → If IsNetworkConnected() → [True] SyncProducts_Incremental → SyncUp_Products → End ```
1/* OnNetworkStatusChanged Client Action */2/* Available at: Interface tab → UI Flows → Common → OnNetworkStatusChanged */34Start5→ If IsNetworkConnected()6 [True — device just came online]7 → SyncProducts_Incremental8 SinceDateTime = LastProductSync9 → SyncUp_Products /* upload local changes */10 → FeedbackMessage("Sync complete", Success)11 [False — device went offline]12 → FeedbackMessage("You are offline. Changes saved locally.", Info)13→ End141516/* Offline indicator — Container Visible property */17Not IsNetworkConnected()1819/* Online indicator */20IsNetworkConnected()2122/* OnApplicationReady — initial sync on app start */23Start24→ If IsNetworkConnected()25 [True]26 → If LastProductSync = NullDate()27 [True] → SyncProducts_Full /* First time */28 [False] → SyncProducts_Incremental /* Subsequent */29→ EndExpected result: The app automatically syncs when connectivity is restored, processes the upload queue, and shows a success notification. Users see an offline indicator when disconnected. Data changes while offline are preserved and uploaded on next connection.
Complete working example
1/* ============================================================2 OutSystems Mobile Offline Sync — Full Pattern Reference3 ============================================================ */45/* === LOCAL STORAGE ENTITY: LocalProduct ===6 Attributes (mirrors server Product entity + sync flags):7 Id (Long Integer, AutoNumber)8 ServerId (Long Integer) — server entity Id9 Name (Text, 100)10 Price (Decimal)11 CategoryId (Integer)12 IsNew (Boolean, default False) — created offline13 IsModified (Boolean, default False) — modified offline14 IsDeleted (Boolean, default False) — deleted offline15 LastSynced (DateTime, default NullDate()) — last server sync16*/171819/* === CLIENT VARIABLES ===20 LastProductSync (DateTime, default NullDate())21 SyncInProgress (Boolean, default False)22*/232425/* === KEY EXPRESSIONS === */2627/* Is device online? */28IsNetworkConnected()2930/* Has this been synced at least once? */31LastProductSync <> NullDate()3233/* Count pending local changes */34/* Aggregate filter: IsNew = True Or IsModified = True Or IsDeleted = True */3536/* Sync age — minutes since last sync */37DiffMinutes(LastProductSync, CurrDateTime())3839/* Should do full sync (never synced OR last sync > 24 hours ago)? */40LastProductSync = NullDate() Or DiffMinutes(LastProductSync, CurrDateTime()) > 1440414243/* === INCREMENTAL FILTER FOR SERVER AGGREGATE ===44 Add to SyncDownload_Products Server Action Aggregate:45 Product.ModifiedOn >= SinceDateTime Or SinceDateTime = NullDate()46*/474849/* === CONFLICT DETECTION EXPRESSION ===50 In SyncUpload Server Action — compare timestamps:51 ServerRecord.ModifiedOn > LocalRecord.LastSynced52*/535455/* === FEEDBACK MESSAGES ===56 $public.FeedbackMessage.showFeedbackMessage(57 "Sync complete — " + IntegerToText(SyncCount) + " records updated",58 1 /* 1 = Success */59 )6061 $public.FeedbackMessage.showFeedbackMessage(62 "You are offline. Changes will sync when reconnected.",63 0 /* 0 = Info */64 )65*/Common mistakes
Why it's a problem: Calling a Server Action that reads the database directly from a Client Action expected to work offline — Server Actions require network connectivity and will fail when offline.
How to avoid: Separate online from offline operations explicitly: all reads during offline use Local Storage Aggregates (client-side, no network call). Server Actions are only called during sync operations when IsNetworkConnected() is True.
Why it's a problem: Using a Local Variable to store the LastSync timestamp, losing it when the sync action completes — the next sync always does a full download.
How to avoid: Store LastSync as a Client Variable (Data tab → Client Variables) — Client Variables persist in the app's local storage across navigations and app restarts.
Why it's a problem: Not implementing a SyncInProgress guard, allowing the user to navigate away and trigger another sync while the first one is still running — causing duplicate records in Local Storage.
How to avoid: Set SyncInProgress = True at the start of your sync Client Action and = False at the end (in all branches including error handlers). Check SyncInProgress = False before starting a new sync.
Why it's a problem: Assuming the full sync covers deleted server records — if you only upsert returned records, records deleted on the server remain in Local Storage indefinitely.
How to avoid: In full sync: after upserting all returned records, query for Local Storage records whose ServerId is NOT in the returned server Id list and delete them. In incremental sync: add a separate 'deleted records' endpoint that returns Ids deleted since the last sync timestamp.
Best practices
- Keep Local Storage entities as lean as possible — store only what is needed for offline operation. Sensitive data on-device increases the impact of device theft.
- Always implement a SyncInProgress Client Variable boolean and guard sync calls with it to prevent overlapping sync operations from corrupting local data.
- Test offline scenarios explicitly in the OutSystems App Feedback app or on a real device — the browser preview does not simulate offline conditions accurately.
- Use MABS (Mobile Application Build Service) version compatibility — check forge.outsystems.com for your platform version's MABS compatibility matrix before adding Forge plugins that affect SQLite.
- Implement a maximum local storage size limit for Binary Data (images, documents) — device storage is finite and large offline payloads cause app performance degradation.
- Log sync operations (start time, record count, duration, errors) to a local LogSync entity so you can diagnose sync failures reported by field users.
- Handle the NullDate() initial state explicitly in every sync condition — failing to check this causes incremental sync to miss all records on a fresh install.
- In ODC mobile apps, the sync patterns are identical — the Local Storage entity concept and IsNetworkConnected() function work the same way in ODC Studio.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building an OutSystems Mobile app that needs to work offline. Explain how to: (1) create Local Storage entities that mirror my server Product entity, adding IsNew/IsModified/IsDeleted/LastSynced tracking fields, (2) implement a full sync Client Action that downloads all server records and upserts them into Local Storage, (3) implement an incremental sync using a LastSynced timestamp Client Variable and a ModifiedOn server filter, (4) upload local changes back to the server detecting conflicts using timestamp comparison, and (5) trigger sync on OnNetworkStatusChanged and OnApplicationReady events. Use OutSystems expression syntax.
In my OutSystems Mobile app in Service Studio, I need to add offline sync for my Order entity. The app already has a server-side Order entity. Help me: (1) create a LocalOrder entity in Data tab → Local Storage with sync tracking attributes, (2) write a SyncDownload_Orders Server Action with a ModifiedOn filter, (3) write a SyncOrders_Incremental Client Action that calls the server action and upserts results into LocalStorage, (4) store the last sync time in a Client Variable, and (5) add the sync trigger in the OnNetworkStatusChanged event. Show exact UI paths and expressions.
Frequently asked questions
What database is used for Local Storage on the device?
OutSystems mobile apps use SQLite for Local Storage. The database file is stored in the app's private storage directory on the device — it is not directly accessible to users or other apps. MABS bundles the SQLite library into the native app container.
Can I use offline sync in a Reactive Web app, or only Mobile?
Local Storage entities are only available in OutSystems Mobile app modules — the option does not appear in Reactive Web modules in Service Studio. For Reactive Web apps, you can achieve limited offline capability using PWA service workers for asset caching, but transactional data sync requires a Mobile app module.
How much data can I store in Local Storage on a device?
SQLite on iOS and Android has no hard platform limit, but practical limits depend on the device. OutSystems does not impose a framework limit. As a guideline, keep Local Storage under 50MB for good performance. For large binary files (images, PDFs), store them separately using the File System Forge plugin rather than as Binary Data attributes in Local Storage entities.
Does the offline sync work the same way in ODC as in O11?
Yes — the Local Storage entity concept, sync action patterns, IsNetworkConnected() function, and Client Variable persistence all work identically in ODC Studio for Mobile apps. The main difference is deployment: native mobile builds still use MABS in both platforms, and ODC Portal handles build submissions instead of Service Center.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation