An HTML app inside an APK saves data with the same web storage APIs a browser offers — localStorage for small key-value state and IndexedDB for anything structured or large — and both persist across app restarts, phone reboots and app updates. They are cleared only when the user clears the app's data or uninstalls it. There is no server, so there is nothing to sync unless you add one; the trade-off is that the data lives on that one phone, which is why an export button is worth adding to anything the user would mind losing.
The options
| localStorage | IndexedDB | Downloaded file | Cookies | |
|---|---|---|---|---|
| Stores | Strings, by key | Objects, blobs, indexes | Anything, as a file the user keeps | Small strings |
| Size | ~5 MB | Hundreds of MB (quota-managed) | Unlimited | 4 KB |
| API | Synchronous, two lines | Asynchronous, verbose (use a wrapper) | Blob + download link | Awkward |
| Survives app update | Yes | Yes | Yes (it is outside the app) | Yes |
| Survives uninstall | No | No | Yes | No |
| Use for | Settings, progress, small lists | Notes, records, images, anything that grows | Backups, exports | Nothing, in a bundled app |
localStorage: the default
// save
localStorage.setItem('settings', JSON.stringify({ theme: 'dark', sound: true }));
// load, with a fallback for the first run
const settings = JSON.parse(localStorage.getItem('settings') || '{"theme":"light","sound":true}');
Synchronous, simple, and enough for most small apps. Two habits: always JSON.stringify on the way in and parse on the way out (it stores strings only), and always supply a fallback, because the first launch has nothing saved. Keep individual values small — writing a 2 MB string on every keystroke will jank the UI. The starter file includes a two-line save/load helper that does exactly this.
IndexedDB: when it grows
For a notes app, a collection, a journal, anything with hundreds of records or with images, use IndexedDB. Its raw API is famously verbose; a small wrapper such as idb-keyval (a 600-byte library you can download and inline) turns it into await set('note-42', note) and await get('note-42'). Storage quota on Android is generous — typically a percentage of free disk — and the browser asks nothing of the user until it is nearly full.
What "persistent" means in practice
- App restart, phone reboot: data is there. Both stores are written to the app's private data directory.
- App update (same package name, same signing key, higher version code): data is there. Android keeps the data directory across updates. See the versioning guide — a different package name or key makes it a different app with empty storage.
- "Clear data" in Android settings, or uninstall: gone. This is by design and the user expects it.
- Low storage: Android can, rarely, evict web storage for apps it considers unimportant. A bundled app in the foreground is not at risk; persist a backup anyway if the data matters.
Backup: give the user a file
Because the data lives on one phone, the kindest feature you can add is export and import. Export is a download:
function exportData() {
const blob = new Blob([JSON.stringify(localStorage)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'my-app-backup.json';
a.click();
}
Inside the APK, that download goes to the phone's Downloads folder, where the user can share it to Drive, email or another phone. Import is a file input that reads the JSON back and writes each key. Ten lines each, and the app's data can outlive the phone.
What does not work
fetch('data.json') to read a file from the bundle fails inside an APK — local-file requests are blocked by the WebView's origin rules. Inline the data as a JavaScript object instead (const DATA = {…} in a script tag), or embed it in a hidden element. Writing files directly to the phone's storage from JavaScript is not possible either; the download pattern above is the way. Cookies work but have no advantage over localStorage here. Service workers and the Cache API do not register from a bundled page — and are unnecessary, since the bundle is already offline.