Native IndexedDB is built on events: open returns a request object, you attach onsuccess and onerror, and nested operations turn into a ladder of callbacks. The code works but is noisy, and it is easy to get wrong. Hence the temptation to reach for a wrapper - and it helps to see what it does not remove: the abstraction cuts the ceremony, the ritual of the event-based API, but not your knowledge of transactions, keys and migrations. The library makes the same platform calls, only more conveniently; versions, upgradeneeded and the rules of a transaction's life stay yours.
The choice is usually between three options. The native API means zero dependencies and full control at the price of verbose event-driven code. The idb library is a thin Promise wrapper around the same primitives: open becomes openDB with an upgrade callback, requests become awaitable; you build the schema and reactive layer yourself. Dexie goes further: a declarative schema, a query language, transactions and liveQuery for reactive subscriptions - for which you pay with abstraction and bundle weight.
| Choice | Strength | Cost |
|---|---|---|
| Native IndexedDB | Zero dependencies, full control | Event-based API, much ceremony |
| idb 8.0.3 | Thin Promise wrapper, close to the platform API | You build the schema and reactive layer yourself |
| Dexie 4.4.4 | Convenient schema, queries, transactions, liveQuery | More abstraction and bundle weight |
Whatever wrapper you pick, keep it away from the rest of the code. Introduce a repository layer so the UI knows nothing about the concrete library: notes.list(), notes.save(), outbox.pending(). This is not decoration - it is what makes the database testable and replaceable. Components call repository methods, and inside can sit idb, Dexie or a mock in tests. When you decide to swap the storage, you change one module, not a hundred spots.
The other half of the picture is disk space, and here the naive model breaks: browser storage is not infinite and by default may be cleared under disk pressure. This is eviction. An origin has a quota - a limit the browser assigns while watching free space and user behaviour - and when the system decides to reclaim disk, a best-effort origin's data is evicted as a whole. The estimate comes from navigator.storage.estimate(): it returns usage and quota in bytes.
The defence against eviction is persistent storage. Calling navigator.storage.persist() asks to move the origin into a durable mode where data is not removed automatically under disk pressure; navigator.storage.persisted() reports the state. But both the quota size and the decision to grant persistence belong to the browser, not to you. Hence the rule: do not ask for persistence on the first screen - in a vacuum the prompt gets denied. Tie it to value: the user has downloaded an offline pack of notes - now it is appropriate to ask not to delete it.
Even persistent storage, once granted, is not a server backup. The user can clear site data by hand, switch devices, reinstall the browser. Durable only means the system will not evict the data itself under a space shortage - a guarantee against background eviction, not against loss. So an outbox of unsent edits is a transit state, not an archive: the moment the server confirms an operation, the source of truth is back on it. Persistent removes one class of failure but does not replace synchronization.
In practice it comes down to a few habits. Show the offline pack's size before download, so the user understands the cost. Let people delete downloads separately from drafts and outbox: heavy cacheable media can go, unsaved intent cannot. Apply LRU or TTL to such media. Always keep headroom and catch QuotaExceededError on writes. And the cardinal anti-pattern: never clean the outbox as a background space optimization - that silently loses edits the user believed were saved.
import { openDB } from 'idb';
export const db = openDB('offline-notes', 3, {
upgrade(db, oldVersion, newVersion, tx) {
if (oldVersion < 1) db.createObjectStore('notes', { keyPath: 'id' });
if (oldVersion < 2) db.createObjectStore('outbox', { keyPath: 'operationId' });
if (oldVersion < 3) tx.objectStore('notes')
.createIndex('syncState', 'syncState');
},
blocked() { showCloseOtherTabsMessage(); },
blocking() { location.reload(); }
});const { usage = 0, quota = 0 } = await navigator.storage.estimate();
const ratio = quota ? usage / quota : 0;
let persistent = await navigator.storage.persisted();
if (!persistent && shouldAskAfterMeaningfulUse()) {
persistent = await navigator.storage.persist();
}