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.
Choosing
Section titled “Choosing”| Need | Use | Quota | Syncs across devices |
|---|---|---|---|
| Small preference, should follow the user | sollar.storage | 256 KB | yes |
| Structured local data, offline records | IndexedDB | ~50 MB | no |
| Trivial local flag | localStorage | ~5 MB | no |
| Static assets for offline launch | Cache API + service worker | shared with IndexedDB | no |
| Anything secret | nowhere on the client | — | — |
sollar.storage
Section titled “sollar.storage”await sollar.storage.set('preferred_view', 'compact')const view = await sollar.storage.get('preferred_view') // undefined if unsetawait 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.
IndexedDB
Section titled “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.
Offline
Section titled “Offline”"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.
A service worker for the shell
Section titled “A service worker for the shell”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)))})A write queue
Section titled “A write queue”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.
Be honest in the UI
Section titled “Be honest in the UI”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.
What you never cache
Section titled “What you never cache”- 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')Quotas and eviction
Section titled “Quotas and eviction”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.
Uninstall
Section titled “Uninstall”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.