Offline Support: Technical Design
Can MobiWeb make a remote SPA / PWA work offline without modifying the web app? This page records the investigation, the verdict per platform, and the Android implementation (gated by OFFLINE_ENABLED, default false). It is written for MobiWeb maintainers. For setup, start with Offline Support; web-app developers should read Offline Support and Write Queue (Android).
Verdict
Capability | Android | iOS |
|---|---|---|
Offline launch + last-seen pages / GET data | Yes, no web changes (Layer A) | No parity (see iOS) |
Offline fallback when the web app has its own SW | Partial, via HTTP cache (Layer C) | No (SWs disabled in this WKWebView) |
Offline UX (auto-retry on reconnect) | Yes | Not implemented (no |
Offline writes | Opt-in | Not implemented |
Reads can be transparent; writes cannot. MobiWeb can't know what a failed POST means (idempotency, conflicts, what the UI should show), so replaying writes needs the web app's cooperation. The write queue is therefore an opt-in bridge: the web app has to call it.
Preconditions on the web app and backend
"No web-app changes" holds for the code, but the result depends on how the app is deployed. Check these before expecting data offline:
Precondition | If it doesn't hold |
|---|---|
The API is same-origin with the page ( | Cross-origin API responses are never cached (only cross-origin |
Reads are | Frameworks such as Spring Security emit |
The web app has no service worker of its own (any scope, incl. a push SW). | Layer A steps aside; only Layer C (HTTP cache) remains. |
| A host redirect (e.g. to |
Logout goes through | An SPA that logs out with only |
Route-level code splitting is tolerated. | Lazy chunks of never-visited routes are not cached: offline, such a route boots from the shell and then fails with the SPA's own chunk-load error, not a graceful fallback. |
Session auth is cookie based, or the token flow tolerates failures. | Bearer-token SPAs keep the token in local storage, but one that validates/refreshes it on boot via |
The page CSP allows |
|
Also note: navigator.onLine reflects system connectivity, so an SPA that gates on it shows its own offline UI (fine). A boot "health" GET is served stale from the cache, so the SPA may believe it is online.
Alternatives considered
Native tee-cache in
shouldInterceptRequest(re-issue every GET natively, cache to disk). Rejected:WebResourceRequestexposes no request body or response cookies, so native would have to re-implement cookies (second cookie jar +Set-Cookiewrite-back), CORS/preflight, redirects,Range/206 and streaming, lose HTTP/2 connection reuse, and block an IO thread per request. Its only advantages (works without SW support, next to a foreign SW, and could cache cross-origin API responses) don't justify that.Cache-mode toggle only (Layer C alone). Under-credited but insufficient:
LOAD_CACHE_ELSE_NETWORKdoes apply to XHR/fetch and ignores freshness, but it fails onno-store, on never-cached routes, on Chromium's own eviction and onVary. Layer A adds deterministic storage of 200s regardless of HTTP cache headers, the SPA route fallback, the lie-fi guard and an explicit wipe. Onno-storeAPIs the two layers converge: neither helps.Local HTTP proxy via
ProxyController. Rejected: HTTPS isCONNECT-tunnelled, so the proxy sees ciphertext unless the app MITMs itself with an installed CA. Unacceptable for medical data.Bundling the SPA into
www/. Not pursued: it would solve shell offline on both platforms (and is the only route to iOS parity without App-Bound Domains), but it changes the product model (remote SPA, build pipeline, app-store releases per SPA change) and makes the API cross-site fromcapacitor://localhost(SameSite cookies, CORS). Listed as the "relax the constraint" option.WebViewAssetLoader(bundled assets only), Chromium flags (not settable in production), Capacitorserver.url(changes Capacitor's notion of the app URL, adds no caching) and WebView HTTP-cache tuning (no public API): not applicable.
Why the old service worker never worked
www/service-worker.js (now removed) lived on the local shell origin (https://localhost/capacitor://localhost). It was never registered, and even if it had been, a service worker only controls its own origin. The real app loads from the remote origin (INITIAL_URL/RemoteContentURL), so the bundled SW could never see its requests. It also precached paths that don't exist locally, so install would have failed.
Android design
Layer A: injected service worker
The remote site never hosts a SW, so MobiWeb serves one for it from the same origin:
OfflineManager.interceptRequestanswershttps://<app-host>/__mobiweb_offline_sw.jswith the bundledandroid/app/src/main/assets/mobiweb/offline-sw.js. It does this through bothWebViewClient.shouldInterceptRequestandServiceWorkerController's client, because SW script and update fetches can bypass the WebViewClient. Every other request passes through untouched.After each app-origin page load, an injected shim (
assets/mobiweb/register-sw.js) registers it withscope: '/'.The browser itself runs the SW. Cookies, redirects, CORS and
fetch()therefore behave exactly as in a browser, and the origin never changes.
Warm-up. The SW registers only after the first page has loaded, so it never saw that page's document or bundles. To make the first online session enough for an offline cold start:
activatecaches the start URL.The shim then posts the current page plus its same-origin page assets (from
performance.getEntriesByType('resource'),script/link/css/img/fontinitiators only) to the SW asmobiweb:warm. Every warm list also retries the start URL.
Cross-origin CDN assets are not warmed; they are cached the next time the page requests them.
If the start URL redirects for a logged-out user (e.g. to /login), the redirected response is never stored, so nothing is cached until the first logged-in full page load of the app. A login that only changes the SPA route (no reload) does not count; the next warm list, posted on the next page load, picks the start URL up once it answers 200.
SW caching rules (cache mobiweb-offline-v1):
GET only, network-first, falling back to the cache when the network fails. A navigation waits at most 8 s for the network before it serves a cached copy (lie-fi guard). This is a behaviour change while online too: on a very slow network the user runs the cached document (and the bundle set it references) for that session; the late network response is still stored for the next load.
Cached: same-origin navigations and any same-origin GET (HTML, JS, CSS, images, API JSON). For cross-origin requests, only the static destinations
script/style/font/image(CDN shell assets), including opaque responses.Never cached:
Cache-Control: no-store, whether on the response, the request header, orfetch(…, {cache:'no-store'}). One exception, the app shell: the start page or/, when the response is HTML, is stored even if markedno-store. Many SPAs sendno-storeonindex.htmlonly to force fresh deploys, and without a stored shell nothing can launch offline (found testingdemo.realworld.show). It is scoped to those two URLs because other HTML pages may be server-rendered with user data, and like everything else it is served only when the network fails.non-200 responses, and redirected responses (Chromium rejects a redirected response served to a navigation)
Rangerequests, audio/video, event streams (AcceptorContent-Type: text/event-stream),Content-Disposition: attachment, and bodies over 10 MB (byContent-Length)/__mobiweb*and/_oauth_callback
Offline navigation fallback: exact URL → cached start URL → last successfully loaded document → cached origin root
/(lets SPA client routing boot on any route) → network error, at which point the native error overlay takes over. Fallbacks ignoreVary, so a server sendingVary: Acceptstill matches. The last-loaded document is kept under the synthetic key/__mobiweb/last-navigationand covers anINITIAL_URLwhose path redirects (/→/doctor): that navigation yields anopaqueredirectthat can never be stored.Eviction, oldest first: at most 500 entries, at most ~100 MB by
Content-Length(unknown sizes count as 0), and at most 50 opaque (cross-origin, uninspectable) entries, which Chromium pads heavily in its quota accounting. Trimming runs every 25 stores and on every SW activation. Cache Storage shares the origin's quota with the SPA's own IndexedDB / localStorage.The store work is registered with
waitUntilsynchronously in the fetch handler. A slow network response that arrives after the lie-fi timeout has already served the cached copy is therefore still cached.
Coexistence:
If any registration has a worker that isn't ours, whether
active,waitingorinstalling, the shim does not register ours (theirs wins), and it reports the conflict throughAndroidOffline._swConflict.The
waiting/installingcheck matters: re-registering ours over a web-app worker waiting behind it would replace that worker every time, so it could never activate.If the web app registers a SW at
/later, the browser replaces ours, and the shim stays out of the way from then on.On a conflict, ours steps aside completely:
Registrations made up only of our worker are unregistered. For example, ours at
/registered before the web app's at a narrower scope would otherwise keep controlling the rest of the origin.mobiweb-offline-v1is deleted.A registration that also holds a web-app worker is left alone, because unregistering it would remove theirs too.
The conflict is persisted (
sw_conflict). While it is set, native leavesServiceWorkerWebSettingsonLOAD_DEFAULT, because that setting is process-wide and would change the web app's own worker'sfetch()behaviour while offline. Only the WebView-level fallback (Layer C) applies.
Remaining window: if the web app registers its SW only after our shim ran on the very first load, ours controls that first session; the browser then replaces ours (same scope) or the next load's shim removes it (other scope).
Kill switch: when OFFLINE_ENABLED is off, app-origin page loads inject assets/mobiweb/unregister-sw.js. It unregisters a MobiWeb SW left behind by an earlier build that had the flag on, and deletes mobiweb-offline-v1. The web app's own SWs are untouched.
Layer C: native baseline
ConnectivityMonitor (registerDefaultNetworkCallback, needs ACCESS_NETWORK_STATE):
Offline →
setCacheMode(LOAD_CACHE_ELSE_NETWORK), so the Chromium HTTP cache serves whatever it still holds. Online →LOAD_DEFAULT.It is always set on
ServiceWorkerController.getServiceWorkerWebSettings(), because requests issued by our SW follow the SW settings, not the WebView's. That way the HTTP cache backs up SW cache misses.It is set on the WebView only while our SW is not in control (web app owns a SW, or ours isn't installed yet). With our SW active, the WebView stays on
LOAD_DEFAULTso every page request goes through the SW unaltered. (This was first added for a device symptom, JS/CSS failing offline while the document loaded, that turned out to be the startup race below; it is kept as a safeguard.) The registration shim reports "our SW is active" throughAndroidOffline._swActive(). Native persists it, so an offline cold start knows it before any page loads. A detected conflict clears it.
Online state comes from the
NetworkCallbackarguments, not from re-querying the active network inside the callback (which is racy). A staleonLostfor a network the device has already switched away from is ignored.False error overlay (found on device). With our SW in control, its network-first attempt for the navigation fails offline and Android reports that to
onReceivedErroras a main-frame error (ERR_INTERNET_DISCONNECTED), even though the SW then serves the cached page. So while our SW is active, a main-frame network error doesn't show the overlay immediately: after ~1 s the app checkslocation.protocoland shows it only if the WebView really ended on Chromium's error page.Startup race (found on device, pre-existing code). At launch the start URL is loaded twice (from
onCreate, and by the "redirect away from Capacitor'slocalhost" path). The redirect used to callstopLoading()before checking whether the WebView was already at the start URL. Online the network is slow enough that this never mattered; offline the SW commits the cached page so fast thatstopLoading()landed after it, cancelling all its JS/CSS (blank page, or HTML without styles). The check now runs beforestopLoading().On reconnect, if the native error overlay is showing it retries automatically, and the write queue is flushed.
Every transition dispatches
mobiweb:connectivity{ online }.
Layer C works even when Layer A is skipped, but it depends on HTTP cache headers and Chromium eviction.
Write queue: AndroidOffline (opt-in)
Method | Returns | Notes |
|---|---|---|
|
|
|
| JSON array of queued items | Includes |
|
| Aborts the item's request if it is in flight |
| — | Empties the queue |
| — | Replay now, cutting any backoff short (also automatic on reconnect, and on the first app page load after start) |
| — | Deletes the SW cache (deferred to the next app-origin load if needed) |
|
| |
|
|
Rules:
methodmust be one ofPOST/PUT/PATCH/DELETE.urlis resolved againstINITIAL_URLand must be on the app origin: same scheme, host and port.enqueueis only accepted while the WebView's main frame shows an app-origin page. A foreign page (e.g. a provider consent page) gets{"ok":false}.Header names or values containing CR/LF are rejected at
enqueue.Cookie,HostandContent-Lengthheaders are managed natively;Origin/Refererdefault to the app origin andX-Requested-Withto the package name (as the WebView sends it).Limits: body ≤ 1 MB,
meta≤ 4 KB, at most 500 queued items ({"ok":false,"error":"Queue full…"}). Every store operation re-encrypts the whole file, which is why the queue is bounded.Replay sends the WebView's cookies and stores
Set-Cookie. Capacitor'sCapacitorCookiesinstalls a process-wideCookieHandlerbacked by the WebViewCookieManager, andHttpURLConnectionuses it automatically. The replayer only handles cookies by hand when no such handler exists, so there is never a duplicateCookieheader.Items are stored in an app-private file, Tink AES-256-GCM encrypted with the same Keystore-backed keyset as
CookieSecureStore. They survive app restarts.Only genuinely corrupt content (bad ciphertext / JSON) resets the file. A transient failure (Keystore or I/O) leaves it in place, and replay retries later.
Replay is strict FIFO:
2xx →
mobiweb:offlineQueueItemSent.3xx, 4xx, or
maxAttemptsreached → removed +mobiweb:offlineQueueItemFailed. Redirects are not followed: the common 3xx is a302to the login page on an expired session, i.e. the write was not applied.5xx → kept, counts as an attempt, replay stops, and it retries with exponential backoff (5 s doubling to a 5 min cap).
Network error (no response) → kept, does not count as an attempt (the server never processed it), replay stops, same backoff.
A new
enqueuedoes not cut a pending backoff short, so a failing head item doesn't burn its attempts. Reconnect andflush()do retry immediately.If the head item was sent (or failed terminally) but can't be removed from the store (e.g. disk full), its event is still dispatched and its id is remembered in memory: this process never sends it again, and replay pauses until the removal succeeds. A process restart loses that memory and may re-send it.
If the queue is wiped or the item is removed while its request is in flight, the request is aborted and its response discarded: no cookie write-back and no item event. The abort is best-effort: a response that has already been read by the time the wipe runs is still processed by the cookie handler, and the logout's own
removeAllCookiesthen clears it.An unsupported HTTP method is dropped as failed; any other
ProtocolException(Android'sHttpURLConnectionraises one for a mid-response connection drop) is treated as a network error and the item is kept.Each flush ends with
mobiweb:offlineQueueFlushed{ remaining }.
Event detail:
{ id, status, attempts, body?, error?, meta? }(response body truncated to 64 KB).Queue events are delivered to the current page. While no app-origin page is loaded (Capacitor's local shell at startup, the native error overlay), they are held (last 200) and delivered on the next app-origin page load. The startup replay itself waits for that first app page.
mobiweb:connectivityis never held.
Wipe
Both logout paths wipe the SW cache and the write queue, aborting any in-flight replay:
the JS
AndroidCookie.clearCookies(...)the native
REDIRECT_URL_PREFIXlogout (clearCookiesAndRedirect)
Otherwise a previous user's cached data or queued writes could reach the next session. AndroidOffline.clear()/clearCache() wipe each one separately. A cache wipe requested while the page isn't on the app origin is deferred to the next app-origin page load.
Security note: the bridge is reachable from every frame
addJavascriptInterface objects are injected into every frame of the WebView, including cross-origin iframes embedded in the app page. AndroidOffline.enqueue validates the target URL and the main-frame page, not the calling frame, and replays carry the user's cookies with Origin/Referer set to the app origin. A malicious cross-origin iframe inside the app could therefore queue an authenticated write that passes an Origin-only CSRF check. This is the same trust model as every other MobiWeb bridge, but this is the first bridge that writes with the user's credentials. Mitigations if the app embeds untrusted frames: keep server-side CSRF tokens in the request (headers/body), or move the bridge to WebViewCompat.addWebMessageListener with an allowed-origin list (origin-scoped and frame-aware).
Limitations
Only GETs are cached. A
POST-based read API won't be available offline.no-storeresponses are deliberately never cached (except the app shell, see the SW rules), so endpoints that send it are not available offline.Cached responses are protected by the Android app sandbox only; they are not encrypted at rest. The write queue is encrypted.
Write-queue bodies are strings only (JSON / form text), not binary or multipart.
The cache only contains what the user has visited, plus the start URL and the first page's same-origin assets (warm-up). Nothing else is precached.
A web app with its own SW gets only Layer C. A page CSP with a restrictive
worker-srcalso blocksregister(); the shim only logs aconsole.warn.The kill switch unregisters the whole registration at scope
/. If a web-app worker was waiting behind ours, it goes too, and the web app re-registers it on its next load.OfflineManager.start()replaces Capacitor's ownServiceWorkerClient(used only for its localhttps://localhostassets, where no SW exists) andstop()resets it tonull.The app host is the
INITIAL_URLhost. If the site redirects to another host (e.g.www.), the SW is never registered there and wipes don't reach it. SetINITIAL_URLto the final host.ACCESS_NETWORK_STATEis declared in the manifest regardless of the flag. It is a normal permission with no runtime prompt. With the flag off, startup also deletes a queue file left by a build that had it on."Online" means the default network has
NET_CAPABILITY_INTERNET, notVALIDATED: a captive portal counts as online. The replayer copes (no response = not an attempt, backoff), but the cache-mode flip andmobiweb:connectivitydon't fire there.VALIDATEDwas not used because some enterprise networks never validate, which would pin the app in offline mode while it is online.Backup exposure (pre-existing, now wider). The manifest has
android:allowBackup="true"with nofullBackupContent/dataExtractionRules, so Auto Backup and device-to-device transfer includeapp_webview/, i.e. the WebView's localStorage / IndexedDB and now the SW cache with full API response bodies. The encrypted queue is Keystore-bound and useless off-device. Decide whether to excludeapp_webviewfrom backups or disable backup for this app.Still to verify on a device: whether Chromium sends the SW main-script fetch through
ServiceWorkerClientand/orWebViewClient(both are hooked), andPATCHreplay (the desktop JDK used by unit tests rejectsPATCH; Android'sHttpURLConnectionaccepts it).
iOS
Not implemented in this round. Findings:
WKURLSchemeHandlercannot registerhttp/https, so there is no public API to intercept the remote origin's requests. That rules out the Android Layer A trick of serving a SW script.Service workers in
WKWebViewrequireWKAppBoundDomains(Info.plist) pluslimitsNavigationsToAppBoundDomains. Even then, the SW script has to come from the network, which means the web app would have to host it.Options considered:
Custom-scheme proxy: load the app under e.g.
mobiweb://example.comand proxy it throughURLSessionwith a cache. This changes the origin, which breaks HttpOnly session cookies,localStorage/ IndexedDB continuity, CORS (theOriginheader becomes the custom scheme), OAuth redirects and third-party iframes. Rejected for now.loadSimulatedRequest(iOS 15+) /loadHTMLString(baseURL:)with a cached HTML snapshot keeps the origin, but covers the document only: subresources still hit the network and fail.fetch/XHR JS shim (
WKUserScript) that falls back to a native cache through a message handler: covers API GETs, but not the initial document, scripts or styles.
NSURLCache/URLCache.shareddoes not apply: WKWebView's network process ignores the app's URL cache.WKWebsiteDataStorehas no offline-mode toggle.WKContentRuleListonly blocks.
Two shippable iOS options, in order of value:
Parity with a one-file hosting change (no SPA code). Add
WKAppBoundDomains = [app host]and setlimitsNavigationsToAppBoundDomains, publish the sameoffline-sw.jsas a static file on the web host (or an edge/CDN rewrite rule), and inject the sameregister-sw.jsthrough aWKUserScript. This reuses the SW, the shim and their node tests unchanged. To verify on a device: App-Bound Domains limits top-level navigations to the listed domains (≤ 10); native OAuth runs inASWebAuthenticationSessionand is unaffected, and the WebRTC call iframe is a subframe, but test both. Open question: does a static file on the web host count as "modifying the PWA"?Native baseline now (Layer C equivalent).
NWPathMonitor+didFailProvisionalNavigation/didFail→ offline screen with auto-retry, and while offline load the main document withURLRequest(cachePolicy: .returnCacheDataElseLoad). WebKit propagates the main-resource cache policy to subresource loads, so the shell and static assets still in WebKit's disk cache come back; API JSON depends on its cache headers. Plus anAppleOfflinequeue bridge mirroringAndroidOffline.
Testing (Android)
Automated:
yarn test:offline-swruns thenode:testsuite (android/app/src/test/js/offline-sw.test.mjs). It executes the realoffline-sw.js,register-sw.jsandunregister-sw.jsagainst an in-memory Cache Storage (Vary-aware, with spec-strictwaitUntil) and a mocknavigator.serviceWorker. It covers:SW: interception rules; network-first;
no-store/ redirected / non-200 / opaque / streaming handling; offline, SPA-shell and Vary fallbacks; the lie-fi timeout and late caching; warm-up; eviction; wipe; activationshims: registration, conflict detection (active / waiting / installing) and the kill switch
cd android && ./gradlew :app:testDebugUnitTestruns the Robolectric + MockWebServer tests (app/src/test/java/mobiweb/offline/). They cover:SW-script serving and the shim assets
enqueuevalidation: origin incl. port, method, header stripping, CR/LFthe encrypted queue store: corrupt vs transient failures
replay: FIFO, 2xx / 4xx / 5xx / max attempts, network errors (not counted), offline, cookies incl. the default
CookieHandlerpath, redirects, wipe andremove(id)during an in-flight request, remove failure (no duplicate send), backoff vs non-forced flushmanager: main-frame origin gate, held events, startup replay deferred to the first app page, body / meta / queue-length caps
ConnectivityMonitor: callback-driven state, staleonLost, dedupe, start/stop idempotenceSW additions: last-navigation fallback, byte budget and opaque cap
Not covered:
start()/ServiceWorkerControllerwiring and theonPageFinishedinjection need a device.
Manual, on a device:
Set
OFFLINE_ENABLED=true(for example in thedebugbuildType) and run the app.In
chrome://inspect→ Application, check that/__mobiweb_offline_sw.jsis active and that themobiweb-offline-v1cache fills while you browse.After one online launch, turn on airplane mode, cold-start the app, and check that the start page (warm-up) and previously visited pages and GET data render.
On a route you never visited, check that the start-URL fallback (or the error overlay) appears, then turn airplane mode off and check that it auto-retries.
Offline, run
AndroidOffline.enqueue(...), relaunch online, and watch themobiweb:offlineQueue*events.Run
AndroidCookie.clearCookies(false), or hit theREDIRECT_URL_PREFIXlogout, then check that the cache andAndroidOffline.list()are empty.Build with the flag off over a build that had it on, then check that the SW is unregistered,
mobiweb-offline-v1is gone andfiles/mobiweb_offline_queue.binwas deleted.Set
INITIAL_URLto a path the server redirects (e.g./→/doctor), go through one online launch, then an offline cold start: the last-navigation fallback must render the app.Expired session: queue a write offline, invalidate the session server-side, reconnect; expect
offlineQueueItemFailedwith status 302/401, neverSent.Release (R8) build:
AndroidOffline.*still callable from the console.
Minimum device matrix (minSdk 24): API 24/25 with the last WebView that supports them (119), API 28 (WebView ~138), API 30–33 current WebView, API 35/36 current WebView; plus one low-free-space device and one R8 release build.