Base44 Offline Mode: Service Workers and Local Data
Base44 offline mode done properly: a service worker in /public, an IndexedDB mirror of your entities, and a write queue that syncs on reconnect.

Search for a command to run...
Base44 offline mode done properly: a service worker in /public, an IndexedDB mirror of your entities, and a write queue that syncs on reconnect.

No comments yet. Be the first to comment.
Lovable offline mode done properly: service worker caching, an IndexedDB mirror of your database, and a write queue that syncs when you reconnect.

A slow Lovable mobile app that reloads on every page change is not a WebView problem. It is SSR in TanStack Start, and you can turn it off.

How to convert a Base44 app to an iOS app and publish it to the App Store: the build, the guidelines that cause rejections, and the fixes.

How to convert a Base44 app to an Android app and publish it to Google Play, including the 12-tester rule and the deadlines that block uploads.

Despia Blog: Build Native iOS & Android Apps from Web Code
134 posts
Most people try this once, in the wrong order. They flip Offline Support to PWA in Despia, rebuild, put the phone in airplane mode, and the app opens to the native network error page. Or it opens, shows the shell, and then spins forever on an empty screen. Or worse: it works offline, and now none of their updates reach users ever again.
Two things are going on. Caching the app and running the app offline are separate problems, and a service worker that solves the first one badly will quietly break your deployments. A worker keeps your HTML, JavaScript and CSS available without a network. It does nothing for the entity reads, backend functions and third party APIs your app calls the moment it boots. And if it answers every request from its stored copy, your users are pinned to whatever version they installed first.
A service worker only controls the routes inside its own scope, so it has to be served from the root of your domain to control the whole app. Base44 serves files from a public folder at the root of your published app, so the worker goes at public/sw.js and is reachable at https://your-app-domain.com/sw.js. Put offline.html and your web app manifest in the same folder.
Confirm that before you write a line of caching logic. Open https://your-app-domain.com/sw.js directly in a browser. You want your JavaScript, served as JavaScript. If you get your app's HTML instead, the path is falling through to the single page app rewrite, the file is not at the root, and every registration attempt fails with a content type error that reads like a code problem and is not one.
Registering from a blob URL is not an alternative. register() requires an http or https script URL, so a blob is rejected outright.
In the Despia Editor, go to Settings > Configuration and scroll to Dynamic App Source. That is where your Start URL points at your published Base44 app, and directly underneath it is Offline Support, with three options: none, PWA, and native. Select PWA. Then bump the version code and publish a new build for iOS, Android, or both.
That toggle provisions the Xcode and Android Studio projects to allow service workers in the native binary, without you touching either. Done manually, this is registering app bound domains on iOS and the equivalent on Android, in native project settings, which is not a path a non-native developer wants to walk. Here it is one setting and a rebuild.
What it does not do is write a service worker. If your Base44 app does not ship one, or ships one that never caches anything, the toggle changes nothing and every offline launch fails exactly as before. Despia removes the native obstacle. The caching strategy is yours, in your web codebase, using standard web APIs.
The other thing worth saying out loud, because it costs people an afternoon: offline support is a native runtime setting, so it only exists in binaries produced after the toggle was flipped. If you enable it and keep testing the build already on your phone, service worker registration is silently ignored, caches stay empty, and the Editor still reads as on. Install the new build before you conclude anything.
This is the part that decides whether your app can still be updated. Say it as a rule and it becomes obvious:
A bad worker asks "is there a cached copy" and serves it. A good worker asks "am I online", takes the network when the answer is yes, and only reaches for the cache when the network genuinely fails.
Cache-first on navigation is the default in most tutorials and in most AI-generated workers, and it is the single worst choice for an app that updates over the air. You publish in Base44, the content updates on your host, and nobody sees it. Here is the shape that works:
const VERSION = 'v1'
const CACHE = `app-${VERSION}`
const PRECACHE = ['/', '/offline.html']
const NETWORK_TIMEOUT_MS = 3000
// content-hashed build output, safe to cache forever because the name changes
function isHashedAsset(url) {
return /^\/assets\//.test(url.pathname)
&& /\.[0-9a-zA-Z_-]{8,}\.(js|css|woff2?|png|jpg|jpeg|svg|webp|avif|ico)$/.test(url.pathname)
}
self.addEventListener('install', event => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE)
await Promise.allSettled(PRECACHE.map(async path => {
const res = await fetch(path, { cache: 'no-store' })
if (res.ok) await cache.put(path, res)
}))
await self.skipWaiting()
})())
})
self.addEventListener('activate', event => {
event.waitUntil((async () => {
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable().catch(() => {})
}
const names = await caches.keys()
await Promise.allSettled(
names.filter(n => n.startsWith('app-') && n !== CACHE).map(n => caches.delete(n))
)
await self.clients.claim()
})())
})
self.addEventListener('fetch', event => {
const { request } = event
if (request.method !== 'GET') return
const url = new URL(request.url)
if (url.origin !== self.location.origin) return
if (isHashedAsset(url)) {
event.respondWith(cacheFirst(request))
return
}
const isDocument = request.mode === 'navigate'
event.respondWith((async () => {
try {
return await fromNetwork(request, {
preload: isDocument ? event.preloadResponse : undefined,
noStore: isDocument
})
} catch {
return offlineFallback(request)
}
})())
})
async function fromNetwork(request, { preload, noStore } = {}) {
const cache = await caches.open(CACHE)
const attempt = preload
? Promise.resolve(preload).then(res => res || fetch(request))
: fetch(noStore ? new Request(request, { cache: 'no-store' }) : request)
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('network-timeout')), NETWORK_TIMEOUT_MS)
)
const res = await Promise.race([attempt, timeout])
if (res && res.ok && res.type !== 'opaque') {
cache.put(request, res.clone()).catch(() => {})
}
return res
}
async function offlineFallback(request) {
const cache = await caches.open(CACHE)
return (await cache.match(request))
|| (request.mode === 'navigate'
? (await cache.match('/')) || (await cache.match('/offline.html'))
: undefined)
|| Response.error()
}
Four details in there are not optional.
The timeout matters more than it looks. A hanging connection does not reject fetch(), it sits there, so a plain fetch().catch() fallback stalls on a bad train connection instead of falling back. Race it.
cache: 'no-store' on document fetches bypasses the browser's own HTTP cache and any CDN copy. Without it, the worker can go to the network, do everything right, and still be handed a stale HTML response, which looks identical to a broken worker and sends you debugging the wrong file.
Navigation preload starts the document request in parallel with worker startup, which removes the roughly one second delay you will otherwise see on the document in the network tab.
And the offline fallback is a chain, not a single file: the last cached copy of that exact page, then the app shell, then /offline.html. Make /offline.html render something useful, because it is the last thing standing between a dropped request and a blank screen.
There is no stack question to answer here. Every Base44 app is React and Vite, built to a static frontend with an index.html rewrite for routes, which makes two things true. Hashed build output lands under /assets/, so the test above is the right one. And every route resolves to the same document, so precaching the shell precaches every screen in the app rather than one page at a time.
Registration matters as much as the worker. updateViaCache: 'none' stops the browser reusing a cached copy of sw.js itself, which is how a broken worker becomes permanent. Then check for updates on load, on focus, on reconnect, and reload when control changes hands:
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none'
})
let reloading = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return
reloading = true
window.location.reload()
})
const checkForUpdate = () => void registration.update().catch(() => {})
window.addEventListener('focus', checkForUpdate)
window.addEventListener('online', checkForUpdate)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') checkForUpdate()
})
checkForUpdate()
Skip registration entirely in the Base44 editor preview, and unregister anything already installed there. The preview is not your deployment, and a worker installed against it will fight the editor's live reload and confuse every test you run afterwards.
Do not hand-write a precache list of hashed filenames. The build renames them every time, so a list written today points at files that will not exist next week, install fails, and the worker never activates. Precache the shell and the offline page, and let everything else fill in as it is visited.
Here is what turns a working service worker into a broken app. Your cached shell boots. It renders. Then it does what Base44 wrote it to do, which is call your backend.
Every base44.entities.Todo.list() is an HTTPS request to a server on the other side of the planet. So is base44.auth.me(), so is every backend function you invoke, every AI call, every payment. With no network there is no server to talk to, and a cached page that renders a spinner until a query resolves will spin until the request times out and then show an error, if you wrote one.
The mental model has to change. You are not fetching data, you are reading data that is already on the device and refreshing it when you can.
| What the user does offline | Works from cache alone | Needs a local data layer |
|---|---|---|
| Opens the app, sees the shell and navigates | Yes | No |
| Loads JS, CSS and previously visited images | Yes | No |
| Reads their existing records | No | Yes, mirrored into IndexedDB |
| Creates or edits a record | No | Yes, optimistic write plus outbox |
| Runs a backend function, AI call or payment | No | No, disable and explain |
| Stays signed in | Only if you cached the user | Cache the user object locally |
| Sees another user's changes | No | No, resync on reconnect |
So: on first load, while online, pull the records this user needs and write them into IndexedDB along with the current user. From then on, read local first, render immediately, then refresh from Base44 in the background and merge. That is one code path that feels instant on a good connection and keeps working on none.
Use IndexedDB for records. localStorage is fine for a token and a small user object, and wrong for lists: synchronous, string-only, and small enough that a few hundred rows hit the wall. Keep entity responses out of the Cache API too. That cache is keyed by URL with no notion of who is signed in. Mirror rows into IndexedDB instead, where you control the shape and the lifetime.
Every offline write is two operations. Update the local copy so the UI responds immediately, and record the intent so it can be replayed later. Keep three IndexedDB stores for this: the mirrored entity, an outbox of queued jobs, and tombstones for deletes.
async function saveTodo(todo) {
const row = { ...todo, updated_at: new Date().toISOString(), pending: true }
await localTodos.put(row)
await outbox.add({ type: 'create', row })
flushOutbox()
return row
}
async function runJob(job) {
const { pending, ...payload } = job.row || {}
if (job.type === 'create') return base44.entities.Todo.create(payload)
if (job.type === 'update') return base44.entities.Todo.update(job.id, payload)
return base44.entities.Todo.delete(job.id)
}
async function flushOutbox() {
if (!navigator.onLine) return
for (const job of await outbox.all()) {
try {
await runJob(job)
await outbox.remove(job.key)
await settleJob(job)
} catch (error) {
// never reached the server, keep it queued and try again later
if (isNetworkFailure(error)) return
// server said no, retrying will not change that
await outbox.remove(job.key)
await revertJob(job)
reportSyncError(error)
}
}
}
window.addEventListener('online', flushOutbox)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') flushOutbox()
})
Five details separate a sync layer from a data loss bug.
Strip your local-only fields before sending. A pending flag that reaches the entity is a field the schema does not have, and the write is rejected. You will spend an hour blaming the queue.
Separate a transport failure from a server rejection. A dropped connection should keep the job queued. A write your entity permissions refuse will never succeed, so drop it, undo the local optimistic change, and tell the user. An offline write is a request, not a guarantee, and the UI should say pending until the server has accepted it.
Flush on visibilitychange as well as the online event. A phone that was asleep in a lift comes back through resume, and does not always fire a connectivity event.
Queue deletes explicitly and keep a tombstone until the delete lands, or the row reappears on the next pull.
For conflicts, compare updated_at and let the newer write win, with one exception: a pending local write always wins over the server copy, because it has not been sent yet and dropping it would lose the user's work.
base44.auth.me() is a network call. If your app awaits it before rendering anything, an offline cold start hangs on it: the promise never resolves, the loading state never clears, and the user sees a spinner or a login screen even though they are signed in.
Cache the user object locally on every successful call, render from that copy when you are offline, and revalidate when the network returns.
function loadUser() {
try {
return JSON.parse(localStorage.getItem('user'))
} catch {
return null
}
}
async function boot() {
const user = loadUser()
if (!navigator.onLine) {
return user ? renderApp(user) : renderOfflineNotice()
}
const fresh = await base44.auth.me()
localStorage.setItem('user', JSON.stringify(fresh))
renderApp(fresh)
}
window.addEventListener('online', boot)
boot()
Treat that cached user as a rendering hint, not as authorization. Anything sensitive is verified server side on the next successful request, and the local mirror should only ever hold data that user was already allowed to read.
If you want the offline state to look different inside the native app than in a browser tab, the environment check is the user agent:
const isDespia = navigator.userAgent.toLowerCase().includes('despia')
This is the part that saves the money. Service workers, the Cache API and IndexedDB are web platform APIs. The Despia runtime runs the same engine your browser runs, so it does not add behaviour to them and it cannot repair them. Works in the browser, works in the PWA, works in the native build. Fails anywhere earlier and it fails everywhere later, except that in the binary the failure hides behind a native error screen and looks like a runtime bug.
So the order is fixed. Step one, prove updates still reach users. Step two, prove the web app works offline as an installed PWA. Step three, prove the native build works offline. People who jump to step three burn hundreds of dollars in AI tokens debugging something they could have excluded in ten minutes.
Step one: prove over-the-air updates still work, online.
Do this before you test offline at all, because a worker that caches beautifully and never updates is worse than no worker.
Publish from Base44 and open the published URL, not the editor preview. Open developer tools, two finger click and choose inspect if you do not have the menu, and enable developer tools in browser settings if the option is missing. Go to the network tab and reload. The gear icon next to a request means it was served by the service worker. Check that your CSS is in there too, not only the HTML and JavaScript, or the app will render as a mess offline. Screenshot that tab and paste it into Base44 with a line like "here is the network tab from the dev tools, make sure the full app experience is cached". Being proactive here is much cheaper than debugging from inside the failure.
Ignore requests injected by browser extensions. They show up in the network tab, they look like your app failing to cache something, and they are not.
Then change something obvious and publish. The theme colour is the crudest and fastest signal: make it pink, publish, reload. If the colour does not change, the worker is corrupt, and no amount of offline testing means anything yet. Escalate through all three kinds of change before you move on: CSS styling, then HTML structure, then JavaScript logic, since a worker can pass one and fail the next.
You will trap yourself, and that is normal. The first bad worker you ship installs itself on your own machine, and from then on your browser keeps serving the old copy no matter how many times you fix the code and reload. Adding a ?cachebust=123 query parameter sometimes works. Opening a 404 route sometimes works. Often neither does. The practical answer is a second browser and a private window: Safari when you have been testing in Chrome, then a fresh incognito window each time. Clearing the cache properly is workable on macOS and Windows, awkward on Android, and genuinely painful on iOS. Assume you will need clean sessions and stop fighting it.
When the worker is wrong, tell the AI exactly what you observed. Both of these move it forward:
I made the update to pink and if I reload my app, the update did not go through.
This means the service worker you wrote is corrupted. The service worker needs to load
the latest web code if the user is online and update over the air. If the user is
offline, serve cache, but once online make a new latest copy.
I opened a private Safari window and tested the colour change. The app is still green
and does not show the updates I made, so it is infinitely stuck on old page code, which
means the service worker is corrupted. Please fix it.
Attach the network tab screenshot. Ask for a plan first when you are two rounds in and it is still wrong, so the model reads the whole registration path instead of patching one file. And read the plan before approving it: a common AI escape route is to replace the worker with a cleanup worker that unregisters itself, which does guarantee fresh code and also deletes the offline support you asked for.
Do not take "I tested it and it works" from an AI tool as a result. Their browser testing and your own physical device are not the same thing. Getting a service worker right usually takes several rounds even with good documentation, so plan for the loop rather than a one-shot.
Step two: install it as a PWA and go offline.
Open your published URL on a real phone and add it to the home screen. If it opens with an address bar at the top or a share and reload bar at the bottom, it opened as a browser tab, not a PWA, and you are missing a web app manifest and the Apple specific head tags. Fix that even if you never intend to ship a PWA, because you need it to run this test. If it still opens wrong after the fix, delete it from the home screen, reload the page, and add it again, waiting a couple of seconds before tapping add.
Then use the app online first. Load the screens that matter, sign in, create a record. That is what fills the caches and writes rows into IndexedDB, and a worker with an empty cache is indistinguishable from no worker.
Now turn off Wi-Fi and cellular data separately in system settings. Not airplane mode, since some devices keep Wi-Fi alive in it and the test passes for the wrong reason. Force quit the installed app and open it cold, because a warm relaunch proves nothing. You want to see: the app opens, your offline indicator appears, your existing records are on screen, a new record can be created and shows as pending. Turn the radios back on and watch that record sync.
Step three: the native build.
Only now flip Offline Support to PWA in Despia, bump the version code, publish, and install the new build. Run the same sequence: use it online, kill both radios, force quit, cold launch past the native splash screen, read your data, write a record, reconnect and watch it sync. If steps one and two passed, this one passes, because it is the same web app with the same caches inside a native shell.
The one thing that genuinely differs is the very first launch. A hosted app has to be opened once with a working connection before the worker can install and precache anything, so a user who installs from the store on a plane has nothing to serve.
A worker that is not really at the root. If /sw.js returns your app's HTML rather than JavaScript, the file is not being served from /public the way you think it is. Registration fails on the content type, and the error message points at your code rather than your hosting.
A stale worker that outlives your fix. A cache-first worker keeps serving the old app to everyone who already installed it, including after you fix the worker, because sw.js is itself a file the old worker is caching. updateViaCache: 'none' prevents it. If you are already in that hole with real users, ship a kill switch, serve the worker path with no-cache headers, and let it run once.
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(keys.map(k => caches.delete(k))))
.then(() => self.registration.unregister())
.then(() => self.clients.claim())
)
})
A cached document pointing at deleted assets. If the HTML is cached and the hashed JavaScript it references is not, the app opens to a white page. Network-first documents plus a versioned cache prevents it.
Testing in the Base44 editor preview. The preview is not your deployment. Scope, headers and worker behaviour only mean anything on the published URL that Despia actually loads.
Debugging the native build first. Almost every reported offline failure is a worker that never cached anything, and the binary is the worst place to find that out.
Paste this into Base44. It covers the worker, the local mirror and the queue together, because asking for offline support alone reliably produces a cache-first worker and nothing else. Expect to iterate anyway.
Add offline support to this app. Read https://setup.despia.com/native-features/service-workers first.
This app is shipped as a native iOS and Android build with Despia, and the runtime allows a standard
service worker.
Service worker, always fresh online, cache only as an offline fallback:
- Put the worker at public/sw.js so it is served from the root of the published app, and register it
with scope '/' and updateViaCache: 'none'. Put offline.html and the web app manifest in public/ too.
- Documents and non-hashed files: network-first with a 3 second timeout and cache: 'no-store' on the
document request, so no browser or CDN copy shadows a new build. Enable navigation preload.
- Content-hashed build output under /assets/: cache-first, safe because the file name changes on every
build.
- Offline fallback chain: last cached copy of that page, then the app shell, then /offline.html.
- Do NOT use cache-first for navigation. It pins users to an old build and breaks over-the-air updates.
- Do NOT hand-write a precache list of hashed filenames. Precache only '/' and '/offline.html'.
- Version the cache name, delete every non-matching cache on activate, skipWaiting on install,
clients.claim on activate.
- Call registration.update() on load, focus, online and visibilitychange, and reload the page on
controllerchange so a new worker takes effect immediately.
- Do not register the worker in the Base44 editor preview, and unregister any worker found there.
- Add a web app manifest and Apple specific head tags so the app installs as a real PWA.
Local data layer, because the backend is unreachable offline:
- Mirror the entities this app reads into IndexedDB. Read from IndexedDB first and render from it, then
refresh from Base44 in the background and merge.
- Never block first render on a network call. No await on an entity read or base44.auth.me() before
something is on screen. Cache the user object locally and render from it when offline.
- Writes are optimistic: update the local row with a pending flag and queue a job in an IndexedDB outbox.
- Flush the outbox on the window 'online' event and on visibilitychange. Strip local-only fields such as
pending before sending. Keep a job queued on a network failure. On a server rejection, drop the job,
revert the local change and surface an error, since a permissions failure never succeeds on retry.
- Queue deletes explicitly with a local tombstone so deleted rows do not come back on the next pull.
- Resolve conflicts on updated_at, newest write wins, except that a pending local write always wins.
- Show a clear pending or offline indicator in the UI.
- Disable online-only features (backend functions, AI calls, payments, email) with an explanation rather
than letting them fail silently.
Do not add a PWA plugin that generates a cache-first worker.
Then enable it on the native side: Settings > Configuration > Dynamic App Source > Offline Support > PWA, bump the version code, and publish a new build.
Take the app you just built and ship it to iOS and Android without a CLI or a Mac. Code signing and submission run from the browser.