Making Save Systems That Work on Web Builds (LocalStorage & IndexedDB)
#webdev
#storage
#offline
#javascript
Introduction
If you ship web apps that work offline or on flaky networks, you’ll need a solid save system. Two core browser storage technologies you’ll reach for are LocalStorage and IndexedDB. LocalStorage is simple and synchronous, while IndexedDB is powerful, asynchronous, and better suited for larger datasets. The trick is to pick the right tool for the job and offer a clean, unified API so your app data flows smoothly across sessions and tabs.
In this guide, you’ll learn:
- When to use LocalStorage vs IndexedDB
- How to design a small, cohesive storage API
- Practical patterns for migrations, cross-tab syncing, and error handling
LocalStorage Basics
What you need to know:
- Synchronous API: localStorage.setItem/getItem run on the main thread.
- Data is stored as strings; you typically JSON.stringify your objects.
- Quotas are small (often around 5–10 MB, varies by browser).
- Per-origin storage is shared across all tabs; you can listen for changes with the storage event.
Pros:
- Very simple to use for small, user-preference data or session tokens.
- No setup required besides a key-value namespace.
Cons:
- Blocking calls can freeze the UI if used in tight loops.
- No built-in indexing; querying is manual and inefficient.
- Limited capacity; not ideal for large data or binary assets.
Common patterns:
- Wrap localStorage in a tiny API that handles JSON serialization and errors.
- Namespace keys (e.g., “myapp_user_settings”) to avoid collisions.
- Use the storage event to invalidate caches across tabs.
Code snippet: a minimal LocalStorage wrapper
// localStorage wrapper for JSON data
export const LocalStore = {
save: (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
// Optional: handle quota or private mode errors
console.warn('LocalStorage save failed', e);
}
},
load: (key, fallback = null) => {
try {
const raw = localStorage.getItem(key);
if (raw == null) return fallback;
return JSON.parse(raw);
} catch (e) {
console.warn('LocalStorage load failed', e);
return fallback;
}
},
remove: (key) => {
try {
localStorage.removeItem(key);
} catch (e) {
console.warn('LocalStorage remove failed', e);
}
},
};
IndexedDB Basics
IndexedDB is an asynchronous, transactional database built into the browser. It supports structured data, large stores, indexes, and versioned migrations.
Key points:
- Asynchronous API (promises or callbacks): does not block the UI.
- Can store large amounts of data and binary blobs.
- Supports object stores, indexes, and transactions.
- Schema migrations are driven by versioning (upgradeneeded).
Pros:
- Scales to larger datasets and more complex data models.
- Good for offline-first apps that need robust query capabilities.
Cons:
- More complex to implement correctly.
- Requires more boilerplate or a helper library.
Common patterns:
- A small wrapper around IndexedDB to provide get/set/delete with JSON values.
- An object store dedicated to the app data (e.g., store = “keyval” with { key, value }).
- A versioned upgrade path to migrate data between schemas.
Code snippet: a compact IndexedDB key-value wrapper (no external libs)
// Very small IndexedDB wrapper for JSON values
export class IDBKeyVal {
constructor(dbName = 'app-db', storeName = 'keyval') {
this.dbName = dbName;
this.storeName = storeName;
this.dbPromise = null;
}
open() {
if (this.dbPromise) return this.dbPromise;
this.dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(this.dbName, 1);
req.onupgradeneeded = (e) => {
const db = req.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'key' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return this.dbPromise;
}
async set(key, value) {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
const req = store.put({ key, value });
req.onsuccess = () => resolve(true);
req.onerror = () => reject(req.error);
tx.oncomplete = () => {}; // optional
});
}
async get(key, fallback = undefined) {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const req = store.get(key);
req.onsuccess = () => resolve(req.result ? req.result.value : fallback);
req.onerror = () => reject(req.error);
});
}
async delete(key) {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
const req = store.delete(key);
req.onsuccess = () => resolve(true);
req.onerror = () => reject(req.error);
});
}
async clear() {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
const req = store.clear();
req.onsuccess = () => resolve(true);
req.onerror = () => reject(req.error);
});
}
}
Choosing the Right Tool
- Use LocalStorage for small, simple preferences or flags that you need synchronously on page load, such as theme, user ID, or last viewed tab.
- Use IndexedDB for larger caches, offline data, or any dataset you want to query, filter, or persist across sessions.
- Consider a hybrid approach: store user settings in LocalStorage for fast access, and keep app data in IndexedDB. If you need quick user-facing toggles or tokens, LocalStorage is fine. If you need offline-first data or large datasets, IndexedDB wins.
Design tip:
- Build a small, unified API that hides the storage implementation behind a single interface, for example:
- getItem(key): returns parsed JSON or default
- setItem(key, value): stores JSON
- removeItem(key)
- clearAll(): clears app-related data
- Provide a clear namespace to avoid clashes with other apps or libraries.
Patterns for a Unified API (Practical Approach)
- Create a storage facade that falls back gracefully:
- Prefer IndexedDB if available; otherwise, degrade gracefully to LocalStorage for non-critical data.
- Use namespaced keys for LocalStorage and a single object store for IndexedDB.
- Implement a migration plan to move data from LocalStorage to IndexedDB when the app detects capabilities.
Example concept:
- LocalStorage keys: “myapp_pref_theme”, “myapp_cache_user”
- IndexedDB store: “myapp_store” with entries { key, value }
Cross-tab sync:
- LocalStorage supports a storage event. Use it to invalidate or refresh in-memory caches when a tab updates data.
- IndexedDB events aren’t cross-tab in the same way, so rely on periodic checks or server sync for multi-tab consistency.
Migration and Sync Considerations
- Start with a small migration path: when the app boots, check for data in LocalStorage and optionally migrate to IndexedDB.
- Keep a version flag in IndexedDB to manage upgrades. If a user has data only in LocalStorage, offer a one-time migration to IndexedDB on their first run.
- For offline-first apps, consider a simple sync strategy: local writes in IndexedDB, and a background sync to a server when online becomes available.
- Be mindful of data formats during migration; you’ll typically use JSON as the interchange format.
Code snippet: simple migration check (conceptual)
async function migrateLocalToIndexedDB() {
const maybeTheme = LocalStore.load('myapp_pref_theme', null);
if (maybeTheme != null) {
const idb = new IDBKeyVal('app-db', 'keyval');
await idb.set('myapp_pref_theme', maybeTheme);
LocalStore.remove('myapp_pref_theme');
}
}
Practical Testing Tips
- Test with low storage quotas and privacy modes to ensure your app handles storage failures gracefully.
- Simulate offline scenarios: disconnect network, reload, and verify that data persistence and reads still work when reconnected.
- Validate data schemas during migrations and provide users with a clean fallback if anything goes wrong.
Conclusion
A robust save system in web builds should leverage LocalStorage for simple, fast, small data and IndexedDB for larger, offline-capable data. By wrapping storage access behind a clean API, you reduce coupling, enable seamless migrations, and improve reliability across tabs and sessions. Start with a minimal, testable wrapper, and evolve your pattern to fit your app’s data needs and offline requirements.