MediumBuilding an Offline Sync for OutSystems Mobile Apps That Survives the Real World | by Henrique Silva | Oct, 2026 | Medium
Sitemap
Henrique Silva

Henrique Silva

Swiss Army knife of the Practice and Innovation team at Axians Low-Code. https://hfps.dev

Building an Offline Sync for OutSystems Mobile Apps That Survives the Real World

10 min readOct 16, 2026

--

Press enter or click to view image in full size
Watch the demo video here: background-sync-demo.mp4

In my previous article, I mentioned how dynamic forms were one of my favorite problems to solve in software development. There is another one that makes me even more excited: offline data sync. Throughout my career as a Senior OutSystems Developer, I have implemented offline sync for several mobile apps, each one improving on the ones we had built before. I really like building sync processes that work in remote, disconnected places and that users can simply trust.

But every one of those solutions shared the same ceiling. They all ran inside the WebView, and the WebView has limits that no amount of good design could get us past.

That always had me thinking… If other apps can sync data in the background, then why can’t my OutSystems app do it too? Recently, I decided to take on this challenge, and I finally found the answer. These processes need to run outside the WebView.

So I planned and built an open-source background sync engine for OutSystems Mobile, Cordova and Capacitor apps, one that moves the heavy part of the sync to native code. It worked well in my tests. But a plugin that only ever ran on sample apps on my test devices is a hard thing to recommend to anyone.

The opportunity to use it in a real project has finally arrived, and the results were better than I expected.

The Limit We Could Never Get Past

A well-built OutSystems sync goes a long way. You send in batches, you avoid holding every binary in memory, you paginate downloads, chunk binaries to then rebuild them on server side… and for most apps that is enough. We have shipped apps like that, and they work.

But even the best sync we built still ran inside the WebView, and the WebView is the most constrained place in a mobile app to do heavy work:

  • It has its own memory limits, well below what a native process can use, so syncing heavier files, like videos, is a challenge.
  • It runs on a single JavaScript thread, which is also the thread that renders your UI. While a heavy sync runs, the app feels slow.
  • The moment the user minimizes the app or locks the screen, the OS suspends it. Ongoing requests die, and your action flow is paused wherever it was.

This last limitation is the one that bothered me most. Having to explain to a client why the app needs to stay in the foreground until the sync completes never made me feel 100% satisfied with my sync solutions.

Google Photos can back up hundreds of photos with the screen locked because its upload doesn’t run in a WebView. It runs in native code, scheduled by the operating system. That is exactly where the plugin puts the heavy part of the sync.

The Idea: A Native Queue That Doesn’t Care About Your Business

Instead of writing native sync code for every business model (and touching Kotlin or Objective-C every time a local entity changes), the plugin works as a generic queue for both download and upload processes.

  1. Its own database. The plugin keeps a private SQLite database, optionally encrypted with SQLCipher. The key is generated on the device and stored in the Android Keystore or the iOS Keychain. Your OutSystems local storage is never touched.
  2. You enqueue, it delivers. From your app, you call EnqueueUpload with an id, a JSON payload, a relative endpoint and, optionally, a local file path. The plugin stores it and returns.
  3. One call to start. TriggerSync hands the queue to a native worker: WorkManager on Android and a background task on iOS. It sends one item at a time and records the result of each one.
  4. Your server, your rules. Each item is a POST to a REST endpoint you expose in your back office. The plugin adds the headers you configured (an API key, a token, whatever your security model needs), because native code does not share the WebView's session.

Because the heavy lifting happens natively, a few things come for free:

  • Background execution. The upload continues when the user locks the screen or switches apps.
  • Memory stays flat. The WebView only writes a record and a file. The binary never has to be loaded into JavaScript to be sent.
  • Network drops are handled by the OS. On a connectivity failure, Android’s WorkManager reschedules the run for when the device is back online.
  • Wi-Fi only or charging only (Android), if you want to save the user’s mobile data or battery.
  • Native progress notifications, with texts you can translate.
  • Upload heavy assets efficiently to blob storages. If your app needs to sync heavy files, like videos or high-res images, your backend only signs a URL, and the file goes straight to whatever blob storage service you use.

Applying It to a Real Project

The opportunity came from an app that belongs to a global industrial engineering company. Its auditors visit customer sites, fill in long technical audits on a tablet, and attach photos to every finding. The app works offline and syncs everything back to the server when the auditor has connectivity. Photos are most of what it sends, which made it a natural fit for the plugin.

This is the part I was most curious about. It is easy to design a sync engine for a green-field app. It is a different story to add it to an app that is already in production, with years of sync logic you don’t want to rewrite.

We had a short, 1-week development spike. There was no time to rebuild and validate the entire sync process of a very complex app, and no reason to: the record data was already syncing fine.

So the approach I chose was simple: keep the original sync for the record data, use the plugin only for photos, and change as little as possible on the app.

The outbox pattern

My implementation plan was based on the outbox pattern. Screens don’t upload anything. They only mark the local record, and a single step at the start of every sync decides what goes into the queue.

Where the photo needs a parent record that the existing sync hasn’t sent yet, the REST endpoint answers with an error, the plugin marks that item as failed, and it goes out on the next run. Deletions go through the same path: a deleted photo record to be deleted is sent with IsActive = False.

What it took

Press enter or click to view image in full size

When I put together the change report for the team, the numbers surprised me:

  • In the mobile app: two new attributes per photo entity, one “enqueue” action per entity, three small actions in the sync module (initialize, enqueue pending, consolidate), and edits to the save and delete actions. The existing sync got one extra filter so it ignores plugin photos, and the download actions now save photos flagged as “already on the server”. Nothing else in it changed.
  • On the server: one new module with two REST endpoints (form photos and comment photos) and a per-device API key. The endpoints reuse the existing actions that create photos from mobile data.
  • Photos captured before the update needed no separate migration. The new attributes default to False, so the same enqueue step picks them up on the first sync.

Putting It to the Test

A sync engine earns trust in the tests that go past what users normally do. So that is where I spent most of the spike.

The stress test: 336 photos on one audit

I built a single audit with 336 photos, about 418 MB of binaries in the local database, on a Samsung Galaxy Tab S9 Ultra. That is far more than a typical audit, on purpose.

Get Henrique Silva’s stories in your inbox

Join Medium for free to get updates from this writer.

This is exactly the kind of volume where a sync that runs in the WebView hits its memory limit and cannot complete the run. With the plugin, the same audit on the same tablet synced from start to finish. It took a few minutes, as expected for that much data, and the app stayed responsive the whole time, because the WebView never held the photos.

Photos captured before the update were part of that run too. The first sync with the new version queued them with everything else, without any migration step.

Background on Android

This is the result I had been waiting years to see in a hybrid app. On Android, I could minimize the app, lock the screen, leave it in the background for a long time, or even close it, and the sync kept going. When I cut the connection in the middle of a run, WorkManager picked it up again as soon as the device was back online, without the app being open.

Background on iOS

With the app in the foreground, iOS behaves exactly like Android. In the background, iOS is stricter: in my tests, a background task gets a short window, around 30 seconds, and closing the app stops the queue. Nothing is lost. The next sync continues from the item where it stopped.

It’s the same limitation apps like Google Photos face on iOS. They ask you to keep the app open for long processes. At least now we are on par with them.

Volume and file size

Across my tests, I synced over 1 GB of data through the plugin: in the background on Android, and with the app in the foreground on iOS.

I also tested how far a single request can go. The default REST_PAYLOAD strategy handled single images or files up to 10 MB without trouble. Above roughly 20 MB it started to struggle: a 70 MB payload went through on iOS, but slowly, and failed on Android. For files that size, the PRESIGNED_URL strategy sends the file straight to blob storage instead.

How Is It Different From Existing Options?

When a sync requirement shows up in an OutSystems project, we usually look at the built-in options first, or try a standard Capacitor plugin:

  • OutSystems offline sync (TriggerOfflineDataSync). It runs your client action flows inside the WebView. For light data it is the right choice. For heavy data it shares every limit described above, and it stops when the app is suspended.
  • @capacitor/background-runner. It gives you a headless JavaScript environment for background work. But you still write all the queueing, state tracking and retry logic yourself, and it has no knowledge of the OutSystems local database.
  • @capacitor/file-transfer. It uploads and downloads files while the app is open. There is no persistent queue, no retry orchestration, and no background execution.

The Background Sync Plugin is focused on one job: keep a persistent queue on the device and deliver it natively, one item at a time, retrying when the network comes back.

The Road to a Decoupled Queue

Building the plugin for a plain Capacitor app was relatively straightforward. OutSystems was where I hit the interesting problems.

My first plan: read the OutSystems database directly

My initial idea was simple: find the OutSystems SQLite database on the device, read the pending records straight from it, send them, and mark them as done. No extra queue, no extra API. It led me straight into three problems.

1. Auto-discovery was fragile. OutSystems database files are named with generated UUIDs, and physical table names carry prefixes (like OSUSR_xyz_Local_SyncQueue). I built a crawler to find the right file and tables. It worked, but any change in how OutSystems generates the native app could break it.

2. Concurrent writes caused deadlocks. The OutSystems SQLite database uses rollback journal mode, which does not handle concurrent writes from separate connections well. When the native worker updated a record status, the app’s own database worker lost track of its transactions until the app was restarted. The workaround (read-only access natively, results stored in SharedPreferences/NSUserDefaults, then written back by JavaScript) worked for uploads but made downloads impractical.

3. Encrypted databases were out of reach. OutSystems can encrypt local storage, and a native crawler has no way to get that key. The apps that need this plugin the most, the ones handling sensitive field data, are exactly the ones most likely to be encrypted.

I realized I was fighting the platform instead of working with it.

The solution: a queue of its own

The plugin now owns its database and exposes a small API. Your app decides what to send and when. The plugin decides how. Since the database belongs to the plugin, encryption became easy too: the passphrase is generated on first run and kept in the Keystore or Keychain.

That separation is also what made the audit app integration small. The plugin didn’t need to know what an audit, a comment or a photo was. It only needed a GUID, a payload, an endpoint and a file.

Things to Keep in Mind

  • One item at a time is a feature. The queue is sequential, so you decide the order of the sync and can guarantee that a record is only created after the one it depends on. The trade-off is that a very large file delays everything behind it.
  • Payload uploads are for smaller requests. Use REST_PAYLOAD for files up to about 10 MB and PRESIGNED_URL for anything heavier, as the tests above showed.
  • No built-in authentication. The plugin sends the headers you give it at initialization, so you choose the scheme (an API key per device, tokens, and so on). If you use short-lived tokens, refresh them in the foreground and initialize again.

When to Use It (and When Not To)

Use it when:

  • Your app syncs photos, documents or other heavy payloads, and real-world volumes push the sync past what the WebView handles well.
  • Users need to press “Sync”, lock the phone and walk away.
  • The network is unreliable and uploads must resume without the user doing anything.

Don’t use it when:

  • Your app only syncs small text records that finish in a second or two. The built-in OutSystems sync handles that well.
  • Your target is a PWA. The plugin needs a native Cordova or Capacitor shell.

Available on the Forge

This project taught me that hybrid apps don’t have to give up on background work. Through Cordova and Capacitor plugins we have access to the same native APIs as any other app, and for heavy sync we should use them.

The plugin is open source, and it is now available on the OutSystems Forge, together with an implementation and support guide that walks through the outbox pattern, the REST contract and the authentication model described here.

--

--

Written by Henrique Silva

Swiss Army knife of the Practice and Innovation team at Axians Low-Code. https://hfps.dev