MobiWeb Help

Offline Support and Write Queue (Android)

What your web app gets for free when MobiWeb's offline support is enabled, what it must do to keep it safe, and the optional AndroidOffline bridge for writes made while offline.

Offline support is a build-time option of the Android app (OFFLINE_ENABLED). When it is off, nothing on this page applies and AndroidOffline is not injected. iOS has no offline support yet (no AppleOffline). Setup is in Offline Support; design, limits and the native side are in the Technical Design.

const hasOfflineQueue = typeof AndroidOffline !== 'undefined';

What happens without any code change

MobiWeb registers a service worker on your origin (/__mobiweb_offline_sw.js, served by the app, not by your server) and keeps a network-first cache of GET responses:

Request

Behaviour

Same-origin GET (HTML, JS, CSS, images, API JSON)

Network first; the last 200 is stored and served only when the network fails.

Cross-origin GET for script/style/font/image (CDN assets)

Same, incl. opaque (no-cors) responses.

Cross-origin API calls, any non-GET, Range, audio/video, event streams, downloads (Content-Disposition: attachment)

Untouched. Never cached.

Anything with Cache-Control: no-store (response header, request header or fetch(url, {cache: 'no-store'}))

Never cached. This is your per-endpoint opt-out. One exception: your app shell (the start page or /, when served as HTML) is stored anyway, because many apps send no-store on index.html only to force fresh deploys and nothing could launch offline without it. It is used only when the network fails.

Redirected responses, non-200, bodies over 10 MB

Never cached.

Navigations while offline fall back, in order, to: the exact URL → the app's start URL → the last document that loaded successfully → /. Your client-side router therefore boots on any deep link with the shell document and must handle that route itself. A navigation that takes longer than 8 s on a bad connection is also served from the cache (and the late response is still stored for next time).

On the first page load after the service worker is installed, MobiWeb warms the cache with the current page, its same-origin page assets, and the start URL, so one online session (logged in) is enough for an offline cold start of the pages the user visited.

Things to check on your side:

  • Same-origin API. Only same-origin API responses are cached. If your API lives on another host, only the shell works offline.

  • Response headers. Frameworks like Spring Security send Cache-Control: no-store on every authenticated response by default. (no-store on the start page HTML itself is fine: the shell exception above covers it. Other HTML pages marked no-store are not cached; deep links into them boot from the shell.) MobiWeb honors it, so such endpoints are never available offline. Decide per endpoint.

  • Your own service worker. If the page registers one (any scope, including a push SW), it is respected: MobiWeb's is not registered, an already-installed MobiWeb worker is unregistered and its cache deleted, and your worker's network behaviour is not altered. Only the browser's HTTP cache remains as an offline fallback for the page. If you register your SW late, MobiWeb's may control the very first session; it is replaced or removed by the next load.

  • CSP. worker-src 'self' (or script-src 'self') must be allowed for /__mobiweb_offline_sw.js.

  • Code splitting. Lazy chunks of routes the user never visited are not cached; offline, such a route renders the shell and then hits your chunk-load error path. Keep the previous build's assets on the server for a while, as for any SPA deploy.

  • Token auth. Cookie sessions work offline as is. If you validate or refresh a bearer token on boot with a POST or a no-store GET, that call fails offline; don't log the user out on a network error.

  • Logout (required). Call AndroidCookie.clearCookies(...) (or use the configured redirect-logout URL). Both also wipe the offline cache and the write queue. A logout that only does POST /logout + localStorage.clear() leaves the previous user's cached API responses servable offline to the next user of a shared device. As a minimum, call AndroidOffline.clearCache() and AndroidOffline.clear().

Detecting whether MobiWeb's worker is active:

const sw = navigator.serviceWorker?.controller; const mobiwebOfflineActive = !!sw && sw.scriptURL.endsWith('/__mobiweb_offline_sw.js');

Connectivity event

Event: mobiweb:connectivity-> { online: boolean }

Fired on every change of the device's default network (Android system connectivity, not reachability of your server). navigator.onLine keeps working too.

window.addEventListener('mobiweb:connectivity', (e) => { store.dispatch(setOnline(e.detail.online)); });

Write queue

Writes made while offline are not replayed automatically: MobiWeb can't know what a failed POST means to your app. Queue them yourself through the bridge; MobiWeb persists them (encrypted, surviving restarts) and replays them with the user's cookies when the device is back online.

Bridge object: AndroidOffline

AndroidOffline.enqueue(requestJson)

Field

Type

Description

url

string

Absolute or relative; resolved against the app's start URL. Must be on the app origin (same scheme, host and port).

method

string

POST (default), PUT, PATCH or DELETE.

headers

object

Optional. Cookie, Host and Content-Length are ignored (managed natively). No CR/LF.

body

string

Optional. Text only (JSON, form-encoded…), max 1 MB. No binary or multipart.

maxAttempts

number

Optional, default 10. Counts responses from the server (5xx), not network failures.

meta

any

Optional JSON, max 4 KB. Stored and echoed unchanged in the item's events: use it for your local record id.

Returns a JSON string: {"ok":true,"id":"…"} or {"ok":false,"error":"…"}. Errors include a non-app origin, an unsupported method, oversized body/meta, and Queue full (max 500 items). Only accepted while the app page itself is loaded in the main frame.

Replay rules:

  • Strict FIFO, one item at a time.

  • 2xx → item removed → mobiweb:offlineQueueItemSent.

  • 3xx or 4xx → item removed → mobiweb:offlineQueueItemFailed. Redirects are not followed: a 302 to the login page means the session expired and the write was not applied.

  • 5xx → kept, counts as an attempt, replay stops and retries with backoff (5 s doubling to 5 min).

  • No response (network error) → kept, not counted, same backoff.

  • maxAttempts reached → removed → mobiweb:offlineQueueItemFailed.

  • Replay runs automatically when connectivity returns and on the first app page load after the app starts. Results for items replayed while no app page was loaded are delivered as soon as one is.

  • Replays carry the WebView's cookies and send Origin/Referer as the app origin and X-Requested-With as the app's package name, like a normal in-app request.

AndroidOffline.list()

Returns a JSON string: array of queued items { id, url, method, headers, body?, meta?, attempts, maxAttempts, createdAt }, oldest first. Use it on boot to show pending work.

AndroidOffline.remove(id)

Returns boolean. Drops a queued item; if it is being sent at that moment, the request is aborted.

AndroidOffline.flush()

Replays now, cutting any pending backoff short. Normally not needed.

AndroidOffline.clear()/AndroidOffline.clearCache()

Empty the write queue / delete the offline response cache. Both also happen on AndroidCookie.clearCookies(...).

AndroidOffline.isOnline()/AndroidOffline.getStatus()

isOnline() returns boolean. getStatus() returns a JSON string { online: boolean, serviceWorkerConflict: boolean, queueLength: number }; serviceWorkerConflict is true when your own service worker prevented MobiWeb's from registering.

Events

Event

Detail

When

mobiweb:offlineQueueItemSent

{ id, status, attempts, body?, meta? }

Server answered 2xx. body is the response text (max 64 KB).

mobiweb:offlineQueueItemFailed

{ id, status, attempts, body?, error?, meta? }

3xx/4xx, maxAttempts reached, or an unsupported method (status: 0).

mobiweb:offlineQueueFlushed

{ remaining }

A replay pass ended (queue empty, offline again, or waiting for backoff).

Example

async function saveNote(note) { const init = { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(note), }; try { return await fetch('/api/notes', init); } catch (networkError) { if (typeof AndroidOffline === 'undefined') throw networkError; const res = JSON.parse(AndroidOffline.enqueue(JSON.stringify({ url: '/api/notes', method: init.method, headers: init.headers, body: init.body, meta: {kind: 'note', localId: note.localId}, }))); if (!res.ok) throw new Error(res.error); markPending(note.localId, res.id); return {queued: true, id: res.id}; } } window.addEventListener('mobiweb:offlineQueueItemSent', (e) => { const {meta, body} = e.detail; if (meta?.kind === 'note') markSynced(meta.localId, JSON.parse(body)); }); window.addEventListener('mobiweb:offlineQueueItemFailed', (e) => { const {meta, status, body} = e.detail; if (meta?.kind === 'note') markFailed(meta.localId, status, body); if (status === 302 || status === 401) promptReLogin(); }); // On boot: reconcile local state with what is still queued. if (typeof AndroidOffline !== 'undefined') { const pending = JSON.parse(AndroidOffline.list()); pending.forEach((item) => markPending(item.meta?.localId, item.id)); }

Design your queued writes to be idempotent where possible (e.g. a client-generated id in the body): a request can be retried after a 5xx or a connection drop, and an item whose result could not be recorded locally may be sent again after the app process is restarted.

Testing from the web side

  1. Build the app with OFFLINE_ENABLED=true and open chrome://inspect on a desktop Chrome.

  2. Application → Service Workers: /__mobiweb_offline_sw.js must be active for your origin; Cache Storage → mobiweb-offline-v1 fills while you browse.

  3. Airplane mode, then cold-start the app: the pages and API data visited before must render; endpoints you marked no-store must not.

  4. In the console: AndroidOffline.enqueue(JSON.stringify({url: '/api/ping', method: 'POST', body: '{}'})) while offline, then disable airplane mode and watch the mobiweb:offlineQueue* events.

18 October 2026