Skip to content

Offline and storage

A mini app runs at its own synthetic origin — https://<appid>.miniapp.localhost/ on Android, served through a custom scheme handler on iOS. Every web storage mechanism is therefore partitioned to that origin by the browser engine, not by a convention the runtime remembers to apply.

That distinction is the point. Cache reuse between mini apps is the first of six vulnerability categories documented across nine mini-program ecosystems, and it exists in those platforms because they store everything in a shared directory and rely on the runtime to keep the paths apart. Origin isolation is a rule the engine cannot violate.

NeedUseQuotaSyncs across devices
Small preference, should follow the usersollar.storage256 KByes
Structured local data, offline recordsIndexedDB~50 MBno
Trivial local flaglocalStorage~5 MBno
Static assets for offline launchCache API + service workershared with IndexedDBno
Anything secretnowhere on the client
await sollar.storage.set('preferred_view', 'compact')
const view = await sollar.storage.get('preferred_view') // undefined if unset
await sollar.storage.remove('preferred_view')

Keyed to (user, mini app). Survives reinstall and follows the user to a new device — which localStorage does not. Use it for the handful of preferences that should follow a person, not for data.

Exceeding 256 KB throws ERR_STORAGE_FULL. It is a preference store; if you are near the limit you want IndexedDB.

Local, per-origin, and the right place for an offline record set.

const db = await new Promise((ok, err) => {
const req = indexedDB.open('acme-erp', 1)
req.onupgradeneeded = () => {
req.result.createObjectStore('orders', { keyPath: 'purchase_order_id' })
}
req.onsuccess = () => ok(req.result)
req.onerror = () => err(req.error)
})

It is not synced and not backed up. Treat it as a cache that can vanish: a user clearing storage, an OS reclaiming space under pressure, or a reinstall all take it. Anything that must survive belongs on your server.

manifest.json
"capabilities": { "offline": true }

Declaring offline lets the mini app launch with no network. The package is already local — it was installed, not fetched — so the shell always loads. What you have to decide is what happens when your API is unreachable.

sw.js
const SHELL = 'shell-v3'
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(SHELL).then((c) =>
c.addAll(['/', '/index.html', '/app.css', '/app.js'])))
})
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url)
// Same-origin: cache first. The package is local; it cannot be stale.
if (url.origin === location.origin) {
e.respondWith(caches.match(e.request).then((r) => r ?? fetch(e.request)))
return
}
// Your API: network first, fall back to the last good response.
e.respondWith(
fetch(e.request)
.then((res) => {
const copy = res.clone()
caches.open('api-v1').then((c) => c.put(e.request, copy))
return res
})
.catch(() => caches.match(e.request)))
})

The harder half of offline is not reading; it is what happens to a write made on a train.

async function queueApproval(orderId, note) {
const tx = (await openDb()).transaction('outbox', 'readwrite')
await tx.objectStore('outbox').add({
id: crypto.randomUUID(), // the idempotency key
kind: 'approve',
order_id: orderId,
note,
queued_at: new Date().toISOString(),
})
}
async function flushOutbox() {
if (!navigator.onLine) return
const { access_token } = await sollar.auth.getToken()
for (const item of await readOutbox()) {
const res = await fetch(`https://erp.acme.example/api/orders/${item.order_id}/approve`, {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
'Idempotency-Key': item.id, // the same key on every retry
},
body: JSON.stringify({ note: item.note }),
})
if (res.ok || res.status === 409) await removeFromOutbox(item.id)
else break // stop; try the whole queue again later
}
}
window.addEventListener('online', flushOutbox)

Three things that are not optional:

The idempotency key is generated when the item is queued, not when it is sent. A retry after a timeout on a request the server did process is the normal case, and only a stable key makes the second attempt harmless.

409 counts as done. “Already approved” means the queue succeeded on an earlier attempt whose response was lost.

Stop at the first hard failure. Draining the rest of a queue against a server that is refusing requests turns one problem into many.

An action that is queued is not an action that happened. Show it as pending. A mini app that renders an approval as complete when it is sitting in an outbox has told the user something untrue, and they will act on it.

  • Tokens. Call getToken() each time.
  • Anything you would not want read off a stolen, unlocked device.
  • Personal data beyond what the current screen needs.

When a user is deprovisioned, your backend receives user.deprovisioned and must delete their data — but you cannot reach the copy on their device. Cache the minimum, and clear it on sign-out:

await sollar.storage.clear()
for (const key of await caches.keys()) await caches.delete(key)
indexedDB.deleteDatabase('acme-erp')

Storage is subject to eviction under pressure, and mini apps are evicted before the host. Do not build anything whose correctness depends on the cache still being there — including a “we already synced this” flag whose loss would silently skip a sync.

Uninstalling removes every trace on the device: package, origin storage, caches, and the sollar.storage entries for that user. Nothing survives except what your backend holds — which is why user.deprovisioned matters, and why the deletion obligation is yours.