A notes app has to work without a network - and the first question is where data lives on the client. Instinct reaches for localStorage, but it is synchronous, stores strings only, and is sized for kilobytes of settings. IndexedDB is a transactional object database: a browser-native store for significant volumes of structured data. Not a big localStorage but a database - with transactions, keys, indexes and a versioned schema. This decides whether drafts survive a tab reload and a sudden offline.
IndexedDB stores values compatible with structured clone - the algorithm the browser uses to copy data between threads: objects, arrays, dates, blobs and files. The unit of storage is an object store, the analogue of a collection. A key path declares which field serves as the primary key, and an index is a secondary key for fast lookups by another field. The API is asynchronous and runs in both the window and the service worker - the latter matters: the worker runs synchronization once the page is already closed.
The beginner's mistake is to dump raw server responses into the store. Store the entity in a shape convenient for local querying, not a copy of every response. Alongside the note's fields add synchronization fields: serverVersion and localVersion, to tell someone else's edit from your own, syncState for the record's state, updatedAt and, when needed, a tombstone - a deletion marker you cannot erase immediately, or the server never learns of the deletion. And do not take the server timestamp as the only ordering of events: client clocks lie.
Everything the database does to data happens inside a transaction, and it has a non-obvious property: it lives exactly as long as active requests are tied to it. The moment the microtask queue drains and no request is pending, the transaction auto-commits. Hence the classic trap: an arbitrary await between two requests of one transaction closes it early, and your next put reaches an already finished transaction - you get an error.
Readonly mode allows parallel reads; readwrite applies related changes atomically - all of them or none. That is what makes an offline write reliable. An optimistic mutation is two durable steps: update the note and write the operation into the outbox, the queue for sending to the server. As separate transactions a failure can land: the note is changed but the operation is lost. One readwrite transaction over both stores closes that window - intent and queue are always consistent.
The schema changes through an upgrade. Opening the database with a version above the current one fires the upgradeneeded event, and its handler runs inside a special versionchange transaction - the only place where you can create and delete object stores and indexes. The difficulty is multiple tabs: if another tab holds an old connection open, the upgrade is blocked. The correct scenario is to listen for versionchange, close the old connection and ask the user to refresh, while the new tab handles the blocked event.
The migrations themselves must be fast: versionchange keeps the database locked, and a heavy pass over every record freezes startup. For large data make the migration resumable or lazy - bring a record to the new shape on first read. A typical failure: a release rewrites tens of thousands of notes in the upgrade; a user with a full database opens a second tab, the upgrade sits in blocked, the first hangs on a white screen. The cure is splitting the migration and handling blocked honestly, not hoping everyone's data is small.
const request = indexedDB.open('offline-notes', 3);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('notes')) {
const notes = db.createObjectStore('notes', { keyPath: 'id' });
notes.createIndex('updatedAt', 'updatedAt');
notes.createIndex('syncState', 'syncState');
}
if (!db.objectStoreNames.contains('outbox')) {
const outbox = db.createObjectStore('outbox', { keyPath: 'operationId' });
outbox.createIndex('createdAt', 'createdAt');
}
};function saveNoteAndQueue(db, note, operation) {
return new Promise((resolve, reject) => {
const tx = db.transaction(['notes', 'outbox'], 'readwrite');
tx.objectStore('notes').put(note);
tx.objectStore('outbox').put(operation);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}