Skip to content

Storing Offline Data with IndexedDB

The browser gives you three main client-side storage mechanisms, each designed for a different job. Picking the right one prevents subtle bugs and keeps your offline experience reliable.

localStorageCache StorageIndexedDB
StoresKey/value stringsHTTP request/response pairsStructured objects (any JS value)
Async?No (synchronous, blocks the main thread)Yes (Promise-based)Yes (event-based / Promise wrappers)
Queryable?NoBy URL onlyYes — indexes, key ranges, cursors
Size limit~5 MBQuota-based (~hundreds of MB)Quota-based (~hundreds of MB)
Available in SW?NoYesYes
Best forTiny settings / flagsCaching network resourcesApp data: tasks, messages, user records

The rule of thumb: use Cache Storage for the network assets your service worker caches (HTML, CSS, JS, images), and use IndexedDB for the actual data your application works with.

IndexedDB is a transactional, object-oriented database built into every modern browser. It stores JavaScript objects in named object stores, identified by a key path you choose.

const request = indexedDB.open('my-app-db', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create an object store keyed on the 'id' property
if (!db.objectStoreNames.contains('tasks')) {
db.createObjectStore('tasks', { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
const db = event.target.result;
console.log('Database opened:', db.name);
};
request.onerror = (event) => {
console.error('Failed to open database:', event.target.error);
};

indexedDB.open(name, version) returns an IDBOpenDBRequest. The onupgradeneeded callback fires whenever the version number increases — this is the only place you can create or modify object stores and indexes.

Once you have a db reference, wrap every operation in a transaction. Pass the store name and the mode — 'readwrite' to write, 'readonly' to read.

function saveTask(db, task) {
return new Promise((resolve, reject) => {
const tx = db.transaction('tasks', 'readwrite');
const store = tx.objectStore('tasks');
const putRequest = store.put(task); // insert or replace by keyPath
putRequest.onsuccess = () => resolve(putRequest.result);
putRequest.onerror = () => reject(putRequest.error);
});
}
// Usage
saveTask(db, { id: 'task-1', title: 'Buy groceries', done: false });

store.put(record) inserts the object if the key does not exist, or replaces it if it does. Use store.add(record) if you want an error on duplicate keys instead.

function getTask(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction('tasks', 'readonly');
const store = tx.objectStore('tasks');
const getRequest = store.get(id);
getRequest.onsuccess = () => resolve(getRequest.result); // undefined if not found
getRequest.onerror = () => reject(getRequest.error);
});
}
// Usage
getTask(db, 'task-1').then((task) => console.log(task));

To read every record, open a cursor on the store.

function getAllTasks(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction('tasks', 'readonly');
const store = tx.objectStore('tasks');
const results = [];
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
cursorRequest.onerror = () => reject(cursorRequest.error);
});
}

The second argument to indexedDB.open() is the schema version. Every time you increment it, onupgradeneeded fires with event.oldVersion and event.newVersion available, letting you migrate the schema incrementally.

request.onupgradeneeded = (event) => {
const db = event.target.result;
const oldVersion = event.oldVersion;
if (oldVersion < 1) {
db.createObjectStore('tasks', { keyPath: 'id' });
}
if (oldVersion < 2) {
// Version 2 adds a 'notes' store
db.createObjectStore('notes', { keyPath: 'id' });
}
};

Never delete a store or index without checking oldVersion — users may be upgrading from any past version, not just the previous one.

The raw event-based API is verbose. In production, most teams use a thin wrapper like the idb library (a few KB) that surfaces the same API as Promise chains. The concepts are identical — open, transaction, store, put/get — the wrapper just removes the boilerplate event handlers.

sequenceDiagram
  participant App as Page / SW
  participant IDB as IndexedDB

  App->>IDB: indexedDB.open('my-app-db', 1)
  IDB-->>App: onupgradeneeded (create stores)
  IDB-->>App: onsuccess (db handle)
  App->>IDB: db.transaction('tasks', 'readwrite')
  App->>IDB: store.put(task)
  IDB-->>App: onsuccess (key)
  App->>IDB: store.get('task-1')
  IDB-->>App: onsuccess (record)
IndexedDB open, write, and read sequence
Which browser storage API is available inside a service worker?
When does the onupgradeneeded callback fire?
What is the difference between store.put() and store.add()?
What is the correct storage choice for caching a JavaScript bundle so it loads offline?